-
Notifications
You must be signed in to change notification settings - Fork 4.8k
Add SelectControl component to @wordpress/ui #77809
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| export * from './primitives'; | ||
|
|
||
| export * from './input-control'; | ||
| export * from './select-control'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ); | ||
| }; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,8 @@ | ||||||||||||||||||||||||||||
| import { SelectControl as _SelectControl } from './select-control'; | ||||||||||||||||||||||||||||
| import { Item } from './item'; | ||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| Item.displayName = 'SelectControl.Item'; | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we do the same for
Suggested change
|
||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||
| export const SelectControl = Object.assign( _SelectControl, { | ||||||||||||||||||||||||||||
| Item, | ||||||||||||||||||||||||||||
| } ); | ||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we align this implementation with gutenberg/packages/ui/src/button/index.ts Lines 4 to 16 in 253bb2a
Whichever we choose (explicit type casting or implicit), we should use it consistently. Possible, we could also document the difference between "real" compound components (using barrel export) vs higher level components like |
||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <Select.Item size={ size } ref={ ref } { ...restProps } />; | ||
| } | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| 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< | ||
|
Comment on lines
+7
to
+10
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should the JSDoc (for |
||
| HTMLButtonElement, | ||
| SelectControlProps | ||
| >( function SelectControl( | ||
| { | ||
| className, | ||
| children, | ||
| items, | ||
| label, | ||
| description, | ||
| details, | ||
| hideLabelFromVision, | ||
| size = 'default', | ||
| triggerContent, | ||
| ...restProps | ||
| }, | ||
| ref | ||
| ) { | ||
| return ( | ||
| <Field.Root className={ className }> | ||
| <Field.Label hideFromVision={ hideLabelFromVision }> | ||
| { label } | ||
| </Field.Label> | ||
| <Select.Root items={ items } { ...restProps }> | ||
| <Select.Trigger ref={ ref } size={ size }> | ||
| { triggerContent } | ||
| </Select.Trigger> | ||
| <Select.Popup> | ||
| <SelectControlSizeContext.Provider value={ size }> | ||
| { children || | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: currently an explicitly falsy children ( Alternatively we could swap this check to be explicitly about |
||
| items?.map( ( item ) => ( | ||
| <Item | ||
| key={ item.value } | ||
| value={ item.value } | ||
| label={ item.label } | ||
| disabled={ item.disabled } | ||
| > | ||
| { item.label } | ||
| </Item> | ||
| ) ) } | ||
| </SelectControlSizeContext.Provider> | ||
| </Select.Popup> | ||
| </Select.Root> | ||
| { description && ( | ||
| <Field.Description>{ description }</Field.Description> | ||
| ) } | ||
| { details && <Field.Details>{ details }</Field.Details> } | ||
| </Field.Root> | ||
| ); | ||
| } ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
|
Comment on lines
+24
to
+28
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we add this guidance directly to the JSDoc of |
||
| */ | ||
| 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 } ) => ( | ||
| <span | ||
| style={ { | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: 8, | ||
| } } | ||
| > | ||
| <img | ||
| src={ `https://gravatar.com/avatar/?d=initials&initials=U${ uuid }` } | ||
| alt="" | ||
| width="20" | ||
| style={ { | ||
| borderRadius: '50%', | ||
| } } | ||
| /> | ||
| { `User ${ uuid }` } | ||
| </span> | ||
| ); | ||
|
|
||
| /** | ||
| * 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 ) => <User uuid={ value } />, | ||
| children: ( | ||
| <> | ||
| { [ '1', '2', '3' ].map( ( item ) => ( | ||
| <SelectControl.Item | ||
| key={ item } | ||
| value={ item } | ||
| label={ `User ${ item }` } | ||
| > | ||
| <User uuid={ item } /> | ||
| </SelectControl.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 ) => ( | ||
| <SelectControl.Item | ||
| key={ item.value } | ||
| value={ item.value } | ||
| label={ item.label } | ||
| disabled={ item.disabled } | ||
| > | ||
| ✨ { item.label } | ||
| </SelectControl.Item> | ||
| ) ) } | ||
| </> | ||
| ), | ||
| }, | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We'll likely need a rebase to move the entry to the new UNRELEASED section