From 1164b90d6a0a49b8aa26bc24e08e600daf7131d8 Mon Sep 17 00:00:00 2001 From: Andrew Holloway <booc0mtaco@users.noreply.github.com> Date: Fri, 22 Mar 2024 16:32:06 -0500 Subject: [PATCH] feat(Select)!: introduce 2.0 component (#1899) - add new 2.0 component - add new stories - add new api options - align styling for popover sub-components --- .../InputField/InputField-v2.module.css | 3 +- src/components/InputLabel/InputLabel-v2.tsx | 41 +- .../PopoverListItem-v2.module.css | 4 +- src/components/Select/Select-v2.module.css | 155 ++++ src/components/Select/Select-v2.stories.tsx | 769 ++++++++++++++++++ src/components/Select/Select-v2.tsx | 559 +++++++++++++ src/components/Select/index.ts | 1 + 7 files changed, 1507 insertions(+), 25 deletions(-) create mode 100644 src/components/Select/Select-v2.module.css create mode 100644 src/components/Select/Select-v2.stories.tsx create mode 100644 src/components/Select/Select-v2.tsx diff --git a/src/components/InputField/InputField-v2.module.css b/src/components/InputField/InputField-v2.module.css index 59eed6b87..dca417336 100644 --- a/src/components/InputField/InputField-v2.module.css +++ b/src/components/InputField/InputField-v2.module.css @@ -3,7 +3,8 @@ \*------------------------------------*/ /** - * Wraps the Label and the optional/required indicator. + * Wraps the Label and the optional/required hint. + * TODO-AH: map the overline styles between Select and InputField */ .input-field__overline { display: flex; diff --git a/src/components/InputLabel/InputLabel-v2.tsx b/src/components/InputLabel/InputLabel-v2.tsx index 9485dc152..b168abf61 100644 --- a/src/components/InputLabel/InputLabel-v2.tsx +++ b/src/components/InputLabel/InputLabel-v2.tsx @@ -1,10 +1,10 @@ import clsx from 'clsx'; -import React from 'react'; -import type { ReactNode } from 'react'; +import React, { type ReactNode } from 'react'; import type { Size } from '../../util/variant-types'; import styles from './InputLabel-v2.module.css'; export type InputLabelProps = { + // Component API /** * Text to render in label. */ @@ -17,6 +17,7 @@ export type InputLabelProps = { * ID of input that label is associated with. */ htmlFor: string; + // Design API /** * Size of the label. * @@ -34,25 +35,21 @@ export type InputLabelProps = { * * Label associated with an input element such as a radio or checkbox. */ -export const InputLabel = ({ - children, - className, - htmlFor, - size = 'lg', - disabled, -}: InputLabelProps) => { - const componentClassName = clsx( - styles['label'], - size === 'md' && styles['label--md'], - size === 'lg' && styles['label--lg'], - disabled && styles['label--disabled'], - className, - ); - return ( - <label className={componentClassName} htmlFor={htmlFor}> - {children} - </label> - ); -}; +export const InputLabel = React.forwardRef<HTMLLabelElement, InputLabelProps>( + ({ children, className, htmlFor, size = 'lg', disabled }, ref) => { + const componentClassName = clsx( + styles['label'], + size === 'md' && styles['label--md'], + size === 'lg' && styles['label--lg'], + disabled && styles['label--disabled'], + className, + ); + return ( + <label className={componentClassName} htmlFor={htmlFor} ref={ref}> + {children} + </label> + ); + }, +); InputLabel.displayName = 'InputLabel'; diff --git a/src/components/PopoverListItem/PopoverListItem-v2.module.css b/src/components/PopoverListItem/PopoverListItem-v2.module.css index 2c9ce143a..e41fafc6e 100644 --- a/src/components/PopoverListItem/PopoverListItem-v2.module.css +++ b/src/components/PopoverListItem/PopoverListItem-v2.module.css @@ -37,12 +37,12 @@ } .popover-list-item__icon { - padding-right: 1rem; + padding-right: 0.5rem; } .popover-list-item__no-icon { /* right padding applies space for the icon itself and the padding for that icon container */ - padding-right: 2rem; + padding-right: 1.5rem; } .popover-list-item__sub-label { diff --git a/src/components/Select/Select-v2.module.css b/src/components/Select/Select-v2.module.css new file mode 100644 index 000000000..29b1b3b20 --- /dev/null +++ b/src/components/Select/Select-v2.module.css @@ -0,0 +1,155 @@ +@import '../../design-tokens/mixins.css'; + +/*------------------------------------*\ + # SELECT +\*------------------------------------*/ + +/** + * Select field used to select one option from a list of options. + */ +.select { + position: relative; +} + +/** + * Wraps the Label and the optional/required hint. + * TODO-AH: map the overline styles between Select and InputField + */ +.select__overline { + display: flex; + margin-bottom: 0.25rem; + gap: 0.25rem; +} + +.select__overline--no-label { + justify-content: flex-start; +} + +/** + * The container for the individual select options. + */ +.select__options { + max-height: 25vh; + z-index: 100; +} + +/** + * The button to trigger the display of the select field. + */ +.select-button { + font: var(--eds-theme-typography-form-input); + + width: 100%; + padding: 0.5rem; + + border: var(--eds-border-width-sm) solid; + border-radius: calc(var(--eds-theme-border-radius-objects-sm) * 1px); + + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + + cursor: pointer; + + /* TODO-AH: handle placeholder color when no value is selected */ + color: var(--eds-theme-color-text-utility-default-primary); + background-color: var(--eds-theme-color-form-background); +} + +/** + * The caret icon to decorate the select trigger button, animated to rotate. + */ +.select-button__icon { + flex-shrink: 0; + transform: rotate(0); + + transition: transform var(--eds-anim-move-medium) ease-out; + + @media (prefers-reduced-motion) { + transition: none; + } +} + +.select-button__text--truncated { + /* TODO-AH: use as mixin */ + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.select-button__icon--reversed { + transform: rotate(180deg); +} + +.select__footer { + display: flex; + justify-content: space-between; +} + +.select--has-fieldNote { + margin-bottom: 0.25rem; +} + +/** + * Label on top of the select trigger button to label the select field. + */ +.select__label { + font: var(--eds-theme-typography-form-label); + color: var(--eds-theme-color-text-utility-default-primary); +} + +.select__label--disabled { + color: var(--eds-theme-color-text-utility-disabled-primary); +} + +.select__option { + color: var(--eds-theme-color-text-utility-interactive-secondary); +} + +.select__option-text { + color: var(--eds-theme-color-text-utility-default-primary); +} + +.select__required-text { + color: var(--eds-theme-color-text-utility-default-secondary); +} + +.select-button:disabled { + cursor: not-allowed; + + color: var(--eds-theme-color-text-utility-disabled-primary); + border-color: var(--eds-theme-color-border-utility-disabled); + background-color: var(--eds-theme-color-background-utility-disabled-low-emphasis); +} + +.select-button:focus-visible { + border-color: var(--eds-theme-color-border-utility-focus); + outline: var(--eds-border-width-sm) solid var(--eds-theme-color-border-utility-focus); +} + +.select-button--error { + border-color: var(--eds-theme-color-border-utility-critical); + + &:hover { + border-color: var(--eds-theme-color-border-utility-critical-hover); + } + + &:focus-visible { + border-color: var(--eds-theme-color-border-utility-critical); + outline: var(--eds-border-width-sm) solid var(--eds-theme-color-border-utility-critical); + } +} + +.select-button--warning { + border-color: var(--eds-theme-color-border-utility-warning); + + &:hover { + border-color: var(--eds-theme-color-border-utility-warning-hover); + } + + &:focus-visible { + border-color: var(--eds-theme-color-border-utility-warning); + outline: var(--eds-border-width-sm) solid var(--eds-theme-color-border-utility-warning); + } +} \ No newline at end of file diff --git a/src/components/Select/Select-v2.stories.tsx b/src/components/Select/Select-v2.stories.tsx new file mode 100644 index 000000000..5efa2feb0 --- /dev/null +++ b/src/components/Select/Select-v2.stories.tsx @@ -0,0 +1,769 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import { expect } from '@storybook/test'; +import { userEvent, within } from '@storybook/testing-library'; +import React from 'react'; +import { Select } from './Select-v2'; +import Icon from '../Icon'; + +const meta: Meta<typeof Select> = { + title: 'Components/V2/Select', + component: Select, + parameters: { + badges: ['intro-1.2', 'current-2.0'], + layout: 'centered', + }, + argTypes: { + multiple: { + description: 'Whether multiple values are allowed in this instance', + }, + children: { + control: { + type: null, + }, + }, + value: { + table: { + description: 'The value of the select field (when controlled)', + }, + }, + defaultValue: { + description: 'The default value of the select field (when uncontrolled)', + }, + onClick: { + description: + 'Optional click handler. Fires after `onChange`, when a value in the dropdown popover is picked', + table: { + type: { + summary: 'SyntheticEvent', + detail: + 'See: https://react.dev/reference/react-dom/components/common#react-event-object', + }, + default: 'void', + }, + }, + onChange: { + description: + 'Optional change handler. Fires when a value is selected (and passes in list of selected values)', + }, + }, +}; + +export default meta; + +type SelectOption = { + key: string; + label: string; +}; + +const exampleOptions: SelectOption[] = [ + { + key: '1', + label: 'Dogs', + }, + { + key: '2', + label: 'Cats', + }, + { + key: '3', + label: 'Birds', + }, +]; + +/** + * Play function to open a menu item + */ +const openMenu: StoryObj['play'] = async (playOptions) => { + const { canvasElement } = playOptions; + const canvas = within(canvasElement); + + // Open the dropdown. + const selectButton = await canvas.findByRole('button'); + await userEvent.click(selectButton); +}; + +/** + * Play function to use with interactive stories + */ +const selectCat: StoryObj['play'] = async (playOptions) => { + const { canvasElement } = playOptions; + const canvas = within(canvasElement); + const selectButton = await canvas.findByRole('button'); + + await openMenu(playOptions); + + // Target the body of the iframe since we now use PopperJS + const popoverCanvas = within(document.body); + + const bestOption = await popoverCanvas.findByText('Cats'); + await userEvent.click(bestOption); + + // Reopen the dropdown; selecting an option closed it. + await userEvent.click(selectButton); +}; + +/** + * The simplest and default case, using the options, button, and button wrapper. + * This shows how to reflect the value in the button upon selection, and how to generate + * a set of options from a list. + * + * **NOTE**: for select value data types, `{label: string}` is required, but any other key/value pairs are allowed. + */ +export const Default: StoryObj = { + args: { + label: 'Favorite Animal', + 'data-testid': 'dropdown', + defaultValue: exampleOptions[0], + name: 'select', + children: ( + <> + <Select.Button> + {({ value, open }) => ( + <Select.ButtonWrapper isOpen={open}> + {value.label} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, + parameters: { + docs: { + source: { + code: ` +<Select> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper + isOpen={open} + > + {value.label} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> +</Select>`, + }, + }, + }, +}; + +/** + * Instead of a render prop for `Select.Button`, you can forego the render prop for the button and use static text instead. + * This mode is also useful if you want to use a controlled component and manage state yourself. + */ +export const WithStandardButton: StoryObj = { + args: { + label: 'Favorite Animal', + 'data-testid': 'dropdown', + defaultValue: exampleOptions[0], + name: 'standard-button', + children: ( + <> + <Select.Button>- Select Option -</Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, +}; + +/** + * `Select` allows for event handlers to be added to the component. + * + * * `onChange` fires when a value is selected (with value of type `SelectOption`) + * + * You can also add an `onClick` handler to `.ButtonWrapper` if using a render prop + * + * * `onClick` fires when the trigger (`.ButtonWrapper`) is clicked + */ +export const EventHandlingOnRenderProp: StoryObj = { + args: { + ...Default.args, + onChange: (args: SelectOption) => console.log('changed to', args), + children: ( + <> + <Select.Button> + {({ value, open }) => ( + <Select.ButtonWrapper + isOpen={open} + onClick={(args) => console.log('custom click')} + > + {value.label} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, + parameters: { + docs: { + source: { + code: ` +<Select onChange={...}> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper + isOpen={open} + onClick={...} + > + {value.label} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> +</Select>`, + }, + }, + }, +}; + +/** + * `Select` allows for event handlers to be added to the component. + * + * * `onChange` fires when a value is selected (with value of type `SelectOption`) + * + * If not using a render prop, you can also add an `onClick` handler to `Select.Button` directly + * + * * `onClick` fires when the trigger (`.ButtonWrapper`) is clicked + * + * **NOTE**: `onClick` has no function when using a render prop + */ +export const EventHandlingOnStandardButton: StoryObj = { + args: { + ...Default.args, + children: ( + <> + <Select.Button + onClick={(ev: MouseEvent) => console.log('external click')} + > + - Select Option - + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + onChange: (args: SelectOption) => console.log('external change', args), + }, +}; + +/** + * You can select a different option to show when rendered. + */ +export const WithSelectedOption: StoryObj<typeof Select> = { + args: { + ...Default.args, + 'aria-label': 'Favorite Animal', + defaultValue: exampleOptions[1], + }, +}; + +/** + * You can add a `name` prop to generate form fields for the value object. + * + * In this example, the field name is `"interactive-select"`, and the value is an object storing `{label: string, key: string}`. + * + * This will generate hidden fields with names: + * * `interactive-select[label]` + * * `interactive-select[key]` + * + */ +export const WithFieldName: StoryObj = { + args: { + ...Default.args, + children: ( + <> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper isOpen={open}> + {value.label} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, +}; + +export const WithFieldNote: StoryObj = { + args: { + ...Default.args, + fieldNote: 'Choose your beast', + children: ( + <> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper isOpen={open}> + {value.label} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, +}; + +/** + * You can implement a `Select.Button` with a render prop. This exposes several useful values to + * control the appearance of the rendered button. The render prop case is "Headless", in that it has + * no styling by default. + */ +export const UncontrolledHeadless: StoryObj = { + args: { + 'aria-label': 'some label', + 'data-testid': 'dropdown', + defaultValue: exampleOptions[0], + name: 'select', + children: ( + <> + <Select.Button> + {({ value, open, disabled }) => ( + <button className="fpo">{value.label}</button> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, +}; + +/** + * You can use `Select.ButtonWrapper` to borrow the existing style used for controlled `Select` components. + */ +export const StyledUncontrolled: StoryObj = { + args: { + 'aria-label': 'some label', + 'data-testid': 'dropdown', + defaultValue: exampleOptions[0], + name: 'select', + children: ( + <> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper isOpen={open}> + {value.label} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, +}; + +/** + * You can select multiple values by passing `multiple` to the parent element. When doing this, + * make sure all props that use the value (e.g., `value` and `defaultValue`) should use an array instead + * of an object or value for the individual `Select.Option` entries. + * + * When handling the button text, `value` represents the data for all options selected. This allows for a flexible + * layout to fit the needs of the design. + * + * Hidden form inputs are generated for each option selected and take the following form: + * - `name[arrayIndex][key]` + * - `name[arrayIndex][value]` + */ +export const Multiple: StoryObj = { + args: { + ...Default.args, + label: 'Favorite Animal(s)', + multiple: true, + 'data-testid': 'select-field', + defaultValue: [exampleOptions[0]], + className: 'w-60', + name: 'standard-button', + children: ( + <> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper isOpen={open}> + {value.length > 0 ? value.length : 'none'} selected + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, + parameters: { + docs: { + source: { + code: ` +<Select> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper + isOpen={open} + > + {value.length > 0 ? value.length : 'none'} selected + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> +</Select>`, + }, + }, + }, +}; + +/** + * The component provides some basic styles to handle long text in the provided field. Use + * `shouldTruncate` on `.ButtonWrapper` to truncate the text with an ellipsis. + */ +export const MultipleWithTruncation: StoryObj = { + args: { + ...Default.args, + label: 'Favorite Animal(s)', + multiple: true, + 'data-testid': 'dropdown', + defaultValue: [exampleOptions[0]], + className: 'w-60', + name: 'standard-button', + children: ( + <> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper isOpen={open} shouldTruncate> + {value.length > 0 ? value.length : 'none'} long selected + description + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, +}; + +/** + * The field trigger width can be set with utility classes. By default, dropdown popover will exppand to match the width. + */ +export const AdjustedWidth: StoryObj = { + args: { + ...Default.args, + className: 'w-60', + }, +}; + +/** + * We lock the maximum height of the option list to 1/4 of the available screen height. Scrolling is allowed in the list, and + * keyboard navigation (showing the items off the edge of the screen) is handled when used. + */ +export const LongOptionList: StoryObj = { + args: { + ...Default.args, + defaultValue: 'test3', + className: 'w-60', + children: ( + <> + <Select.Button> + {({ value, open, disabled }) => ( + <Select.ButtonWrapper isOpen={open} shouldTruncate> + {value} + </Select.ButtonWrapper> + )} + </Select.Button> + <Select.Options> + {Array(30) + .fill('test') + .map((option, index) => ( + // eslint-disable-next-line react/no-array-index-key + <Select.Option key={`${option}-${index}`} value={option + index}> + {option} + {index} + </Select.Option> + ))} + </Select.Options> + </> + ), + }, + play: async (playOptions) => { + const canvas = within(playOptions.canvasElement); + const selectButton = await canvas.findByRole('button'); + + await openMenu(playOptions); + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}'); + + await expect(selectButton.getAttribute('aria-expanded')).toEqual('true'); + }, + parameters: { + badges: ['intro-1.2'], + layout: 'centered', + chromatic: { delay: 450 }, + }, + decorators: [(Story) => <div className="p-8 pb-16">{Story()}</div>], +}; + +/** + * If you want a different width for the trigger and the dropdown popover, you can control them separately. + */ +export const SeparateButtonAndMenuWidth: StoryObj = { + args: { + ...Default.args, + className: 'w-40', + optionsClassName: 'w-96', + }, + play: selectCat, + parameters: { + chromatic: { + diffIncludeAntiAliasing: false, + diffThreshold: 0.72, + }, + }, + decorators: [(Story) => <div className="p-8">{Story()}</div>], +}; + +/** + * Each Select can be marked as disabled. This will update the visual treatment to indicate the field cannot be changed (but by default + * will show the selected value). + */ +export const Disabled: StoryObj = { + args: { + ...Default.args, + disabled: true, + }, + parameters: { + axe: { + disabledRules: ['color-contrast'], + }, + }, +}; + +export const Required: StoryObj = { + args: { + ...Default.args, + required: true, + showHint: true, + className: 'w-96', + }, +}; + +export const Optional: StoryObj = { + args: { + ...Default.args, + required: false, + showHint: true, + className: 'w-96', + }, +}; + +export const Error: StoryObj = { + args: { + ...Required.args, + isError: true, + fieldNote: 'Some text describing error', + }, +}; + +export const Warning: StoryObj = { + args: { + ...Optional.args, + isWarning: true, + fieldNote: 'Some text describing warning', + }, +}; + +/** + * Having a visible label is not necessary. In those cases, use `aria-label` to set a accessible label for the field + */ +export const NoVisibleLabel: StoryObj = { + args: { + ...Default.args, + label: undefined, + 'aria-label': 'hidden label', + }, +}; + +export const NoVisibleLabelButRequired: StoryObj = { + args: { + ...Default.args, + label: undefined, + 'aria-label': 'hidden label', + required: true, + className: 'w-96', + }, +}; + +export const DisabledRequired: StoryObj = { + args: { + ...Default.args, + disabled: true, + required: true, + showHint: true, + className: 'w-96', + }, + parameters: { + axe: { + disabledRules: ['color-contrast'], + }, + }, +}; + +/** + * Options for each `Select` can be aligned on different sides of the target button. Options for `placement` defined by + * PopperJS. + * + * More information: https://popper.js.org/docs/v2/constructors/#options + */ +export const OptionsRightAligned: StoryObj = { + parameters: { + chromatic: { + delay: 300, + }, + }, + args: { + ...Default.args, + className: 'w-60', + optionsClassName: 'w-96', + placement: 'bottom-end', + }, + play: openMenu, + decorators: [(Story) => <div className="p-8">{Story()}</div>], +}; + +/** + * As an alternative rendering method, you can use several types of render props for fine-grained control of the button rendering, and + * the rendering of the list itself. Here, we use a render prop to control the contents of `Select` + * + * For more information on `Select` render props, review: https://headlessui.com/react/listbox#using-render-props + */ +export const UsingFunctionProps: StoryObj = { + render: () => { + const [selectedOption, setSelectedOption] = + // eslint-disable-next-line react-hooks/rules-of-hooks + React.useState<(typeof exampleOptions)[0]>(); + + return ( + <Select + aria-label="Favorite Animal" + as="div" + data-testid="dropdown" + name="interactive-with-children" + onChange={setSelectedOption} + value={selectedOption} + > + {({ open }) => ( + <> + <Select.Button + // Because we're using a render prop to completely control the styling and icon of the + // button, we need to configure this component to render as a Fragment. Otherwise we'd + // render two, nested buttons. + as={React.Fragment} + > + {() => ( + <button aria-expanded={open} className="fpo"> + {selectedOption?.label || 'Select'} + <Icon + className="ml-4" + name="filter-list" + purpose="decorative" + /> + </button> + )} + </Select.Button> + <Select.Options> + {exampleOptions.map((option) => ( + <Select.Option key={option.key} value={option}> + {option.label} + </Select.Option> + ))} + </Select.Options> + </> + )} + </Select> + ); + }, +}; + +/** + * This shows the contents of `Select` upon render. Mostly to demonstrate it is possible, to capture a snapshot of the appearance. + */ +export const OpenByDefault: StoryObj = { + ...Default, + parameters: { + badges: ['intro-1.2', 'current-2.0'], + layout: 'centered', + chromatic: { delay: 300, disableSnapshot: true }, + }, + play: selectCat, +}; diff --git a/src/components/Select/Select-v2.tsx b/src/components/Select/Select-v2.tsx new file mode 100644 index 000000000..48a45167f --- /dev/null +++ b/src/components/Select/Select-v2.tsx @@ -0,0 +1,559 @@ +import { Listbox } from '@headlessui/react'; +import clsx from 'clsx'; + +import React, { + useContext, + useState, + type ReactNode, + type MouseEventHandler, +} from 'react'; +import { createPortal } from 'react-dom'; +import { usePopper } from 'react-popper'; + +import { useId } from '../../util/useId'; +import type { ExtractProps } from '../../util/utility-types'; +import { FieldNoteV2 as FieldNote } from '../FieldNote'; +import Icon, { type IconName } from '../Icon'; +import { InputLabelV2 as InputLabel } from '../InputLabel'; +import { + defaultPopoverModifiers, + PopoverContainerV2 as PopoverContainer, +} from '../PopoverContainer'; +import type { PopoverContext, PopoverOptions } from '../PopoverContainer'; +import { PopoverListItemV2 as PopoverListItem } from '../PopoverListItem'; +import Text from '../Text'; + +import styles from './Select-v2.module.css'; + +/** + * TODO-AH: things to add: + * - handle placeholder (and color when no value is selected) + * - handle labelLayout + * - check handling of field status across components (booleans versus status field) + */ + +type SelectProps = ExtractProps<typeof Listbox> & + PopoverOptions & { + // Component API + /** + * Screen-reader text for the select's label. + * + * When possible, use a visible label by passing a <Select.Label> into `children`. + * In rare cases where there's no visible label, you must provide an `aria-label` for screen readers. + * If you pass in an `aria-label`, <Select.Label>. + */ + 'aria-label'?: string; + /** + * Optional className for additional styling. + */ + className?: string; + /** + * Name of the form element, which triggers the generation of hidden key/value form fields (e.g. `name=$name[$key]`). + * + * See: https://headlessui.com/react/listbox#using-with-html-forms + */ + name?: string; + /** + * Optional className for additional options menu styling. + * + * When not using the compact variant, if optionsClassName is provided please + * include the width property to define the options menu width. + */ + optionsClassName?: string; + /** + * Indicates that field is required for form to be successfully submitted + */ + required?: boolean; + // Design API + /** + * Text under the text input used to provide a description or error message to describe the input. + */ + fieldNote?: ReactNode; + /** + * Whether there is an error state for the field note text (and icon) + * + * **Default is `false`**. + */ + isError?: boolean; + /** + * Whether there is a warning state for the field note text (and icon) + * + * **Default is `false`**. + */ + isWarning?: boolean; + /** + * Visible text label for the component. + */ + label?: string; + /** + * Whether it should show the field hint or not + * + * **Default is `"false"`**. + */ + showHint?: boolean; + }; + +type SelectLabelProps = ExtractProps<typeof Listbox.Label> & { + disabled?: boolean; + htmlFor: string; + required?: boolean; + showHint?: boolean; +}; +type SelectOptionsProps = ExtractProps<typeof Listbox.Options>; +type SelectOptionProps = ExtractProps<typeof Listbox.Option> & { + optionClassName?: string; +}; +type SelectButtonProps = ExtractProps<typeof Listbox.Button> & { + // Design API + /** + * Icon override for component. Default is 'expand-more' + */ + icon?: Extract<IconName, 'expand-more'>; + /** + * Indicates state of the select, used to style the button. + */ + isOpen?: boolean; +}; + +type SelectButtonWrapperProps = { + // Component API + /** + * Text placed inside the button to describe the field. + */ + children?: ReactNode; + /** + * Optional className for additional styling. + */ + className?: string; + // Design API + /** + * Icon override for component. Default is 'expand-more' + */ + icon?: Extract<IconName, 'expand-more'>; + /** + * Whether there is an error state for the field note text (and icon) + * + * **Default is `false`**. + */ + isError?: boolean; + /** + * Whether there is a warning state for the field note text (and icon) + * + * **Default is `false`**. + */ + isWarning?: boolean; + /** + * Indicates state of the select, used to style the button. + */ + isOpen?: boolean; + /** + * custom click handler for the built-in or wrapper button + */ + onClick?: MouseEventHandler; + /** + * Whether we should truncate the text displayed in the select field + */ + shouldTruncate?: boolean; +}; + +type SelectContextType = PopoverContext & { + optionsClassName?: string; + isWarning?: boolean; + isError?: boolean; +}; + +let showNameWarning = true; + +function childrenHaveLabelComponent(children?: ReactNode): boolean { + const childrenArray = React.Children.toArray(children); + return childrenArray.some((child) => { + if (typeof child === 'string' || typeof child === 'number') { + return false; + } else if ( + 'props' in child && + child.type && + typeof child.type !== 'string' && + child.type?.name === 'SelectLabel' + ) { + return true; + } else if ('props' in child && child.props.children) { + return childrenHaveLabelComponent(child.props.children); + } + return false; + }); +} + +const SelectContext = React.createContext<SelectContextType>({}); + +/** + * `import {Select} from "@chanzuckerberg/eds";` + * + * A popover that reveals or hides a list of options from which to select + * + * Supports controlled and uncontrolled behavior, using a render prop in the latter case. + * + */ +export function Select({ + 'aria-label': ariaLabel, + children, + className, + disabled, + fieldNote, + id, + isError, + isWarning, + label, + modifiers = defaultPopoverModifiers, + name, + onFirstUpdate, + optionsClassName, + placement = 'bottom-start', + required, + showHint, + strategy, + onChange: theirOnChange, + ...other +}: SelectProps) { + if (process.env.NODE_ENV !== 'production') { + const childrenHaveLabel = + children && childrenHaveLabelComponent(children as ReactNode); + if (!ariaLabel && !label && !childrenHaveLabel) { + throw new Error('You must provide a visible label or `aria-label`.'); + } + if (!name && showNameWarning) { + console.warn( + "%c`Select` won't render a form field unless you include a `name` prop.\n\n See https://headlessui.com/react/listbox#using-with-html-forms for more information", + 'font-weight: bold', + ); + showNameWarning = false; + } + } + + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const { styles: popperStyles, attributes: popperAttributes } = usePopper( + referenceElement, + popperElement, + { placement, modifiers, strategy, onFirstUpdate }, + ); + + // Create a new value to track the internal state of Listbox. Added to work around + // behavior inherited from HeadlessUI where it will fire onChange even if there is no change + // Adding to support behavior synced to how <select> tags work + const [selectedValue, setSelectedValue] = useState( + other.value !== undefined ? other.value : other.defaultValue, + ); + + const generatedIdVar = useId(); + const idVar = id || generatedIdVar; + + const componentClassName = clsx( + styles['select'], + fieldNote && styles['select--has-fieldNote'], + className, + ); + const sharedProps = { + className: componentClassName, + // Provide a wrapping <div> element for the select. This is needed so that any props + // passed directly to this component have a corresponding DOM element to receive them. + // Otherwise we get an error. + as: 'div' as const, + disabled, + id: idVar, + name, + ...other, + }; + + const contextValue = Object.assign( + {}, + optionsClassName ? { optionsClassName } : null, + { setReferenceElement }, + { setPopperElement }, + { popperStyles: popperStyles.popper }, + { popperAttributes: popperAttributes.popper }, + { isError }, + { isWarning }, + ); + + if (typeof children === 'function') { + return ( + <SelectContext.Provider value={contextValue}> + <Listbox + {...sharedProps} + // We prefer to pass the aria-label in via an invisible SelectLabel, but we can't + // easily pass down function children with component children, so we'll settle for + // using a standard aria-label in this case. + aria-label={ariaLabel} + > + {children} + </Listbox> + </SelectContext.Provider> + ); + } + + return ( + <SelectContext.Provider value={contextValue}> + <Listbox + {...sharedProps} + onChange={(changedValue) => { + if (selectedValue !== changedValue) { + setSelectedValue(changedValue); + // Use the value from the event because updates to `useState` are queued + theirOnChange && theirOnChange(changedValue); + } + }} + > + {(label || required) && ( + <Select.Label + disabled={disabled} + htmlFor={idVar} + required={required} + showHint={showHint} + > + {label} + </Select.Label> + )} + {children} + </Listbox> + {fieldNote && ( + <div className={styles['select__footer']}> + <FieldNote + disabled={disabled} + isError={isError} + isWarning={isWarning} + > + {fieldNote} + </FieldNote> + </div> + )} + </SelectContext.Provider> + ); +} + +const SelectLabel = ({ + children: label, + required, + className, + disabled, + htmlFor, + showHint, +}: SelectLabelProps) => { + const componentClassName = clsx( + styles['select__label'], + disabled && clsx(styles['select__label--disabled']), + className, + ); + + const requiredTextClassName = clsx( + styles['select__required-text'], + disabled && styles['select__required-text--disabled'], + ); + + const overlineClassName = clsx( + styles['select__overline'], + !label && styles['select__overline--no-label'], + ); + + return ( + <div className={overlineClassName}> + <Listbox.Label + as={InputLabel} + className={componentClassName} + htmlFor={htmlFor} + > + {label} + </Listbox.Label> + {required && showHint && ( + <Text as="span" className={requiredTextClassName} preset="body-sm"> + (Required) + </Text> + )} + {!required && showHint && ( + <Text as="span" className={requiredTextClassName} preset="body-sm"> + (Optional) + </Text> + )} + </div> + ); +}; + +/** + * The trigger for the select component, which is usually a form of `Button` or some targetable/clickable component + */ +const SelectButton = function (props: SelectButtonProps) { + const { children, className, onClick: theirOnClick, ...other } = props; + const { setReferenceElement, isWarning, isError } = useContext(SelectContext); + return ( + <Listbox.Button + // Render as a fragment instead of the default element. We're rendering our own element in + // the render prop to control styling and positiong, and we don't want to end up with + // duplicate buttons. + as={React.Fragment} + ref={setReferenceElement} + {...other} + > + {(renderProps) => { + return typeof children === 'function' ? ( + children(renderProps) + ) : ( + <SelectButtonWrapper + className={className} + isError={isError} + isOpen={renderProps.open} + isWarning={isWarning} + onClick={(event) => { + theirOnClick && theirOnClick(event); + }} + > + {children} + </SelectButtonWrapper> + ); + }} + </Listbox.Button> + ); +}; + +/** + * The content container showing the available options when the trigger is activated + */ +const SelectOptions = function (props: SelectOptionsProps) { + const { className, ...other } = props; + const { optionsClassName, setPopperElement, popperStyles, popperAttributes } = + useContext(SelectContext); + + const componentClassName = clsx( + styles['select__options'], + className, + optionsClassName, + ); + + const optionProps = { + as: PopoverContainer, + className: componentClassName, + ref: setPopperElement, + style: popperStyles, + ...popperAttributes, + ...other, + }; + if (typeof document !== 'undefined') { + return ( + <>{createPortal(<Listbox.Options {...optionProps} />, document.body)}</> + ); + } + return null; +}; + +/** + * Represents one of the available options for selection + */ +const SelectOption = function (props: SelectOptionProps) { + const { children, className, optionClassName, ...other } = props; + + const optionItemClassName = clsx(optionClassName, styles['select__option']); + + return ( + <Listbox.Option + // Render as a fragment instead of the default <li>. We're rendering our own <li> in the + // render prop to control active/selected styling, and we don't want to end up with duplicate + // <li>'s. + as={React.Fragment} + {...other} + > + {typeof children === 'function' + ? children + : ({ active, disabled, selected }) => { + return ( + <PopoverListItem + className={optionItemClassName} + icon={selected ? 'check' : undefined} + isDisabled={disabled} + isFocused={active} + > + <span className={styles['select__option-text']}> + {children} + </span> + </PopoverListItem> + ); + }} + </Listbox.Option> + ); +}; + +/** + * The component functioning as a styling for the trigger, selection arrow, and space to + * show the current value. + */ +export const SelectButtonWrapper = React.forwardRef< + HTMLButtonElement, + SelectButtonWrapperProps +>( + ( + { + children, + className, + icon = 'expand-more', + isError, + isOpen, + isWarning, + shouldTruncate = false, + onClick: theirOnClick, + ...other + }, + ref, + ) => { + const { isWarning: contextWarning, isError: contextError } = + useContext(SelectContext); + const showWarning = + typeof isWarning !== 'undefined' ? isWarning : contextWarning; + const showError = typeof isError !== 'undefined' ? isError : contextError; + + const componentClassName = clsx( + styles['select-button'], + showWarning && styles['select-button--warning'], + showError && styles['select-button--error'], + className, + ); + const iconClassName = clsx( + styles['select-button__icon'], + isOpen && styles['select-button__icon--reversed'], + ); + const textClassName = clsx( + shouldTruncate && styles['select-button__text--truncated'], + ); + + return ( + <button + className={componentClassName} + onClick={(ev) => { + theirOnClick && theirOnClick(ev); + }} + ref={ref} + type="button" + {...other} + > + {/* Wrapping span ensures that `children` and icon will be correctly pushed to + either side of the button even if `children` contains more than one element. */} + <span className={textClassName}>{children}</span> + <Icon + className={iconClassName} + name={icon} + purpose="decorative" + size="1.5rem" + /> + </button> + ); + }, +); + +Select.displayName = 'Select'; +SelectButton.displayName = 'Select.Button'; +SelectButtonWrapper.displayName = 'Select.ButtonWrapper'; +SelectLabel.displayName = 'Select.Label'; +SelectOption.displayName = 'Select.Option'; +SelectOptions.displayName = 'Select.Options'; + +Select.Button = SelectButton; +Select.ButtonWrapper = SelectButtonWrapper; +Select.Label = SelectLabel; +Select.Option = SelectOption; +Select.Options = SelectOptions; diff --git a/src/components/Select/index.ts b/src/components/Select/index.ts index b7cf7418b..5671b425a 100644 --- a/src/components/Select/index.ts +++ b/src/components/Select/index.ts @@ -1 +1,2 @@ export { Select as default } from './Select'; +export { Select as SelectV2 } from './Select-v2';