From 14b3e89ca97517d87bac21500a30ffb22107a668 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Thu, 21 Mar 2024 17:21:50 -0500 Subject: [PATCH] feat(Select)!: introduce 2.0 component - 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 ( - - ); -}; +export const InputLabel = React.forwardRef( + ({ 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 ( + + ); + }, +); 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 = { + 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: ( + <> + + {({ value, open }) => ( + + {value.label} + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, + parameters: { + docs: { + source: { + code: ` +`, + }, + }, + }, +}; + +/** + * 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 Option - + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, +}; + +/** + * `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: ( + <> + + {({ value, open }) => ( + console.log('custom click')} + > + {value.label} + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, + parameters: { + docs: { + source: { + code: ` +`, + }, + }, + }, +}; + +/** + * `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: ( + <> + console.log('external click')} + > + - Select Option - + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + onChange: (args: SelectOption) => console.log('external change', args), + }, +}; + +/** + * You can select a different option to show when rendered. + */ +export const WithSelectedOption: StoryObj = { + 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: ( + <> + + {({ value, open, disabled }) => ( + + {value.label} + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, +}; + +export const WithFieldNote: StoryObj = { + args: { + ...Default.args, + fieldNote: 'Choose your beast', + children: ( + <> + + {({ value, open, disabled }) => ( + + {value.label} + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, +}; + +/** + * 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: ( + <> + + {({ value, open, disabled }) => ( + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, +}; + +/** + * 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: ( + <> + + {({ value, open, disabled }) => ( + + {value.label} + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, +}; + +/** + * 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: ( + <> + + {({ value, open, disabled }) => ( + + {value.length > 0 ? value.length : 'none'} selected + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, + parameters: { + docs: { + source: { + code: ` +`, + }, + }, + }, +}; + +/** + * 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: ( + <> + + {({ value, open, disabled }) => ( + + {value.length > 0 ? value.length : 'none'} long selected + description + + )} + + + {exampleOptions.map((option) => ( + + {option.label} + + ))} + + + ), + }, +}; + +/** + * 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: ( + <> + + {({ value, open, disabled }) => ( + + {value} + + )} + + + {Array(30) + .fill('test') + .map((option, index) => ( + // eslint-disable-next-line react/no-array-index-key + + {option} + {index} + + ))} + + + ), + }, + 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) =>
{Story()}
], +}; + +/** + * 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) =>
{Story()}
], +}; + +/** + * 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) =>
{Story()}
], +}; + +/** + * 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 ( + + ); + }, +}; + +/** + * 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 & + PopoverOptions & { + // Component API + /** + * Screen-reader text for the select's label. + * + * When possible, use a visible label by passing a 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`, . + */ + '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 & { + disabled?: boolean; + htmlFor: string; + required?: boolean; + showHint?: boolean; +}; +type SelectOptionsProps = ExtractProps; +type SelectOptionProps = ExtractProps & { + optionClassName?: string; +}; +type SelectButtonProps = ExtractProps & { + // Design API + /** + * Icon override for component. Default is 'expand-more' + */ + icon?: Extract; + /** + * 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; + /** + * 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({}); + +/** + * `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