From 6bf26ac11dadd2f38f67d3c7c49555e96deb0e18 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 6 Dec 2023 21:43:17 -0500 Subject: [PATCH] feat: Support 'fullWidth' prop on all text fields (#979) Adds support for a `PresentationFieldProps.fullWidth` property. The following components will respect the property: - FormLines - TextField - TextAreaField - RichTextField - SelectField - MultiSelectField - DateField - NumberField - TreeSelectField Updates the `FormLines` component to default to the `"lg"` size instead of `"full"`. This will hopefully minimize the impact on existing layouts as the previous default width for each field within `FormLines` was 550px. By using the `lg` size, we'll continue to default the fields within `FormLines` to 550px. --- package.json | 2 +- src/Css.ts | 16 ++ src/components/Pagination.stories.tsx | 2 +- src/components/PresentationContext.tsx | 2 + src/forms/FormLines.stories.tsx | 152 ++++++++++++++++-- src/forms/FormLines.tsx | 3 +- src/inputs/Autocomplete.stories.tsx | 14 +- src/inputs/Autocomplete.tsx | 4 +- src/inputs/DateFields/DateField.stories.tsx | 3 +- src/inputs/DateFields/DateFieldBase.tsx | 5 +- .../DateFields/DateRangeField.stories.tsx | 1 + src/inputs/MultiSelectField.stories.tsx | 5 + src/inputs/NumberField.stories.tsx | 5 + src/inputs/NumberField.tsx | 2 +- src/inputs/RichTextField.tsx | 17 +- src/inputs/SelectField.stories.tsx | 2 + src/inputs/TextAreaField.stories.tsx | 3 + src/inputs/TextField.stories.tsx | 5 + src/inputs/TextFieldBase.tsx | 9 +- .../TreeSelectField.stories.tsx | 11 ++ .../TreeSelectField/TreeSelectField.tsx | 12 +- src/inputs/internal/ComboBoxBase.tsx | 9 +- src/inputs/utils.ts | 3 + yarn.lock | 10 +- 24 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 src/inputs/utils.ts diff --git a/package.json b/package.json index 9f2175339..064f0c8e0 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,7 @@ "@homebound/eslint-config": "^1.8.0", "@homebound/rtl-react-router-utils": "1.0.3", "@homebound/rtl-utils": "^2.65.0", - "@homebound/truss": "^1.131.0", + "@homebound/truss": "^1.132.0", "@homebound/tsconfig": "^1.0.3", "@semantic-release/exec": "^6.0.3", "@semantic-release/git": "^10.0.1", diff --git a/src/Css.ts b/src/Css.ts index 4faf58a80..279b06df5 100644 --- a/src/Css.ts +++ b/src/Css.ts @@ -1133,6 +1133,22 @@ class CssBuilder { fd(value: Properties["flexDirection"]) { return this.add("flexDirection", value); } + /** Sets `flexWrap: "wrap"`. */ + get fww() { + return this.add("flexWrap", "wrap"); + } + /** Sets `flexWrap: "wrap-reverse"`. */ + get fwr() { + return this.add("flexWrap", "wrap-reverse"); + } + /** Sets `flexWrap: "nowrap"`. */ + get fwnw() { + return this.add("flexWrap", "nowrap"); + } + /** Sets `flexWrap: value`. */ + flexWrap(value: Properties["flexWrap"]) { + return this.add("flexWrap", value); + } // float /** Sets `float: "left"`. */ diff --git a/src/components/Pagination.stories.tsx b/src/components/Pagination.stories.tsx index e7ef412ff..adc89aaea 100644 --- a/src/components/Pagination.stories.tsx +++ b/src/components/Pagination.stories.tsx @@ -31,7 +31,7 @@ function RenderPagination({ totalRows }: { totalRows: number }) { const page = useState({ pageNumber: 1, pageSize: 100 }); return ( <> - + diff --git a/src/components/PresentationContext.tsx b/src/components/PresentationContext.tsx index 06903b3d3..10c1c389c 100644 --- a/src/components/PresentationContext.tsx +++ b/src/components/PresentationContext.tsx @@ -17,6 +17,8 @@ export interface PresentationFieldProps { visuallyDisabled?: false; // If set error messages will be rendered as tooltips rather than below the field errorInTooltip?: true; + /** Allow the fields to grow to the width of its container. By default, fields will extend up to 550px */ + fullWidth?: boolean; } export type PresentationContextProps = { diff --git a/src/forms/FormLines.stories.tsx b/src/forms/FormLines.stories.tsx index 3f22d90c7..af412d5f2 100644 --- a/src/forms/FormLines.stories.tsx +++ b/src/forms/FormLines.stories.tsx @@ -1,6 +1,6 @@ import { Meta } from "@storybook/react"; import { FieldGroup, FormDivider, FormLines } from "src/forms/FormLines"; -import { SelectField } from "src/inputs"; +import { DateField, MultiSelectField, RichTextField, SelectField, TextAreaField, TreeSelectField } from "src/inputs"; import { NumberField } from "src/inputs/NumberField"; import { Switch } from "src/inputs/Switch"; import { TextField } from "src/inputs/TextField"; @@ -13,8 +13,38 @@ export default { export function FlatList() { return ( - {}} /> - {}} /> + + + + + label="Single Select Field" + value={1} + options={[ + { id: 1, name: "Green" }, + { id: 2, name: "Red" }, + ]} + onSelect={noop} + /> + + label="Multiselect Field" + values={[1]} + options={[ + { id: 1, name: "Soccer" }, + { id: 2, name: "Basketball" }, + { id: 3, name: "Football" }, + ]} + onSelect={noop} + /> + o.id} + getOptionLabel={(o) => o.name} + /> + + ); } @@ -22,8 +52,38 @@ export function FlatList() { export function SmallFlatList() { return ( - {}} /> - {}} /> + + + + + label="Single Select Field" + value={1} + options={[ + { id: 1, name: "Green" }, + { id: 2, name: "Red" }, + ]} + onSelect={noop} + /> + + label="Multiselect Field" + values={[1]} + options={[ + { id: 1, name: "Soccer" }, + { id: 2, name: "Basketball" }, + { id: 3, name: "Football" }, + ]} + onSelect={noop} + /> + o.id} + getOptionLabel={(o) => o.name} + /> + + ); } @@ -31,15 +91,45 @@ export function SmallFlatList() { export function FullFlatList() { return ( - {}} /> - {}} /> + + + + + label="Single Select Field" + value={1} + options={[ + { id: 1, name: "Green" }, + { id: 2, name: "Red" }, + ]} + onSelect={noop} + /> + + label="Multiselect Field" + values={[1]} + options={[ + { id: 1, name: "Soccer" }, + { id: 2, name: "Basketball" }, + { id: 3, name: "Football" }, + ]} + onSelect={noop} + /> + o.id} + getOptionLabel={(o) => o.name} + /> + + ); } -export function SideBySideMedium() { +export function SideBySideFull() { return ( - + {}} /> {}} /> @@ -80,7 +170,7 @@ export function SideBySideMedium() { } export function SideBySideLarge() { return ( - + {}} /> {}} /> @@ -129,6 +219,48 @@ export function SideBySideLarge() { ); } + +export function SideBySideMedium() { + return ( + + + {}} /> + {}} /> + + {}} /> + + {}} /> + {}} /> + + + {}} /> + + + + {}} /> + {}} /> + {}} /> + + + {}} /> + {}} /> + {}} /> + + + {}} /> + + label="Unit of Measure" + value={1} + options={[ + { id: 1, name: "Each" }, + { id: 2, name: "Square Feet" }, + ]} + onSelect={noop} + /> + + + ); +} export function SideBySideSmall() { return ( diff --git a/src/forms/FormLines.tsx b/src/forms/FormLines.tsx index 1f5fbe004..c525390b7 100644 --- a/src/forms/FormLines.tsx +++ b/src/forms/FormLines.tsx @@ -28,7 +28,7 @@ export interface FormLinesProps { * (see the `FieldGroup` component), where they will be laid out side-by-side. */ export function FormLines(props: FormLinesProps) { - const { children, width = "full", labelSuffix, labelStyle, compact } = props; + const { children, width = "lg", labelSuffix, labelStyle, compact } = props; let firstFormHeading = true; // Only overwrite `fieldProps` if new values are explicitly set. Ensures we only set to `undefined` if explicitly set. @@ -36,6 +36,7 @@ export function FormLines(props: FormLinesProps) { ...("labelSuffix" in props ? { labelSuffix } : {}), ...("labelStyle" in props ? { labelStyle } : {}), ...("compact" in props ? { compact } : {}), + ...(width === "full" ? { fullWidth: true } : {}), }; return ( diff --git a/src/inputs/Autocomplete.stories.tsx b/src/inputs/Autocomplete.stories.tsx index ba05091c3..02bde5908 100644 --- a/src/inputs/Autocomplete.stories.tsx +++ b/src/inputs/Autocomplete.stories.tsx @@ -4,22 +4,25 @@ import { within } from "@storybook/testing-library"; import { useState } from "react"; import { useFilter } from "react-aria"; import { Css } from "src/Css"; -import { Autocomplete } from "src/inputs/Autocomplete"; +import { Autocomplete, AutocompleteProps } from "src/inputs/Autocomplete"; export default { component: Autocomplete, } as Meta; -export const Example: StoryFn = Template.bind({}); +export const Example: StoryFn> = Template.bind({}); Example.parameters = { chromatic: { disableSnapshot: true } }; -export const MenuOpen: StoryFn = Template.bind({}); +export const FullWidth: StoryFn> = Template.bind({}); +FullWidth.args = { fullWidth: true }; + +export const MenuOpen: StoryFn> = Template.bind({}); MenuOpen.play = async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); canvas.getByTestId("heroes").focus(); }; -function Template() { +function Template(props: AutocompleteProps) { const { contains } = useFilter({ sensitivity: "base" }); const [value, setValue] = useState(); const allOptions = [ @@ -30,9 +33,10 @@ function Template() { { label: "Black Widow", imgSrc: "/black-widow.jpg" }, ]; const [options, setOptions] = useState(allOptions); - + console.log("props", props); return ( + {...props} label="Heroes" labelStyle="hidden" getOptionValue={(o) => o.label} diff --git a/src/inputs/Autocomplete.tsx b/src/inputs/Autocomplete.tsx index 95a055f6a..29ebeefe6 100644 --- a/src/inputs/Autocomplete.tsx +++ b/src/inputs/Autocomplete.tsx @@ -9,9 +9,9 @@ import { ListBox } from "src/inputs/internal/ListBox"; import { TextFieldBase, TextFieldBaseProps } from "src/inputs/TextFieldBase"; import { Value, valueToKey } from "src/inputs/Value"; -interface AutocompleteProps +export interface AutocompleteProps extends Pick, - Pick, "label" | "clearable" | "startAdornment"> { + Pick, "label" | "clearable" | "startAdornment" | "fullWidth"> { onSelect: (item: T) => void; /** A function that returns how to render the an option in the menu. If not set, `getOptionLabel` will be used */ getOptionMenuLabel?: (o: T) => ReactNode; diff --git a/src/inputs/DateFields/DateField.stories.tsx b/src/inputs/DateFields/DateField.stories.tsx index 5438e631a..7e937045c 100644 --- a/src/inputs/DateFields/DateField.stories.tsx +++ b/src/inputs/DateFields/DateField.stories.tsx @@ -10,7 +10,6 @@ import { samples, withDimensions } from "src/utils/sb"; export default { component: Button, - decorators: [withDimensions()], parameters: { design: { type: "figma", @@ -61,6 +60,7 @@ export function DateFields() { disabledDays={[{ before: jan1 }, { after: jan10 }]} />, ], + ["Full Width", ], ); } @@ -79,6 +79,7 @@ export function DatePickerOpen() { ); } +DatePickerOpen.decorators = [withDimensions()]; function TestDateField(props: Omit) { const [value, onChange] = useState(jan1); diff --git a/src/inputs/DateFields/DateFieldBase.tsx b/src/inputs/DateFields/DateFieldBase.tsx index 93e61ff2c..30bfac1bc 100644 --- a/src/inputs/DateFields/DateFieldBase.tsx +++ b/src/inputs/DateFields/DateFieldBase.tsx @@ -22,7 +22,10 @@ import { maybeCall, useTestIds } from "src/utils"; import { defaultTestId } from "src/utils/defaultTestId"; export interface DateFieldBaseProps - extends Pick, "borderless" | "visuallyDisabled" | "labelStyle" | "compact"> { + extends Pick< + TextFieldBaseProps, + "borderless" | "visuallyDisabled" | "labelStyle" | "compact" | "fullWidth" + > { label: string; /** Called when the component loses focus */ onBlur?: () => void; diff --git a/src/inputs/DateFields/DateRangeField.stories.tsx b/src/inputs/DateFields/DateRangeField.stories.tsx index 00a71ed3e..fd4b70103 100644 --- a/src/inputs/DateFields/DateRangeField.stories.tsx +++ b/src/inputs/DateFields/DateRangeField.stories.tsx @@ -40,6 +40,7 @@ export function Example() { label="Error message" errorMsg={rangeInitUndefined ? undefined : "Required"} /> + ); } diff --git a/src/inputs/MultiSelectField.stories.tsx b/src/inputs/MultiSelectField.stories.tsx index 4b7844e50..950064bc7 100644 --- a/src/inputs/MultiSelectField.stories.tsx +++ b/src/inputs/MultiSelectField.stories.tsx @@ -173,6 +173,11 @@ export function MultiSelectFields() { )} /> + +
+

Full Width

+ +
); } diff --git a/src/inputs/NumberField.stories.tsx b/src/inputs/NumberField.stories.tsx index dd6eb09ab..07237f211 100644 --- a/src/inputs/NumberField.stories.tsx +++ b/src/inputs/NumberField.stories.tsx @@ -63,6 +63,11 @@ export function NumberFieldStyles() {

Without grouping

+ +
+

Full Width

+ +
); } diff --git a/src/inputs/NumberField.tsx b/src/inputs/NumberField.tsx index a743c8332..28dee2a10 100644 --- a/src/inputs/NumberField.tsx +++ b/src/inputs/NumberField.tsx @@ -11,7 +11,7 @@ import { TextFieldBase } from "./TextFieldBase"; export type NumberFieldType = "cents" | "dollars" | "percent" | "basisPoints" | "days"; // exported for testing purposes -export interface NumberFieldProps extends Pick { +export interface NumberFieldProps extends Pick { label: string; /** If set, the label will be defined as 'aria-label` on the input element */ type?: NumberFieldType; diff --git a/src/inputs/RichTextField.tsx b/src/inputs/RichTextField.tsx index 1f63990d3..d89e00f98 100644 --- a/src/inputs/RichTextField.tsx +++ b/src/inputs/RichTextField.tsx @@ -8,8 +8,9 @@ import Tribute from "tributejs"; import "tributejs/dist/tribute.css"; import "trix/dist/trix"; import "trix/dist/trix.css"; +import { PresentationFieldProps, usePresentationContext } from "src/components/PresentationContext"; -export interface RichTextFieldProps { +export interface RichTextFieldProps extends Pick { /** The initial html value to show in the trix editor. */ value: string | undefined; onChange: (html: string | undefined, text: string | undefined, mergeTags: string[]) => void; @@ -28,8 +29,6 @@ export interface RichTextFieldProps { onFocus?: () => void; /** For rendering formatted text */ readOnly?: boolean; - /** Will set width to: 100% */ - fullWidth?: boolean; } /** @@ -40,7 +39,17 @@ export interface RichTextFieldProps { * We also integrate [tributejs]{@link https://github.com/zurb/tribute} for @ mentions. */ export function RichTextField(props: RichTextFieldProps) { - const { mergeTags, label, value = "", onChange, onBlur = noop, onFocus = noop, readOnly, fullWidth } = props; + const { fieldProps } = usePresentationContext(); + const { + mergeTags, + label, + value = "", + onChange, + onBlur = noop, + onFocus = noop, + readOnly, + fullWidth = fieldProps?.fullWidth ?? false, + } = props; // We get a reference to the Editor instance after trix-init fires const [editor, setEditor] = useState(); diff --git a/src/inputs/SelectField.stories.tsx b/src/inputs/SelectField.stories.tsx index 3591acd00..4932ff207 100644 --- a/src/inputs/SelectField.stories.tsx +++ b/src/inputs/SelectField.stories.tsx @@ -201,6 +201,8 @@ function Template(args: SelectFieldProps) { disabledOptions={[options[0].id, { value: options[3].id, reason: "Example disabled tooltip" }]} helperText="Disabled options can optionally have tooltip text" /> + +
diff --git a/src/inputs/TextAreaField.stories.tsx b/src/inputs/TextAreaField.stories.tsx index 6e017397e..583a69c8b 100644 --- a/src/inputs/TextAreaField.stories.tsx +++ b/src/inputs/TextAreaField.stories.tsx @@ -40,6 +40,9 @@ export function TextAreaStyles() { {}} /> +

