Skip to content

Commit

Permalink
feat: palette fill as a presentation prop; getInputStylePalette fn pr…
Browse files Browse the repository at this point in the history
…op; (#1092)

[SC-61998](https://app.shortcut.com/homebound-team/story/61998/houston-pilot-feedback-update-beam-to-support-coloring-dropdowns)
|
[Figma](https://www.figma.com/design/xQ3iNLHJCFEJeOi7boONY4/Q1-Q4-2024-%7C-Dynamic-Schedules?node-id=11535-84519&node-type=frame&t=wrjRlakQknbMmW1j-0)


https://github.com/user-attachments/assets/61fdf544-1949-475c-b01d-f805504a7f61


Initially the goal was to only have select fields show color but since
the underlying `<TextFieldBase` that actually controls colors takes
props from presentationFieldProps it was pretty easy to hook up the
components starting from there. So technically our `<MultiselectField
also supports `getInputStylePalette` & `inputStylePalette` and our
`<TextFields` support `inputStylePalette` as well practically for free.
  • Loading branch information
apattersonATX-HB authored Dec 9, 2024
1 parent a2ac69f commit 794663c
Show file tree
Hide file tree
Showing 4 changed files with 89 additions and 17 deletions.
7 changes: 6 additions & 1 deletion src/components/PresentationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" */
Expand All @@ -23,10 +25,13 @@ 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;
/** `inputStylePalette` omitted because it is too dependent on the individual field use case to be controlled at this level */
fieldProps?: Omit<PresentationFieldProps, "inputStylePalette">;
gridTableStyle?: GridStyle;
// Defines whether content should be allowed to wrap or not. `undefined` is treated as true.
wrap?: boolean;
Expand Down
49 changes: 47 additions & 2 deletions src/inputs/SelectField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Meta } from "@storybook/react";
import { within } from "@storybook/test";
import { useState } from "react";
import { GridColumn, GridTable, Icon, IconKey, simpleHeader, SimpleHeaderAndData } from "src/components";
import { InputStylePalette } from "src/components/PresentationContext";
import { Css } from "src/Css";
import { SelectField, SelectFieldProps } from "src/inputs/SelectField";
import { Value } from "src/inputs/Value";
Expand Down Expand Up @@ -31,14 +32,22 @@ 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" },
{ id: "4", name: "Calendar", icon: "calendar" },
{ 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" },
Expand All @@ -54,6 +63,7 @@ const booleanOptions = [

function Template(args: SelectFieldProps<any, any>) {
const loadTestOptions: TestOption[] = zeroTo(1000).map((i) => ({ id: String(i), name: `Project ${i}` }));
const options = (args?.options as TestOption[]) ?? standardOptions;

return (
<div css={Css.df.fdc.gap5.p2.if(args.contrast === true).white.bgGray800.$}>
Expand Down Expand Up @@ -223,6 +233,33 @@ export const Contrast = Template.bind({});
// @ts-ignore
Contrast.args = { compact: true, contrast: true };

// @ts-ignore
function getInputStylePalette(v): InputStylePalette | undefined {
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;
}

export const Colored = Template.bind({});
// @ts-ignore
Colored.args = { options: coloredOptions };

export const ColoredContrast = Template.bind({});
// @ts-ignore
ColoredContrast.args = {
contrast: true,
options: coloredOptions,
};

export const ColoredCompact = Template.bind({});
// @ts-ignore
ColoredCompact.args = {
compact: true,
options: coloredOptions,
};

const loadTestOptions: TestOption[] = zeroTo(1000).map((i) => ({ id: String(i), name: `Project ${i}` }));

export function PerfTest() {
Expand Down Expand Up @@ -371,15 +408,23 @@ function TestSelectField<T extends object, V extends Value>(
props: Optional<Omit<SelectFieldProps<T, V>, "onSelect">, "getOptionValue" | "getOptionLabel">,
): JSX.Element {
const [selectedOption, setSelectedOption] = useState<V | undefined>(props.value);
const [inputStylePalette, setInputStylePalette] = useState<InputStylePalette | undefined>();

// @ts-ignore: Hacking around type props within the testSelectField instead of the SB Template
const shouldUseStylePalette: boolean = props.options === coloredOptions;

return (
<div css={Css.df.$}>
<SelectField<T, V>
// The `as any` is due to something related to https://github.com/emotion-js/emotion/issues/2169
// We may have to redo the conditional getOptionValue/getOptionLabel
{...(props as any)}
inputStylePalette={shouldUseStylePalette ? inputStylePalette : undefined}
value={selectedOption}
onSelect={setSelectedOption}
onSelect={(v, opt) => {
setSelectedOption(v);
setInputStylePalette(getInputStylePalette(v));
}}
errorMsg={
selectedOption !== undefined || props.disabled
? ""
Expand Down
47 changes: 33 additions & 14 deletions src/inputs/TextFieldBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -42,6 +42,7 @@ export interface TextFieldBaseProps<X>
| "visuallyDisabled"
| "fullWidth"
| "xss"
| "inputStylePalette"
>,
Partial<Pick<BeamTextFieldProps<X>, "onChange">> {
labelProps?: LabelHTMLAttributes<HTMLLabelElement>;
Expand Down Expand Up @@ -103,6 +104,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(props: TextFieldB
fullWidth = fieldProps?.fullWidth ?? false,
unfocusedPlaceholder,
selectOnFocus = true,
inputStylePalette,
} = props;

const typeScale = fieldProps?.typeScale ?? (inputProps.readOnly && labelStyle !== "hidden" ? "smMd" : "sm");
Expand All @@ -121,14 +123,16 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(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);

Expand All @@ -137,7 +141,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(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.
Expand All @@ -150,7 +154,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(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.$,
Expand All @@ -167,7 +171,7 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(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
Expand All @@ -179,14 +183,14 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(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.$,
Expand Down Expand Up @@ -370,3 +374,18 @@ export function TextFieldBase<X extends Only<TextFieldXss, X>>(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];
}
}
3 changes: 3 additions & 0 deletions src/inputs/internal/ComboBoxBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export function ComboBoxBase<O, V extends Value>(props: ComboBoxBaseProps<O, V>)
disabledOptions,
borderless,
unsetLabel,
inputStylePalette: propsInputStylePalette,
getOptionLabel: propOptionLabel,
getOptionValue: propOptionValue,
getOptionMenuLabel: propOptionMenuLabel,
Expand Down Expand Up @@ -147,6 +148,7 @@ export function ComboBoxBase<O, V extends Value>(props: ComboBoxBaseProps<O, V>)
);

const values = useMemo(() => propValues ?? [], [propValues]);
const inputStylePalette = useMemo(() => propsInputStylePalette, [propsInputStylePalette]);

const selectedOptionsRef = useRef(options.filter((o) => values.includes(getOptionValue(o))));
const selectedOptions = useMemo(() => {
Expand Down Expand Up @@ -379,6 +381,7 @@ export function ComboBoxBase<O, V extends Value>(props: ComboBoxBaseProps<O, V>)
<div css={Css.df.fdc.w100.maxw(fieldMaxWidth).if(labelStyle === "left").maxw100.$} ref={comboBoxRef}>
<ComboBoxInput
{...otherProps}
inputStylePalette={inputStylePalette}
fullWidth={fullWidth}
buttonProps={buttonProps}
buttonRef={triggerRef}
Expand Down

0 comments on commit 794663c

Please sign in to comment.