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