From 082134291097743c095dba9ebc8e205f068b4ac0 Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Thu, 30 Apr 2026 01:24:11 +0900 Subject: [PATCH 1/2] Add SelectControl component --- packages/ui/src/form/index.ts | 1 + .../ui/src/form/select-control/context.tsx | 9 + packages/ui/src/form/select-control/index.ts | 8 + packages/ui/src/form/select-control/item.tsx | 13 ++ .../form/select-control/select-control.tsx | 58 ++++++ .../select-control/stories/index.story.tsx | 166 ++++++++++++++++++ .../form/select-control/test/index.test.tsx | 150 ++++++++++++++++ packages/ui/src/form/select-control/types.ts | 42 +++++ 8 files changed, 447 insertions(+) create mode 100644 packages/ui/src/form/select-control/context.tsx create mode 100644 packages/ui/src/form/select-control/index.ts create mode 100644 packages/ui/src/form/select-control/item.tsx create mode 100644 packages/ui/src/form/select-control/select-control.tsx create mode 100644 packages/ui/src/form/select-control/stories/index.story.tsx create mode 100644 packages/ui/src/form/select-control/test/index.test.tsx create mode 100644 packages/ui/src/form/select-control/types.ts diff --git a/packages/ui/src/form/index.ts b/packages/ui/src/form/index.ts index 419ec6230db808..500b83f687719e 100644 --- a/packages/ui/src/form/index.ts +++ b/packages/ui/src/form/index.ts @@ -1,3 +1,4 @@ export * from './primitives'; export * from './input-control'; +export * from './select-control'; diff --git a/packages/ui/src/form/select-control/context.tsx b/packages/ui/src/form/select-control/context.tsx new file mode 100644 index 00000000000000..424f883852fc11 --- /dev/null +++ b/packages/ui/src/form/select-control/context.tsx @@ -0,0 +1,9 @@ +import { createContext, useContext } from '@wordpress/element'; +import type { SelectTriggerProps } from '../primitives/select/types'; + +export const SelectControlSizeContext = + createContext< SelectTriggerProps[ 'size' ] >( undefined ); + +export const useSelectControlSizeContext = () => { + return useContext( SelectControlSizeContext ); +}; diff --git a/packages/ui/src/form/select-control/index.ts b/packages/ui/src/form/select-control/index.ts new file mode 100644 index 00000000000000..a013b961458d05 --- /dev/null +++ b/packages/ui/src/form/select-control/index.ts @@ -0,0 +1,8 @@ +import { SelectControl as _SelectControl } from './select-control'; +import { Item } from './item'; + +Item.displayName = 'SelectControl.Item'; + +export const SelectControl = Object.assign( _SelectControl, { + Item, +} ); diff --git a/packages/ui/src/form/select-control/item.tsx b/packages/ui/src/form/select-control/item.tsx new file mode 100644 index 00000000000000..eadb7bf3b5b7b0 --- /dev/null +++ b/packages/ui/src/form/select-control/item.tsx @@ -0,0 +1,13 @@ +import { forwardRef } from '@wordpress/element'; +import { Select } from '../primitives'; +import { useSelectControlSizeContext } from './context'; +import type { SelectItemProps } from '../primitives/select/types'; + +export const Item = forwardRef< HTMLDivElement, SelectItemProps >( + function Item( { size: sizeProp, ...restProps }, ref ) { + const contextSize = useSelectControlSizeContext(); + const size = sizeProp ?? contextSize; + + return ; + } +); diff --git a/packages/ui/src/form/select-control/select-control.tsx b/packages/ui/src/form/select-control/select-control.tsx new file mode 100644 index 00000000000000..85dbf8e7ec9bd4 --- /dev/null +++ b/packages/ui/src/form/select-control/select-control.tsx @@ -0,0 +1,58 @@ +import { forwardRef } from '@wordpress/element'; +import { Field, Select } from '../primitives'; +import { SelectControlSizeContext } from './context'; +import { Item } from './item'; +import type { SelectControlProps } from './types'; + +/** + * A complete select field with integrated label and description. + */ +export const SelectControl = forwardRef< HTMLInputElement, SelectControlProps >( + function SelectControl( + { + className, + children, + items, + label, + description, + details, + hideLabelFromVision, + size = 'default', + triggerContent, + ...restProps + }, + ref + ) { + return ( + + + { label } + + + + { triggerContent } + + + + { children || + items?.map( ( item ) => ( + + { item.label } + + ) ) } + + + + { description && ( + { description } + ) } + { details && { details } } + + ); + } +); diff --git a/packages/ui/src/form/select-control/stories/index.story.tsx b/packages/ui/src/form/select-control/stories/index.story.tsx new file mode 100644 index 00000000000000..d8205ba7491981 --- /dev/null +++ b/packages/ui/src/form/select-control/stories/index.story.tsx @@ -0,0 +1,166 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SelectControl } from '../'; +import { + WITH_DETAILS_DESCRIPTION, + DETAILS_EXAMPLE, +} from '../../stories/shared'; + +const meta: Meta< typeof SelectControl > = { + title: 'Design System/Components/Form/SelectControl', + component: SelectControl, + subcomponents: { + Item: SelectControl.Item, + }, + argTypes: { + onValueChange: { action: 'onValueChange' }, + }, +}; + +export default meta; + +type Story = StoryObj< typeof SelectControl >; + +/** + * When showing a "placeholder" item, prefer a concise label such as "Select" + * without a trailing ellipsis. By default, items with an empty string value + * will be rendered with a lighter color in the trigger. + * + * It is recommended to also add `disabled` to the placeholder item so it cannot be reselected. + */ +export const Default: Story = { + args: { + items: [ + { + value: '', + label: 'Select', + disabled: true, + }, + { + value: '1', + label: 'Item 1', + }, + { + value: '2', + label: 'Item 2', + }, + ], + label: 'Label', + description: 'This is the description.', + defaultValue: '', + }, +}; + +export const VisuallyHiddenLabel: Story = { + args: { + ...Default.args, + hideLabelFromVision: true, + }, +}; + +export const WithDetails: Story = { + parameters: { + docs: { description: { story: WITH_DETAILS_DESCRIPTION } }, + }, + args: { + ...Default.args, + description: undefined, + details: DETAILS_EXAMPLE, + }, +}; + +export const WithDisabledOption: Story = { + args: { + items: [ + { + value: '1', + label: 'Item 1', + }, + { + value: '2', + label: 'Item 2', + disabled: true, + }, + ], + label: 'Label', + description: 'This is the description.', + defaultValue: '1', + }, +}; + +const User = ( { uuid }: { uuid: string } ) => ( + + + { `User ${ uuid }` } + +); + +/** + * To customize what is rendered inside the trigger element, pass a + * render function to the `triggerContent` prop. + * + * The item list can be customized by passing an array of + * `SelectControl.Item` as children. Note that the `label` prop of a `SelectControl.Item` + * is used as the string to match against in the typeahead functionality, while the + * item content is determined by `children`. + */ +export const WithCustomTriggerAndItems: Story = { + args: { + label: 'Label', + description: 'This is the description.', + triggerContent: ( value: string ) => , + children: ( + <> + { [ '1', '2', '3' ].map( ( item ) => ( + + + + ) ) } + + ), + defaultValue: '1', + }, +}; + +/** + * By default, the `items` array is used to render both the Trigger + * and the Item list. Passing a custom `triggerContent` or `children` in addition + * to `items` will override that particular aspect of the behavior. + * In other words, if you pass both an `items` array and a custom `triggerContent`, + * the Item list in the popover will still be rendered based on the `items` array. + */ +export const WithItemsArrayAndPartialCustomization: Story = { + args: { + ...Default.args, + children: ( + <> + { Default.args?.items?.map( ( item ) => ( + + ✨ { item.label } + + ) ) } + + ), + }, +}; diff --git a/packages/ui/src/form/select-control/test/index.test.tsx b/packages/ui/src/form/select-control/test/index.test.tsx new file mode 100644 index 00000000000000..d103e2f430ad28 --- /dev/null +++ b/packages/ui/src/form/select-control/test/index.test.tsx @@ -0,0 +1,150 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from '@wordpress/element'; +import { SelectControl } from '../index'; + +describe( 'SelectControl', () => { + const mockItems = [ + { value: '', label: 'Select' }, + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + ]; + + it( 'forwards ref', () => { + const ref = createRef< HTMLInputElement >(); + + render( + + ); + + expect( ref.current ).toBeInstanceOf( HTMLInputElement ); + } ); + + it( 'renders accessible label and description', () => { + render( + + ); + + expect( + screen.getByRole( 'combobox', { + name: 'Country', + description: 'Choose your country', + } ) + ).toBeVisible(); + } ); + + it( 'renders with a visually hidden label', () => { + render( + + ); + + expect( + screen.getByRole( 'combobox', { name: 'Country' } ) + ).toBeVisible(); + } ); + + it( 'renders with details', () => { + render( + + Select the country where your store is registered. + + } + /> + ); + + expect( + screen.getByText( /where your store is registered/ ) + ).toBeVisible(); + } ); + + describe( 'Form data behavior', () => { + it( 'submits correct form data when option is selected with custom name', async () => { + const user = userEvent.setup(); + const handleSubmit = jest.fn( + ( event: React.FormEvent< HTMLFormElement > ) => { + event.preventDefault(); + return new FormData( event.currentTarget ); + } + ); + + render( +
+ + + + ); + + await user.click( + screen.getByRole( 'combobox', { + name: 'Country', + } ) + ); + + const optionToSelect = await screen.findByRole( 'option', { + name: /Option 2/i, + } ); + await user.click( optionToSelect ); + + await user.click( + screen.getByRole( 'button', { + name: 'Submit', + } ) + ); + + const formData = handleSubmit.mock.results[ 0 ].value; + expect( formData.get( 'country' ) ).toBe( 'option2' ); + } ); + + it( 'submits form data with default value when no selection is made', async () => { + const user = userEvent.setup(); + const handleSubmit = jest.fn( + ( event: React.FormEvent< HTMLFormElement > ) => { + event.preventDefault(); + return new FormData( event.currentTarget ); + } + ); + + render( +
+ + + + ); + + await user.click( + screen.getByRole( 'button', { + name: 'Submit', + } ) + ); + + const formData = handleSubmit.mock.results[ 0 ].value; + expect( formData.get( 'country' ) ).toBe( '' ); + } ); + } ); +} ); diff --git a/packages/ui/src/form/select-control/types.ts b/packages/ui/src/form/select-control/types.ts new file mode 100644 index 00000000000000..e1999cb727bf74 --- /dev/null +++ b/packages/ui/src/form/select-control/types.ts @@ -0,0 +1,42 @@ +import type { ControlProps } from '../types'; +import type { + SelectRootProps, + SelectTriggerProps, +} from '../primitives/select/types'; + +export type SelectItem = { + label: string; + value: string; + disabled?: boolean; +}; + +export type SelectControlProps = Omit< SelectRootProps, 'items' | 'inputRef' > & + ControlProps & { + /** + * CSS class to apply. + */ + className?: string; + /** + * The array of option items to render in the select. + */ + items?: SelectItem[]; + /** + * The custom trigger content to use instead of the default. + * + * ```jsx + * triggerContent={ ( value ) => ( + * + * + * { value } + * + * ) } + * ``` + */ + triggerContent?: SelectTriggerProps[ 'children' ]; + /** + * The size of the control. + * + * @default 'default' + */ + size?: Exclude< SelectTriggerProps[ 'size' ], 'small' >; + }; From 1091be59fbf4fb9799cac5adae35b7db1a6ec5a8 Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Thu, 30 Apr 2026 03:31:13 +0900 Subject: [PATCH 2/2] Forward SelectControl ref to trigger --- packages/ui/CHANGELOG.md | 1 + .../form/select-control/select-control.tsx | 99 ++++++++++--------- .../form/select-control/test/index.test.tsx | 20 ++-- 3 files changed, 62 insertions(+), 58 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 3566bb11a50396..13f95e32bf34a0 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -12,6 +12,7 @@ - Add `Drawer` primitive ([#76690](https://github.com/WordPress/gutenberg/pull/76690)). - Add `Autocomplete` primitive ([#77642](https://github.com/WordPress/gutenberg/pull/77642)). +- Add `SelectControl` component ([#77809](https://github.com/WordPress/gutenberg/pull/77809)). ### Documentation diff --git a/packages/ui/src/form/select-control/select-control.tsx b/packages/ui/src/form/select-control/select-control.tsx index 85dbf8e7ec9bd4..609c001ffc3fa6 100644 --- a/packages/ui/src/form/select-control/select-control.tsx +++ b/packages/ui/src/form/select-control/select-control.tsx @@ -7,52 +7,53 @@ import type { SelectControlProps } from './types'; /** * A complete select field with integrated label and description. */ -export const SelectControl = forwardRef< HTMLInputElement, SelectControlProps >( - function SelectControl( - { - className, - children, - items, - label, - description, - details, - hideLabelFromVision, - size = 'default', - triggerContent, - ...restProps - }, - ref - ) { - return ( - - - { label } - - - - { triggerContent } - - - - { children || - items?.map( ( item ) => ( - - { item.label } - - ) ) } - - - - { description && ( - { description } - ) } - { details && { details } } - - ); - } -); +export const SelectControl = forwardRef< + HTMLButtonElement, + SelectControlProps +>( function SelectControl( + { + className, + children, + items, + label, + description, + details, + hideLabelFromVision, + size = 'default', + triggerContent, + ...restProps + }, + ref +) { + return ( + + + { label } + + + + { triggerContent } + + + + { children || + items?.map( ( item ) => ( + + { item.label } + + ) ) } + + + + { description && ( + { description } + ) } + { details && { details } } + + ); +} ); diff --git a/packages/ui/src/form/select-control/test/index.test.tsx b/packages/ui/src/form/select-control/test/index.test.tsx index d103e2f430ad28..e932a85097e23a 100644 --- a/packages/ui/src/form/select-control/test/index.test.tsx +++ b/packages/ui/src/form/select-control/test/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createRef } from '@wordpress/element'; import { SelectControl } from '../index'; @@ -11,18 +11,20 @@ describe( 'SelectControl', () => { { value: 'option3', label: 'Option 3' }, ]; - it( 'forwards ref', () => { - const ref = createRef< HTMLInputElement >(); + it( 'forwards ref to the visible trigger', () => { + const ref = createRef< HTMLButtonElement >(); render( - + ); - expect( ref.current ).toBeInstanceOf( HTMLInputElement ); + const trigger = screen.getByRole( 'combobox', { name: 'Country' } ); + + expect( ref.current ).toBe( trigger ); + act( () => { + ref.current?.focus(); + } ); + expect( trigger ).toHaveFocus(); } ); it( 'renders accessible label and description', () => {