Full Width

+ +

Modified for Blueprint To Do Title

} />
+ +
+

Full Width

+ +
); } diff --git a/src/inputs/TextFieldBase.tsx b/src/inputs/TextFieldBase.tsx index 402568a27..c45a4d96a 100644 --- a/src/inputs/TextFieldBase.tsx +++ b/src/inputs/TextFieldBase.tsx @@ -14,13 +14,14 @@ 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 { Css, Only, Palette, px } from "src/Css"; +import { Css, Only, Palette } from "src/Css"; import { getLabelSuffix } from "src/forms/labelUtils"; import { useGetRef } from "src/hooks/useGetRef"; import { ErrorMessage } from "src/inputs/ErrorMessage"; import { BeamTextFieldProps, TextFieldInternalProps, TextFieldXss } from "src/interfaces"; import { defaultTestId } from "src/utils/defaultTestId"; import { useTestIds } from "src/utils/useTestIds"; +import { getFieldWidth } from "src/inputs/utils"; export interface TextFieldBaseProps extends Pick< @@ -37,6 +38,7 @@ export interface TextFieldBaseProps | "compact" | "borderless" | "visuallyDisabled" + | "fullWidth" | "xss" >, Partial, "onChange">> { @@ -89,6 +91,7 @@ export function TextFieldBase>(props: TextFieldB errorInTooltip = fieldProps?.errorInTooltip ?? false, hideErrorMessage = false, alwaysShowHelperText = false, + fullWidth = fieldProps?.fullWidth ?? false, } = props; const typeScale = fieldProps?.typeScale ?? (inputProps.readOnly && labelStyle !== "hidden" ? "smMd" : "sm"); @@ -113,8 +116,10 @@ export function TextFieldBase>(props: TextFieldB ? [Palette.Gray100, Palette.Gray200, Palette.Gray200] : [Palette.White, Palette.Gray100, Palette.Gray100]; + const fieldMaxWidth = getFieldWidth(fullWidth); + const fieldStyles = { - container: Css.df.fdc.w100.maxw(px(550)).relative.if(labelStyle === "left").maxw100.fdr.gap2.jcsb.aic.$, + container: Css.df.fdc.w100.maxw(fieldMaxWidth).relative.if(labelStyle === "left").maxw100.fdr.gap2.jcsb.aic.$, inputWrapper: { ...Css[typeScale].df.aic.br4.px1.w100 .hPx(fieldHeight - maybeSmaller) diff --git a/src/inputs/TreeSelectField/TreeSelectField.stories.tsx b/src/inputs/TreeSelectField/TreeSelectField.stories.tsx index 9d6efbbf5..84d00691b 100644 --- a/src/inputs/TreeSelectField/TreeSelectField.stories.tsx +++ b/src/inputs/TreeSelectField/TreeSelectField.stories.tsx @@ -119,6 +119,17 @@ function Template(args: TreeSelectFieldProps) { placeholder="Hidden label" /> + +
+ +
diff --git a/src/inputs/TreeSelectField/TreeSelectField.tsx b/src/inputs/TreeSelectField/TreeSelectField.tsx index 0139a7cbd..03df3e6ed 100644 --- a/src/inputs/TreeSelectField/TreeSelectField.tsx +++ b/src/inputs/TreeSelectField/TreeSelectField.tsx @@ -14,8 +14,8 @@ 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 } from "src/components/PresentationContext"; -import { Css, px } from "src/Css"; +import { PresentationFieldProps, usePresentationContext } from "src/components/PresentationContext"; +import { Css } from "src/Css"; import { Value } from "src/inputs/index"; import { ComboBoxInput } from "src/inputs/internal/ComboBoxInput"; import { ListBox } from "src/inputs/internal/ListBox"; @@ -31,6 +31,7 @@ import { import { keyToValue, valueToKey } from "src/inputs/Value"; import { BeamFocusableProps } from "src/interfaces"; import { HasIdAndName, Optional } from "src/types"; +import { getFieldWidth } from "src/inputs/utils"; export interface TreeSelectFieldProps extends BeamFocusableProps, PresentationFieldProps { /** Renders `opt` in the dropdown menu, defaults to the `getOptionLabel` prop. `isUnsetOpt` is only defined for single SelectField */ @@ -127,6 +128,7 @@ export const CollapsedContext = React.createContext(props: TreeSelectFieldProps) { + const { fieldProps } = usePresentationContext(); const { values, options, @@ -142,6 +144,7 @@ function TreeSelectFieldBase(props: TreeSelectFieldProps(props: TreeSelectFieldProps +
extends BeamFocusableProps, PresentationFieldProps { @@ -88,6 +89,7 @@ export function ComboBoxBase(props: ComboBoxBaseProps) getOptionLabel: propOptionLabel, getOptionValue: propOptionValue, getOptionMenuLabel: propOptionMenuLabel, + fullWidth = fieldProps?.fullWidth ?? false, ...otherProps } = props; const labelStyle = otherProps.labelStyle ?? fieldProps?.labelStyle ?? "above"; @@ -320,10 +322,13 @@ export function ComboBoxBase(props: ComboBoxBaseProps) minWidth: 200, }; + const fieldMaxWidth = getFieldWidth(fullWidth); + return ( -
+