diff --git a/src/components/PresentationContext.tsx b/src/components/PresentationContext.tsx index fae8c8265..2aa8cb00d 100644 --- a/src/components/PresentationContext.tsx +++ b/src/components/PresentationContext.tsx @@ -2,6 +2,8 @@ import { createContext, PropsWithChildren, useContext, useMemo } from "react"; import { GridStyle } from "src/components/Table"; import { Typography } from "src/Css"; +export type InputStylePalette = "success" | "warning" | "caution" | "info"; + export interface PresentationFieldProps { numberAlignment?: "left" | "right"; /** Sets the label position or visibility. Defaults to "above" */ @@ -23,10 +25,12 @@ export interface PresentationFieldProps { errorInTooltip?: true; /** Allow the fields to grow to the width of its container. By default, fields will extend up to 550px */ fullWidth?: boolean; + /** Changes bg and hoverBg; Takes priority over `contrast`; Useful when showing many fields w/in a table that require user attention; In no way should be used as a replacement for error/focus state */ + inputStylePalette?: InputStylePalette; } export type PresentationContextProps = { - fieldProps?: PresentationFieldProps; + fieldProps?: Omit; gridTableStyle?: GridStyle; // Defines whether content should be allowed to wrap or not. `undefined` is treated as true. wrap?: boolean; diff --git a/src/inputs/SelectField.stories.tsx b/src/inputs/SelectField.stories.tsx index 45771b7c0..e59092fef 100644 --- a/src/inputs/SelectField.stories.tsx +++ b/src/inputs/SelectField.stories.tsx @@ -31,7 +31,7 @@ type TestOption = { icon?: IconKey; }; -const options: TestOption[] = [ +const standardOptions: TestOption[] = [ { id: "1", name: "Download", icon: "download" }, { id: "2", name: "Camera", icon: "camera" }, { id: "3", name: "Info Circle", icon: "infoCircle" }, @@ -39,6 +39,14 @@ const options: TestOption[] = [ { id: "5", name: "Dollar dollar bill, ya'll! ".repeat(5), icon: "dollar" }, ]; +const coloredOptions: TestOption[] = [ + { id: "1", name: "Download (SUCCESS style palette when selected)", icon: "download" }, + { id: "2", name: "Camera (CAUTION style palette when selected)", icon: "camera" }, + { id: "3", name: "Info Circle (WARNING style palette when selected)", icon: "infoCircle" }, + { id: "4", name: "Calendar (INFO style palette when selected)", icon: "calendar" }, + { id: "5", name: "Dollar dollar bill, ya'll! (NO EXTRA style palette when selected)", icon: "dollar" }, +]; + const optionsWithNumericIds: { id: number; name: string }[] = [ { id: 1, name: "One" }, { id: 2, name: "Two" }, @@ -54,6 +62,7 @@ const booleanOptions = [ function Template(args: SelectFieldProps) { const loadTestOptions: TestOption[] = zeroTo(1000).map((i) => ({ id: String(i), name: `Project ${i}` })); + const options = (args?.options as TestOption[]) ?? standardOptions; return (
@@ -223,6 +232,39 @@ export const Contrast = Template.bind({}); // @ts-ignore Contrast.args = { compact: true, contrast: true }; +// @ts-ignore +function getInputStylePalette(v) { + if (v?.includes(1) || v?.includes("1")) return "success"; + if (v?.includes(2) || v?.includes("2")) return "caution"; + if (v?.includes(3) || v?.includes("3")) return "warning"; + if (v?.includes(4) || v?.includes("4")) return "info"; + return undefined; +} + +const standardColoredSelectArgs = { + options: coloredOptions, + // @ts-ignore + getInputStylePalette, +}; + +export const Colored = Template.bind({}); +// @ts-ignore +Colored.args = standardColoredSelectArgs; + +export const ColoredContrast = Template.bind({}); +// @ts-ignore +ColoredContrast.args = { + contrast: true, + ...standardColoredSelectArgs, +}; + +export const ColoredCompact = Template.bind({}); +// @ts-ignore +ColoredCompact.args = { + compact: true, + ...standardColoredSelectArgs, +}; + const loadTestOptions: TestOption[] = zeroTo(1000).map((i) => ({ id: String(i), name: `Project ${i}` })); export function PerfTest() { diff --git a/src/inputs/TextFieldBase.tsx b/src/inputs/TextFieldBase.tsx index ec50fc717..f6fbc28d3 100644 --- a/src/inputs/TextFieldBase.tsx +++ b/src/inputs/TextFieldBase.tsx @@ -13,7 +13,7 @@ import { chain, mergeProps, useFocusWithin, useHover } from "react-aria"; import { Icon, IconButton, maybeTooltip } from "src/components"; import { HelperText } from "src/components/HelperText"; import { InlineLabel, Label } from "src/components/Label"; -import { usePresentationContext } from "src/components/PresentationContext"; +import { InputStylePalette, usePresentationContext } from "src/components/PresentationContext"; import { BorderHoverChild, BorderHoverParent } from "src/components/Table/components/Row"; import { Css, Only, Palette } from "src/Css"; import { getLabelSuffix } from "src/forms/labelUtils"; @@ -42,6 +42,7 @@ export interface TextFieldBaseProps | "visuallyDisabled" | "fullWidth" | "xss" + | "inputStylePalette" >, Partial, "onChange">> { labelProps?: LabelHTMLAttributes; @@ -103,6 +104,7 @@ export function TextFieldBase>(props: TextFieldB fullWidth = fieldProps?.fullWidth ?? false, unfocusedPlaceholder, selectOnFocus = true, + inputStylePalette, } = props; const typeScale = fieldProps?.typeScale ?? (inputProps.readOnly && labelStyle !== "hidden" ? "smMd" : "sm"); @@ -121,14 +123,16 @@ export function TextFieldBase>(props: TextFieldB const fieldHeight = 40; const compactFieldHeight = 32; - const [bgColor, hoverBgColor, disabledBgColor] = contrast - ? [Palette.Gray700, Palette.Gray600, Palette.Gray700] - : borderOnHover - ? // Use transparent backgrounds to blend with the table row hover color - [Palette.Transparent, Palette.Blue100, Palette.Gray100] - : borderless && !compound - ? [Palette.Gray100, Palette.Gray200, Palette.Gray200] - : [Palette.White, Palette.Gray100, Palette.Gray100]; + const [bgColor, hoverBgColor, disabledBgColor] = inputStylePalette + ? getInputStylePalette(inputStylePalette) + : contrast + ? [Palette.Gray700, Palette.Gray600, Palette.Gray700] + : borderOnHover + ? // Use transparent backgrounds to blend with the table row hover color + [Palette.Transparent, Palette.Blue100, Palette.Gray100] + : borderless && !compound + ? [Palette.Gray100, Palette.Gray200, Palette.Gray200] + : [Palette.White, Palette.Gray100, Palette.Gray100]; const fieldMaxWidth = getFieldWidth(fullWidth); @@ -137,7 +141,7 @@ export function TextFieldBase>(props: TextFieldB inputWrapper: { ...Css[typeScale].df.aic.br4.px1.w100 .bgColor(bgColor) - .gray900.if(contrast) + .gray900.if(contrast && !inputStylePalette) .white.if(labelStyle === "left") .w(labelLeftFieldWidth).$, // When borderless then perceived vertical alignments are misaligned. As there is no longer a border, then the field looks oddly indented. @@ -150,7 +154,7 @@ export function TextFieldBase>(props: TextFieldB // Do not add borders to compound fields. A compound field is responsible for drawing its own borders ...(!compound ? Css.ba.$ : {}), ...(borderOnHover && Css.br4.ba.bcTransparent.add("transition", "border-color 200ms").$), - ...(borderOnHover && Css.if(isHovered).bgBlue100.ba.bcBlue300.$), + ...(borderOnHover && Css.if(isHovered).bgColor(hoverBgColor).ba.bcBlue300.$), ...{ // Highlight the field when hovering over the row in a table, unless some other edit component (including ourselves) is hovered [`.${BorderHoverParent}:hover:not(:has(.${BorderHoverChild}:hover)) &`]: Css.ba.bcBlue300.$, @@ -167,7 +171,7 @@ export function TextFieldBase>(props: TextFieldB }, inputWrapperReadOnly: { ...Css[typeScale].df.aic.w100.gray900 - .if(contrast) + .if(contrast && !inputStylePalette) .white.if(labelStyle === "left") .w(labelLeftFieldWidth).$, // If we are hiding the label, then we are typically in a table. Keep the `mh` in this case to ensure editable and non-editable fields in a single table row line up properly @@ -179,14 +183,14 @@ export function TextFieldBase>(props: TextFieldB input: { ...Css.w100.mw0.outline0.fg1.bgColor(bgColor).$, // Not using Truss's inline `if` statement here because `addIn` properties do not respect the if statement. - ...(contrast && Css.addIn("&::selection", Css.bgGray800.$).$), + ...(contrast && !inputStylePalette && Css.addIn("&::selection", Css.bgGray800.$).$), // Make the background transparent when highlighting the field on hover ...(borderOnHover && Css.bgTransparent.$), // For "multiline" fields we add top and bottom padding of 7px for compact, or 11px for non-compact, to properly match the height of the single line fields ...(multiline ? Css.br4.pyPx(compact ? 7 : 11).add("resize", "none").$ : Css.truncate.$), }, hover: Css.bgColor(hoverBgColor).if(contrast).bcGray600.$, - focus: Css.bcBlue700.if(contrast).bcBlue500.if(borderOnHover).bgBlue100.bcBlue500.$, + focus: Css.bcBlue700.if(contrast).bcBlue500.if(borderOnHover).bgColor(hoverBgColor).bcBlue500.$, disabled: visuallyDisabled ? Css.cursorNotAllowed.gray600.bgColor(disabledBgColor).if(contrast).gray500.$ : Css.cursorNotAllowed.$, @@ -370,3 +374,18 @@ export function TextFieldBase>(props: TextFieldB ); } + +function getInputStylePalette(inputStylePalette: InputStylePalette): [Palette, Palette, Palette] { + switch (inputStylePalette) { + case "success": + return [Palette.Green50, Palette.Green100, Palette.Green50]; + case "caution": + return [Palette.Yellow50, Palette.Yellow100, Palette.Yellow50]; + case "warning": + return [Palette.Red50, Palette.Red100, Palette.Red50]; + case "info": + return [Palette.Blue50, Palette.Blue100, Palette.Blue50]; + default: + return [Palette.White, Palette.Gray100, Palette.Gray100]; + } +} diff --git a/src/inputs/internal/ComboBoxBase.tsx b/src/inputs/internal/ComboBoxBase.tsx index 7da44333f..62c602382 100644 --- a/src/inputs/internal/ComboBoxBase.tsx +++ b/src/inputs/internal/ComboBoxBase.tsx @@ -5,7 +5,7 @@ import { useButton, useComboBox, useFilter, useOverlayPosition } from "react-ari import { Item, useComboBoxState, useMultipleSelectionState } from "react-stately"; import { resolveTooltip } from "src/components"; import { Popover } from "src/components/internal"; -import { PresentationFieldProps, usePresentationContext } from "src/components/PresentationContext"; +import { InputStylePalette, PresentationFieldProps, usePresentationContext } from "src/components/PresentationContext"; import { Css } from "src/Css"; import { ComboBoxInput } from "src/inputs/internal/ComboBoxInput"; import { ListBox } from "src/inputs/internal/ListBox"; @@ -20,6 +20,9 @@ export interface ComboBoxBaseProps extends BeamFocusableProp getOptionMenuLabel?: (opt: O, isUnsetOpt?: boolean, isAddNewOption?: boolean) => string | ReactNode; getOptionValue: (opt: O) => V; getOptionLabel: (opt: O) => string; + // TODO: explain yourself + // updates input style based on the option(s) selected if `PresentationFieldProps.inputStylePalette` is not set + getInputStylePalette?: (values: V[]) => InputStylePalette; /** The current value; it can be `undefined`, even if `V` cannot be. */ values: V[] | undefined; onSelect: (values: V[], opts: O[]) => void; @@ -92,6 +95,8 @@ export function ComboBoxBase(props: ComboBoxBaseProps) disabledOptions, borderless, unsetLabel, + inputStylePalette: propsInputStylePalette, + getInputStylePalette, getOptionLabel: propOptionLabel, getOptionValue: propOptionValue, getOptionMenuLabel: propOptionMenuLabel, @@ -147,6 +152,14 @@ export function ComboBoxBase(props: ComboBoxBaseProps) ); const values = useMemo(() => propValues ?? [], [propValues]); + const inputStylePalette = useMemo(() => { + if (propsInputStylePalette) { + return propsInputStylePalette; + } else if (getInputStylePalette) { + return getInputStylePalette(values); + } + return undefined; + }, [propsInputStylePalette, getInputStylePalette, values]); const selectedOptionsRef = useRef(options.filter((o) => values.includes(getOptionValue(o)))); const selectedOptions = useMemo(() => { @@ -379,6 +392,7 @@ export function ComboBoxBase(props: ComboBoxBaseProps)