diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index b7e8390633c704..bd860455727d9d 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -4,6 +4,7 @@ ### Enhancements +- `Combobox`, `Select`, `SelectControl`: Add `Group` and `GroupLabel` subcomponents ([#80574](https://github.com/WordPress/gutenberg/pull/80574)). - `Popover`: Default the popup's portal container to the `@wordpress/ui` compat overlay slot when present, so popovers stack reliably above other overlays in mixed-library compositions. A caller-supplied `Popover.Portal` `container` prop continues to take precedence ([#80278](https://github.com/WordPress/gutenberg/pull/80278)). - `Button`: Add Storybook example demonstrating manual keyboard shortcut composition with `Tooltip`, `aria-keyshortcuts`, and an accessible description ([#80353](https://github.com/WordPress/gutenberg/pull/80353)). diff --git a/packages/ui/src/form/primitives/combobox/group-label.tsx b/packages/ui/src/form/primitives/combobox/group-label.tsx new file mode 100644 index 00000000000000..7d96c45e273912 --- /dev/null +++ b/packages/ui/src/form/primitives/combobox/group-label.tsx @@ -0,0 +1,27 @@ +import { Combobox as _Combobox } from '@base-ui/react/combobox'; +import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import { Text } from '../../../text'; +import type { ComboboxGroupLabelProps } from './types'; +import itemPopupStyles from '../../../utils/css/item-popup.module.css'; + +/** + * Renders a label for a `Combobox.Group`, describing the group of items + * it is associated with. + */ +export const GroupLabel = forwardRef< HTMLDivElement, ComboboxGroupLabelProps >( + function GroupLabel( { className, children, ...restProps }, ref ) { + return ( + } + > + { children } + + ); + } +); diff --git a/packages/ui/src/form/primitives/combobox/group.tsx b/packages/ui/src/form/primitives/combobox/group.tsx new file mode 100644 index 00000000000000..f4fdafbbc967c3 --- /dev/null +++ b/packages/ui/src/form/primitives/combobox/group.tsx @@ -0,0 +1,24 @@ +import { Combobox as _Combobox } from '@base-ui/react/combobox'; +import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import type { ComboboxGroupProps } from './types'; +import itemPopupStyles from '../../../utils/css/item-popup.module.css'; + +/** + * Groups related items together with an associated label rendered by + * `Combobox.GroupLabel`. When `items` is provided, child + * `Combobox.Collection` components iterate over them. + */ +export const Group = forwardRef< HTMLDivElement, ComboboxGroupProps >( + function Group( { className, children, ...restProps }, ref ) { + return ( + <_Combobox.Group + className={ clsx( itemPopupStyles.group, className ) } + ref={ ref } + { ...restProps } + > + { children } + + ); + } +); diff --git a/packages/ui/src/form/primitives/combobox/index.ts b/packages/ui/src/form/primitives/combobox/index.ts index a4cc8e604589dc..bfb4e97684a142 100644 --- a/packages/ui/src/form/primitives/combobox/index.ts +++ b/packages/ui/src/form/primitives/combobox/index.ts @@ -3,6 +3,8 @@ export { ChipWithRemove } from './chip-with-remove'; export { Clear } from './clear'; export { Collection } from './collection'; export { Empty } from './empty'; +export { Group } from './group'; +export { GroupLabel } from './group-label'; export { Input } from './input'; export { Item } from './item'; export { List } from './list'; diff --git a/packages/ui/src/form/primitives/combobox/stories/fixtures.ts b/packages/ui/src/form/primitives/combobox/stories/fixtures.ts index e1fffaba9bc5a9..e82d3b5789ec0f 100644 --- a/packages/ui/src/form/primitives/combobox/stories/fixtures.ts +++ b/packages/ui/src/form/primitives/combobox/stories/fixtures.ts @@ -3,6 +3,11 @@ export interface FixtureItem { label: string; } +export interface FixtureGroup { + label: string; + items: FixtureItem[]; +} + export const ITEMS: FixtureItem[] = [ { value: 'apple', label: 'Apple' }, { value: 'apricot', label: 'Apricot' }, @@ -55,3 +60,30 @@ export const ITEMS: FixtureItem[] = [ { value: 'tangerine', label: 'Tangerine' }, { value: 'watermelon', label: 'Watermelon' }, ]; + +export const GROUPED_ITEMS: FixtureGroup[] = [ + { + label: 'Common', + items: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'orange', label: 'Orange' }, + ], + }, + { + label: 'Berries', + items: [ + { value: 'strawberry', label: 'Strawberry' }, + { value: 'blueberry', label: 'Blueberry' }, + { value: 'raspberry', label: 'Raspberry' }, + ], + }, + { + label: 'Tropical', + items: [ + { value: 'mango', label: 'Mango' }, + { value: 'pineapple', label: 'Pineapple' }, + { value: 'papaya', label: 'Papaya' }, + ], + }, +]; diff --git a/packages/ui/src/form/primitives/combobox/stories/index.story.tsx b/packages/ui/src/form/primitives/combobox/stories/index.story.tsx index 800c322f560f2f..d23d887157b9a6 100644 --- a/packages/ui/src/form/primitives/combobox/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/combobox/stories/index.story.tsx @@ -1,7 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useEffect, useState } from '@wordpress/element'; import * as Combobox from '../index'; -import { ITEMS, type FixtureItem } from './fixtures'; +import { + ITEMS, + GROUPED_ITEMS, + type FixtureGroup, + type FixtureItem, +} from './fixtures'; const meta: Meta< typeof Combobox.Root > = { title: 'Design System/Components/Form/Primitives/Combobox', @@ -16,6 +21,8 @@ const meta: Meta< typeof Combobox.Root > = { 'Combobox.ListBody': Combobox.ListBody, 'Combobox.ListFooter': Combobox.ListFooter, 'Combobox.Collection': Combobox.Collection, + 'Combobox.Group': Combobox.Group, + 'Combobox.GroupLabel': Combobox.GroupLabel, 'Combobox.Item': Combobox.Item, 'Combobox.Value': Combobox.Value, 'Combobox.Chips': Combobox.Chips, @@ -334,6 +341,51 @@ export const WithCustomTriggerAndItem: Story = { }, }; +/** + * Options can be organized into labeled groups with `Combobox.Group` + * and `Combobox.GroupLabel`. + */ +export const Grouped: Story = { + args: { + items: GROUPED_ITEMS, + children: [ + , + +
+ +
+ No results found. + + + + { ( group: FixtureGroup ) => ( + + + { group.label } + + + { ( item: FixtureItem ) => ( + + { item.label } + + ) } + + + ) } + + + +
, + ], + }, +}; + /** * Popovers in Gutenberg are managed with explicit z-index values, which can create * situations where a popover renders below another popover, when you want it to be rendered above. diff --git a/packages/ui/src/form/primitives/combobox/test/index.test.tsx b/packages/ui/src/form/primitives/combobox/test/index.test.tsx index b0c2d61bf03f26..45e7546833bdae 100644 --- a/packages/ui/src/form/primitives/combobox/test/index.test.tsx +++ b/packages/ui/src/form/primitives/combobox/test/index.test.tsx @@ -507,6 +507,81 @@ describe( 'Combobox', () => { } ); /* eslint-enable testing-library/no-node-access */ + describe( 'grouped items', () => { + const GROUPED_ITEMS = [ + { + label: 'Group 1', + items: [ + { id: '1', value: 'Item 1' }, + { id: '2', value: 'Item 2' }, + ], + }, + { + label: 'Group 2', + items: [ { id: '3', value: 'Item 3' } ], + }, + ]; + + it( 'forwards refs', async () => { + const user = userEvent.setup(); + const groupRef = createRef< HTMLDivElement >(); + const groupLabelRef = createRef< HTMLDivElement >(); + + render( + + + + + + + + { ( group ) => ( + + + { group.label } + + + { ( item ) => ( + + { item.value } + + ) } + + + ) } + + + + + + ); + + await user.click( screen.getByRole( 'combobox' ) ); + + await waitFor( () => { + expect( groupRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); + expect( groupLabelRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); + } ); + describe( 'when disabled', () => { it( 'hides the chip remove button from screen readers', () => { renderDisabledMultiSelect(); diff --git a/packages/ui/src/form/primitives/combobox/types.ts b/packages/ui/src/form/primitives/combobox/types.ts index ed770ec2463022..4e8d560074321a 100644 --- a/packages/ui/src/form/primitives/combobox/types.ts +++ b/packages/ui/src/form/primitives/combobox/types.ts @@ -38,6 +38,16 @@ export type ComboboxEmptyProps = ComponentProps< typeof _Combobox.Empty > & { children?: React.ReactNode; }; +export type ComboboxGroupProps = ComponentProps< typeof _Combobox.Group > & { + children?: React.ReactNode; +}; + +export type ComboboxGroupLabelProps = ComponentProps< + typeof _Combobox.GroupLabel +> & { + children?: React.ReactNode; +}; + export type ComboboxInputProps = Omit< ComponentProps< typeof _Combobox.Input >, 'size' diff --git a/packages/ui/src/form/primitives/select/group-label.tsx b/packages/ui/src/form/primitives/select/group-label.tsx new file mode 100644 index 00000000000000..784ba2acc0be8f --- /dev/null +++ b/packages/ui/src/form/primitives/select/group-label.tsx @@ -0,0 +1,27 @@ +import { Select as _Select } from '@base-ui/react/select'; +import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import { Text } from '../../../text'; +import type { SelectGroupLabelProps } from './types'; +import itemPopupStyles from '../../../utils/css/item-popup.module.css'; + +/** + * Renders a label for a `Select.Group`, describing the group of items + * it is associated with. + */ +export const GroupLabel = forwardRef< HTMLDivElement, SelectGroupLabelProps >( + function GroupLabel( { className, children, ...restProps }, ref ) { + return ( + } + > + { children } + + ); + } +); diff --git a/packages/ui/src/form/primitives/select/group.tsx b/packages/ui/src/form/primitives/select/group.tsx new file mode 100644 index 00000000000000..f868cdd8139e10 --- /dev/null +++ b/packages/ui/src/form/primitives/select/group.tsx @@ -0,0 +1,23 @@ +import { Select as _Select } from '@base-ui/react/select'; +import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import type { SelectGroupProps } from './types'; +import itemPopupStyles from '../../../utils/css/item-popup.module.css'; + +/** + * Groups related items together with an associated label rendered by + * `Select.GroupLabel`. + */ +export const Group = forwardRef< HTMLDivElement, SelectGroupProps >( + function Group( { className, children, ...restProps }, ref ) { + return ( + <_Select.Group + className={ clsx( itemPopupStyles.group, className ) } + ref={ ref } + { ...restProps } + > + { children } + + ); + } +); diff --git a/packages/ui/src/form/primitives/select/index.ts b/packages/ui/src/form/primitives/select/index.ts index 3e46cbb75370f8..c3890842fc7b60 100644 --- a/packages/ui/src/form/primitives/select/index.ts +++ b/packages/ui/src/form/primitives/select/index.ts @@ -1,3 +1,5 @@ +export { Group } from './group'; +export { GroupLabel } from './group-label'; export { Item } from './item'; export { Popup } from './popup'; export { Portal } from './portal'; diff --git a/packages/ui/src/form/primitives/select/stories/index.story.tsx b/packages/ui/src/form/primitives/select/stories/index.story.tsx index 5e791fe17556da..cb544a5ea43cc8 100644 --- a/packages/ui/src/form/primitives/select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/select/stories/index.story.tsx @@ -9,6 +9,8 @@ const meta: Meta< typeof Select.Root > = { 'Select.Portal': Select.Portal, 'Select.Positioner': Select.Positioner, 'Select.Popup': Select.Popup, + 'Select.Group': Select.Group, + 'Select.GroupLabel': Select.GroupLabel, 'Select.Item': Select.Item, }, parameters: { @@ -93,6 +95,58 @@ export const Minimal: Story = { }, }; +const groupedItems = [ + { + label: 'Common', + items: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'orange', label: 'Orange' }, + ], + }, + { + label: 'Berries', + items: [ + { value: 'strawberry', label: 'Strawberry' }, + { value: 'blueberry', label: 'Blueberry' }, + { value: 'raspberry', label: 'Raspberry' }, + ], + }, + { + label: 'Tropical', + items: [ + { value: 'mango', label: 'Mango' }, + { value: 'pineapple', label: 'Pineapple' }, + { value: 'papaya', label: 'Papaya' }, + ], + }, +]; + +/** + * Options can be organized into labeled groups with `Select.Group` + * and `Select.GroupLabel`. + */ +export const Grouped: Story = { + args: { + items: groupedItems.flatMap( ( group ) => group.items ), + children: [ + , + + { groupedItems.map( ( group ) => ( + + { group.label } + { group.items.map( ( item ) => ( + + { item.label } + + ) ) } + + ) ) } + , + ], + }, +}; + /** * Use the `placeholder` prop on `Select.Trigger` to show text when no * value is selected. The default placeholder is `"Select"`. diff --git a/packages/ui/src/form/primitives/select/test/index.test.tsx b/packages/ui/src/form/primitives/select/test/index.test.tsx index c339a20014c5d4..6d09c4e874fd5c 100644 --- a/packages/ui/src/form/primitives/select/test/index.test.tsx +++ b/packages/ui/src/form/primitives/select/test/index.test.tsx @@ -321,4 +321,69 @@ describe( 'Select', () => { } ); } ); /* eslint-enable testing-library/no-node-access */ + + describe( 'grouped items', () => { + const GROUPED_ITEMS = [ + { + label: 'Group 1', + items: [ + { value: 'item-1', label: 'Item 1' }, + { value: 'item-2', label: 'Item 2' }, + ], + }, + { + label: 'Group 2', + items: [ { value: 'item-3', label: 'Item 3' } ], + }, + ]; + + it( 'forwards refs', async () => { + const user = userEvent.setup(); + const groupRef = createRef< HTMLDivElement >(); + const groupLabelRef = createRef< HTMLDivElement >(); + + render( + group.items ) } + > + + + { GROUPED_ITEMS.map( ( group ) => ( + + + { group.label } + + { group.items.map( ( item ) => ( + + { item.label } + + ) ) } + + ) ) } + + + ); + + await user.click( screen.getByRole( 'combobox' ) ); + + expect( groupRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( groupLabelRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); + } ); } ); diff --git a/packages/ui/src/form/primitives/select/types.ts b/packages/ui/src/form/primitives/select/types.ts index 48253eb6e2532c..a795802ac29b2c 100644 --- a/packages/ui/src/form/primitives/select/types.ts +++ b/packages/ui/src/form/primitives/select/types.ts @@ -44,6 +44,16 @@ export type SelectTriggerProps = ComponentProps< typeof _Select.Trigger > & { children?: _Select.Value.Props[ 'children' ]; }; +export type SelectGroupProps = ComponentProps< typeof _Select.Group > & { + children?: React.ReactNode; +}; + +export type SelectGroupLabelProps = ComponentProps< + typeof _Select.GroupLabel +> & { + children?: React.ReactNode; +}; + export type SelectPopupProps = ComponentProps< typeof _Select.Popup > & { /** * The content to be rendered inside the popup. diff --git a/packages/ui/src/form/select-control/index.ts b/packages/ui/src/form/select-control/index.ts index a5fa597aa21ed4..d050ef325e5ef7 100644 --- a/packages/ui/src/form/select-control/index.ts +++ b/packages/ui/src/form/select-control/index.ts @@ -1,12 +1,25 @@ import { SelectControl as _SelectControl } from './select-control'; +import { Group } from '../primitives/select/group'; +import { GroupLabel } from '../primitives/select/group-label'; import { Item } from './item'; +Group.displayName = 'SelectControl.Group'; +GroupLabel.displayName = 'SelectControl.GroupLabel'; Item.displayName = 'SelectControl.Item'; /** * A complete select field with integrated label and description. */ export const SelectControl = Object.assign( _SelectControl, { + /** + * Groups related items together with an associated label rendered by + * `SelectControl.GroupLabel`. + */ + Group, + /** + * Renders a label for a `SelectControl.Group`. + */ + GroupLabel, /** * An item rendered inside a `SelectControl` popup. */ diff --git a/packages/ui/src/form/select-control/stories/index.story.tsx b/packages/ui/src/form/select-control/stories/index.story.tsx index d863873bca08e6..7db171b2b6e5b8 100644 --- a/packages/ui/src/form/select-control/stories/index.story.tsx +++ b/packages/ui/src/form/select-control/stories/index.story.tsx @@ -9,6 +9,8 @@ const meta: Meta< typeof SelectControl > = { title: 'Design System/Components/Form/SelectControl', component: SelectControl, subcomponents: { + 'SelectControl.Group': SelectControl.Group, + 'SelectControl.GroupLabel': SelectControl.GroupLabel, 'SelectControl.Item': SelectControl.Item, }, argTypes: { @@ -123,6 +125,64 @@ export const WithDisabledOption: Story = { }, }; +const groupedItems = [ + { + label: 'Common', + items: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'orange', label: 'Orange' }, + ], + }, + { + label: 'Berries', + items: [ + { value: 'strawberry', label: 'Strawberry' }, + { value: 'blueberry', label: 'Blueberry' }, + { value: 'raspberry', label: 'Raspberry' }, + ], + }, + { + label: 'Tropical', + items: [ + { value: 'mango', label: 'Mango' }, + { value: 'pineapple', label: 'Pineapple' }, + { value: 'papaya', label: 'Papaya' }, + ], + }, +]; + +/** + * Options can be organized into labeled groups with `SelectControl.Group` + * and `SelectControl.GroupLabel`. Pass a flat `items` array for trigger label + * resolution, and use `children` to render the grouped popup content. + */ +export const Grouped: Story = { + args: { + label: 'Fruit', + description: 'Choose a fruit.', + items: groupedItems.flatMap( ( group ) => group.items ), + children: [ + groupedItems.map( ( group ) => ( + + + { group.label } + + { group.items.map( ( item ) => ( + + { item.label } + + ) ) } + + ) ), + ], + }, +}; + const userOptions: React.ComponentProps< typeof SelectControl >[ 'items' ] = [ { value: '1',