Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Copy link
Copy Markdown
Contributor

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


### Documentation

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/form/index.ts
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';
9 changes: 9 additions & 0 deletions packages/ui/src/form/select-control/context.tsx
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 );
};
8 changes: 8 additions & 0 deletions packages/ui/src/form/select-control/index.ts
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';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do the same for _SelectControl?

Suggested change
Item.displayName = 'SelectControl.Item';
_SelectControl.displayName = 'SelectControl';
Item.displayName = 'SelectControl.Item';


export const SelectControl = Object.assign( _SelectControl, {
Item,
} );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we align this implementation with Button especially when it comes to the type assignment?

/**
* A versatile button component with multiple variants, tones, and sizes.
* Built on design tokens for consistent theming and accessibility.
*/
export const Button = Object.assign( ButtonButton, {
/**
* An icon component specifically designed to work well when rendered inside
* a `Button` component.
*/
Icon: ButtonIcon,
} ) as typeof ButtonButton & {
Icon: typeof ButtonIcon;
};

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 SelectControl and Button (using Object.assign) in the package's Guidelines?

13 changes: 13 additions & 0 deletions packages/ui/src/form/select-control/item.tsx
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 } />;
}
);
59 changes: 59 additions & 0 deletions packages/ui/src/form/select-control/select-control.tsx
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the JSDoc (for SelectControl and Item) be added in the Object.assign call in index.ts ?

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 ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: currently an explicitly falsy children (false, null) would silently regenerates items from items — is that a side effect we want to keep?

Alternatively we could swap this check to be explicitly about children !== undefined

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>
);
} );
166 changes: 166 additions & 0 deletions packages/ui/src/form/select-control/stories/index.story.tsx
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add this guidance directly to the JSDoc of SelectControl ? It feels like an important tip that would be very useful to consumers

*/
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>
) ) }
</>
),
},
};
Loading
Loading