From 385d6fa4e19cb6afcc4773f6dfb15928c317189c Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Tue, 29 Sep 2026 20:30:37 +0900 Subject: [PATCH 1/8] Add separators to item popup components --- packages/ui/CHANGELOG.md | 1 + .../src/form/primitives/autocomplete/index.ts | 1 + .../primitives/autocomplete/separator.tsx | 21 ++++++++ .../autocomplete/stories/index.story.tsx | 51 ++++++++++++++++++- .../src/form/primitives/autocomplete/types.ts | 4 ++ .../ui/src/form/primitives/combobox/index.ts | 1 + .../form/primitives/combobox/separator.tsx | 20 ++++++++ .../combobox/stories/index.story.tsx | 49 +++++++++++++++++- .../ui/src/form/primitives/combobox/types.ts | 4 ++ .../searchable-chip-select/index.ts | 2 + .../stories/index.story.tsx | 40 ++++++++------- .../primitives/searchable-select/index.ts | 2 + .../searchable-select/stories/index.story.tsx | 37 ++++++++------ .../ui/src/form/primitives/select/index.ts | 1 + .../src/form/primitives/select/separator.tsx | 20 ++++++++ .../primitives/select/stories/index.story.tsx | 26 ++++++++++ .../ui/src/form/primitives/select/types.ts | 2 + .../primitives/test/separator.jsdom.test.tsx | 22 ++++++++ .../searchable-chip-select-control/index.ts | 2 + .../stories/index.story.tsx | 2 + .../form/searchable-select-control/index.ts | 2 + .../stories/index.story.tsx | 1 + packages/ui/src/form/select-control/index.ts | 5 ++ .../select-control/stories/index.story.tsx | 34 +++++++++++++ .../ui/src/utils/css/item-popup.module.css | 11 ++++ 25 files changed, 324 insertions(+), 37 deletions(-) create mode 100644 packages/ui/src/form/primitives/autocomplete/separator.tsx create mode 100644 packages/ui/src/form/primitives/combobox/separator.tsx create mode 100644 packages/ui/src/form/primitives/select/separator.tsx create mode 100644 packages/ui/src/form/primitives/test/separator.jsdom.test.tsx diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 5ee434fdf5b2ed..a41b90c42805c4 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -8,6 +8,7 @@ ### New Features +- `Autocomplete`, `Combobox`, `Select`, `SearchableSelect`, `SearchableChipSelect`, and the select controls: Add `Separator` subcomponents for dividing popup items and groups. - Add `SearchableSelectControl` component ([#80979](https://github.com/WordPress/gutenberg/pull/80979)). ### Bug Fixes diff --git a/packages/ui/src/form/primitives/autocomplete/index.ts b/packages/ui/src/form/primitives/autocomplete/index.ts index e169bcd9df8c0e..bab3fe6b6ac28f 100644 --- a/packages/ui/src/form/primitives/autocomplete/index.ts +++ b/packages/ui/src/form/primitives/autocomplete/index.ts @@ -13,6 +13,7 @@ export { Portal } from './portal'; export { Positioner } from './positioner'; export { Root } from './root'; export { Row } from './row'; +export { Separator } from './separator'; export { Status } from './status'; export { useFilteredItems } from './use-filtered-items'; export { Value } from './value'; diff --git a/packages/ui/src/form/primitives/autocomplete/separator.tsx b/packages/ui/src/form/primitives/autocomplete/separator.tsx new file mode 100644 index 00000000000000..de1413095850fa --- /dev/null +++ b/packages/ui/src/form/primitives/autocomplete/separator.tsx @@ -0,0 +1,21 @@ +import { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete'; +import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import itemPopupStyles from '../../../utils/css/item-popup.module.css'; +import type { AutocompleteSeparatorProps } from './types'; + +/** + * Renders a visual separator between autocomplete items or groups. + */ +export const Separator = forwardRef< + HTMLDivElement, + AutocompleteSeparatorProps +>( function AutocompleteSeparator( { className, ...props }, ref ) { + return ( + <_Autocomplete.Separator + ref={ ref } + className={ clsx( itemPopupStyles.separator, className ) } + { ...props } + /> + ); +} ); diff --git a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx index 39f64bd33b2927..263abcd81624f4 100644 --- a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import type { CSSProperties } from 'react'; -import { useRef, useState } from '@wordpress/element'; +import { Fragment, useRef, useState } from '@wordpress/element'; import { search } from '@wordpress/icons'; import * as Autocomplete from '../index'; import { Icon } from '../../../../icon'; @@ -37,6 +37,7 @@ const meta: Meta< typeof Autocomplete.Root > = { 'Autocomplete.GroupLabel': Autocomplete.GroupLabel, 'Autocomplete.Item': Autocomplete.Item, 'Autocomplete.Row': Autocomplete.Row, + 'Autocomplete.Separator': Autocomplete.Separator, 'Autocomplete.Value': Autocomplete.Value, 'Autocomplete.Empty': Autocomplete.Empty, 'Autocomplete.Status': Autocomplete.Status, @@ -89,6 +90,54 @@ export const Default: Story = { }, }; +/** + * Use `Autocomplete.Separator` to divide related suggestions in the popup. + */ +export const WithSeparator: Story = { + args: { + items: [ + { label: 'Suggested', items: URLS.slice( 0, 2 ) }, + { label: 'More links', items: URLS.slice( 2, 4 ) }, + ], + openOnInputClick: true, + children: [ + , + + + + + { ( group: FixtureGroup, index: number ) => ( + + { index > 0 && } + + + { group.label } + + + { ( item: FixtureItem ) => ( + + { item.value } + + ) } + + + + ) } + + + + , + ], + }, +}; + /** * Controls the popup so it only opens when there is at least one match. */ diff --git a/packages/ui/src/form/primitives/autocomplete/types.ts b/packages/ui/src/form/primitives/autocomplete/types.ts index d6ed0fc0f84dd1..d95e67191c9e2c 100644 --- a/packages/ui/src/form/primitives/autocomplete/types.ts +++ b/packages/ui/src/form/primitives/autocomplete/types.ts @@ -91,6 +91,10 @@ export type AutocompleteRowProps = ComponentProps< children?: React.ReactNode; }; +export type AutocompleteSeparatorProps = ComponentProps< + typeof _Autocomplete.Separator +>; + export type AutocompleteStatusProps = ComponentProps< typeof _Autocomplete.Status > & { diff --git a/packages/ui/src/form/primitives/combobox/index.ts b/packages/ui/src/form/primitives/combobox/index.ts index 80f5599d43b991..fb4f81bff6bc23 100644 --- a/packages/ui/src/form/primitives/combobox/index.ts +++ b/packages/ui/src/form/primitives/combobox/index.ts @@ -15,6 +15,7 @@ export { Portal } from './portal'; export { Positioner } from './positioner'; export { Popup } from './popup'; export { Root } from './root'; +export { Separator } from './separator'; export { Status } from './status'; export { Trigger } from './trigger'; export { useFilteredItems } from './use-filtered-items'; diff --git a/packages/ui/src/form/primitives/combobox/separator.tsx b/packages/ui/src/form/primitives/combobox/separator.tsx new file mode 100644 index 00000000000000..60c34ae621a505 --- /dev/null +++ b/packages/ui/src/form/primitives/combobox/separator.tsx @@ -0,0 +1,20 @@ +import { Combobox as _Combobox } from '@base-ui/react/combobox'; +import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import itemPopupStyles from '../../../utils/css/item-popup.module.css'; +import type { ComboboxSeparatorProps } from './types'; + +/** + * Renders a visual separator between combobox items or groups. + */ +export const Separator = forwardRef< HTMLDivElement, ComboboxSeparatorProps >( + function ComboboxSeparator( { className, ...props }, ref ) { + return ( + <_Combobox.Separator + ref={ ref } + className={ clsx( itemPopupStyles.separator, className ) } + { ...props } + /> + ); + } +); 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 d12cf88590c9f6..578a90cded816c 100644 --- a/packages/ui/src/form/primitives/combobox/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/combobox/stories/index.story.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useRef, useState } from '@wordpress/element'; +import { Fragment, useRef, useState } from '@wordpress/element'; import * as Combobox from '../index'; import { Spinner } from '../../../../spinner'; import { Stack } from '../../../../stack'; @@ -29,6 +29,7 @@ const meta: Meta< typeof Combobox.Root > = { 'Combobox.Group': Combobox.Group, 'Combobox.GroupLabel': Combobox.GroupLabel, 'Combobox.Item': Combobox.Item, + 'Combobox.Separator': Combobox.Separator, 'Combobox.Value': Combobox.Value, 'Combobox.Chips': Combobox.Chips, 'Combobox.ChipWithRemove': Combobox.ChipWithRemove, @@ -85,6 +86,52 @@ export const Default: Story = { }, }; +/** + * Use `Combobox.Separator` to divide related options in the popup. + */ +export const WithSeparator: Story = { + args: { + items: [ + { label: 'Suggested', items: ITEMS.slice( 0, 2 ) }, + { label: 'More fruit', items: ITEMS.slice( 2, 4 ) }, + ], + children: [ + , + +
+ +
+ + + + { ( group: FixtureGroup, index: number ) => ( + + { index > 0 && } + + + { group.label } + + + { ( item: FixtureItem ) => ( + + { item.label } + + ) } + + + + ) } + + + +
, + ], + }, +}; + export const Compact: Story = { args: { defaultValue: ITEMS[ 0 ], diff --git a/packages/ui/src/form/primitives/combobox/types.ts b/packages/ui/src/form/primitives/combobox/types.ts index a7b2bc8ac09403..c5ef2833a0c340 100644 --- a/packages/ui/src/form/primitives/combobox/types.ts +++ b/packages/ui/src/form/primitives/combobox/types.ts @@ -115,6 +115,10 @@ export type ComboboxRootProps< Multiple extends boolean | undefined = false, > = _Combobox.Root.Props< Value, Multiple >; +export type ComboboxSeparatorProps = ComponentProps< + typeof _Combobox.Separator +>; + export type ComboboxStatusProps = ComponentProps< typeof _Combobox.Status > & { children?: React.ReactNode; }; diff --git a/packages/ui/src/form/primitives/searchable-chip-select/index.ts b/packages/ui/src/form/primitives/searchable-chip-select/index.ts index 8c3c5b5467760c..65a582d004e5d2 100644 --- a/packages/ui/src/form/primitives/searchable-chip-select/index.ts +++ b/packages/ui/src/form/primitives/searchable-chip-select/index.ts @@ -2,6 +2,7 @@ import { Item } from '../combobox/item'; import { ChipWithRemove } from '../combobox/chip-with-remove'; import { Group } from '../combobox/group'; import { GroupLabel } from '../combobox/group-label'; +import { Separator } from '../combobox/separator'; import { Collection } from '../combobox/collection'; import { useFilteredItems } from '../combobox/use-filtered-items'; import { SearchableChipSelect as _SearchableChipSelect } from './searchable-chip-select'; @@ -22,6 +23,7 @@ export const SearchableChipSelect = Object.assign( _SearchableChipSelect, { ChipWithRemove, Group, GroupLabel, + Separator, Collection, useFilteredItems, } ); diff --git a/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx index a59d8d2da48258..efe099204d0cfe 100644 --- a/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; +import { Fragment } from '@wordpress/element'; import { SearchableChipSelect } from '../'; import { GROUPED_ITEMS, @@ -16,6 +17,7 @@ const meta: Meta< typeof SearchableChipSelect > = { subcomponents: { 'SearchableChipSelect.Group': SearchableChipSelect.Group, 'SearchableChipSelect.GroupLabel': SearchableChipSelect.GroupLabel, + 'SearchableChipSelect.Separator': SearchableChipSelect.Separator, 'SearchableChipSelect.Item': SearchableChipSelect.Item, 'SearchableChipSelect.ChipWithRemove': SearchableChipSelect.ChipWithRemove, @@ -100,25 +102,25 @@ export const Grouped: Story = { args: { 'aria-label': 'Fruit', items: GROUPED_ITEMS, - children: ( group: FixtureGroup ) => ( - - - { group.label } - - - { ( item: FixtureItem ) => ( - - { item.label } - - ) } - - + children: ( group: FixtureGroup, index: number ) => ( + + { index > 0 && } + + + { group.label } + + + { ( item: FixtureItem ) => ( + + { item.label } + + ) } + + + ), }, }; diff --git a/packages/ui/src/form/primitives/searchable-select/index.ts b/packages/ui/src/form/primitives/searchable-select/index.ts index f038be69f0c991..08c59f3cc318ba 100644 --- a/packages/ui/src/form/primitives/searchable-select/index.ts +++ b/packages/ui/src/form/primitives/searchable-select/index.ts @@ -1,6 +1,7 @@ import { Item } from './item'; import { Group } from '../combobox/group'; import { GroupLabel } from '../combobox/group-label'; +import { Separator } from '../combobox/separator'; import { Collection } from '../combobox/collection'; import { useFilteredItems } from '../combobox/use-filtered-items'; import { SearchableSelect as _SearchableSelect } from './searchable-select'; @@ -19,6 +20,7 @@ export const SearchableSelect = Object.assign( _SearchableSelect, { Item, Group, GroupLabel, + Separator, Collection, useFilteredItems, } ); diff --git a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx index ee28fc4c5ac3c3..b07820bcf7aaea 100644 --- a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { fn } from 'storybook/test'; +import { Fragment } from '@wordpress/element'; import { SearchableSelect } from '../'; import { GROUPED_ITEMS, @@ -15,6 +16,7 @@ const meta: Meta< typeof SearchableSelect > = { subcomponents: { 'SearchableSelect.Group': SearchableSelect.Group, 'SearchableSelect.GroupLabel': SearchableSelect.GroupLabel, + 'SearchableSelect.Separator': SearchableSelect.Separator, 'SearchableSelect.Item': SearchableSelect.Item, 'SearchableSelect.Collection': SearchableSelect.Collection, }, @@ -118,22 +120,25 @@ export const Grouped: Story = { args: { ...Default.args, items: GROUPED_ITEMS, - children: ( group: FixtureGroup ) => ( - - - { group.label } - - - { ( item: FixtureItem ) => ( - - { item.label } - - ) } - - + children: ( group: FixtureGroup, index: number ) => ( + + { index > 0 && } + + + { group.label } + + + { ( item: FixtureItem ) => ( + + { item.label } + + ) } + + + ), }, }; diff --git a/packages/ui/src/form/primitives/select/index.ts b/packages/ui/src/form/primitives/select/index.ts index 244ab446b479ab..4e56825beb4458 100644 --- a/packages/ui/src/form/primitives/select/index.ts +++ b/packages/ui/src/form/primitives/select/index.ts @@ -7,4 +7,5 @@ export { Popup } from './popup'; export { Portal } from './portal'; export { Positioner } from './positioner'; export { Root } from './root'; +export { Separator } from './separator'; export { Trigger } from './trigger'; diff --git a/packages/ui/src/form/primitives/select/separator.tsx b/packages/ui/src/form/primitives/select/separator.tsx new file mode 100644 index 00000000000000..5239cfeab8b274 --- /dev/null +++ b/packages/ui/src/form/primitives/select/separator.tsx @@ -0,0 +1,20 @@ +import { Select as _Select } from '@base-ui/react/select'; +import clsx from 'clsx'; +import { forwardRef } from '@wordpress/element'; +import itemPopupStyles from '../../../utils/css/item-popup.module.css'; +import type { SelectSeparatorProps } from './types'; + +/** + * Renders a visual separator between select items or groups. + */ +export const Separator = forwardRef< HTMLDivElement, SelectSeparatorProps >( + function SelectSeparator( { className, ...props }, ref ) { + return ( + <_Select.Separator + ref={ ref } + className={ clsx( itemPopupStyles.separator, className ) } + { ...props } + /> + ); + } +); 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 1fc6898b2d43fa..6c398117e6eb7d 100644 --- a/packages/ui/src/form/primitives/select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/select/stories/index.story.tsx @@ -16,6 +16,7 @@ const meta: Meta< typeof Select.Root > = { 'Select.Item': Select.Item, 'Select.ItemLabel': Select.ItemLabel, 'Select.ItemDescription': Select.ItemDescription, + 'Select.Separator': Select.Separator, }, argTypes: { onValueChange: { action: 'onValueChange' }, @@ -52,6 +53,31 @@ export const Default: Story = { }, }; +/** + * Use `Select.Separator` to divide related options in the popup. + */ +export const WithSeparator: Story = { + args: { + items: defaultItems.slice( 0, 4 ), + children: [ + , + + { defaultItems.slice( 0, 2 ).map( ( item ) => ( + + { item.label } + + ) ) } + + { defaultItems.slice( 2, 4 ).map( ( item ) => ( + + { item.label } + + ) ) } + , + ], + }, +}; + export const Compact: Story = { args: { ...Default.args, diff --git a/packages/ui/src/form/primitives/select/types.ts b/packages/ui/src/form/primitives/select/types.ts index 468404cb785c74..ed226f3d49922c 100644 --- a/packages/ui/src/form/primitives/select/types.ts +++ b/packages/ui/src/form/primitives/select/types.ts @@ -54,6 +54,8 @@ export type SelectGroupLabelProps = ComponentProps< children?: React.ReactNode; }; +export type SelectSeparatorProps = ComponentProps< typeof _Select.Separator >; + export type SelectPopupProps = ComponentProps< typeof _Select.Popup > & ItemPopupWidthProps & { /** diff --git a/packages/ui/src/form/primitives/test/separator.jsdom.test.tsx b/packages/ui/src/form/primitives/test/separator.jsdom.test.tsx new file mode 100644 index 00000000000000..a4805108f91cb1 --- /dev/null +++ b/packages/ui/src/form/primitives/test/separator.jsdom.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { createRef } from '@wordpress/element'; +import * as Autocomplete from '../autocomplete'; +import * as Combobox from '../combobox'; +import * as Select from '../select'; + +describe.each( [ + [ 'Autocomplete', Autocomplete.Separator ], + [ 'Combobox', Combobox.Separator ], + [ 'Select', Select.Separator ], +] )( '%s.Separator', ( _name, Separator ) => { + it( 'renders a presentational divider and forwards its class and ref', () => { + const ref = createRef< HTMLDivElement >(); + render( ); + const separator = screen.getByRole( 'presentation' ); + + expect( separator ).toHaveAttribute( 'role', 'presentation' ); + expect( separator ).toHaveClass( 'custom-separator' ); + expect( ref.current ).toBe( separator ); + } ); +} ); diff --git a/packages/ui/src/form/searchable-chip-select-control/index.ts b/packages/ui/src/form/searchable-chip-select-control/index.ts index 6b1e8fefba225e..b60d1c27e1ee4e 100644 --- a/packages/ui/src/form/searchable-chip-select-control/index.ts +++ b/packages/ui/src/form/searchable-chip-select-control/index.ts @@ -1,6 +1,7 @@ import { SearchableChipSelectControl as _SearchableChipSelectControl } from './searchable-chip-select-control'; import { Group } from '../primitives/combobox/group'; import { GroupLabel } from '../primitives/combobox/group-label'; +import { Separator } from '../primitives/combobox/separator'; import { Item } from '../primitives/combobox/item'; import { ChipWithRemove } from '../primitives/combobox/chip-with-remove'; import { Collection } from '../primitives/combobox/collection'; @@ -15,6 +16,7 @@ export const SearchableChipSelectControl = Object.assign( { Group, GroupLabel, + Separator, Item, ChipWithRemove, Collection, diff --git a/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx index e5af858ffe6241..209234c6df8c14 100644 --- a/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx @@ -28,6 +28,8 @@ const meta: Meta< typeof SearchableChipSelectControl > = { 'SearchableChipSelectControl.Group': SearchableChipSelectControl.Group, 'SearchableChipSelectControl.GroupLabel': SearchableChipSelectControl.GroupLabel, + 'SearchableChipSelectControl.Separator': + SearchableChipSelectControl.Separator, 'SearchableChipSelectControl.Item': SearchableChipSelectControl.Item, 'SearchableChipSelectControl.ChipWithRemove': SearchableChipSelectControl.ChipWithRemove, diff --git a/packages/ui/src/form/searchable-select-control/index.ts b/packages/ui/src/form/searchable-select-control/index.ts index 6e917ba06f23cd..2bf0a7ddf5a027 100644 --- a/packages/ui/src/form/searchable-select-control/index.ts +++ b/packages/ui/src/form/searchable-select-control/index.ts @@ -1,6 +1,7 @@ import { SearchableSelectControl as _SearchableSelectControl } from './searchable-select-control'; import { Group } from '../primitives/combobox/group'; import { GroupLabel } from '../primitives/combobox/group-label'; +import { Separator } from '../primitives/combobox/separator'; import { Item } from '../primitives/searchable-select/item'; import { Collection } from '../primitives/combobox/collection'; import { useFilteredItems } from '../primitives/combobox/use-filtered-items'; @@ -13,6 +14,7 @@ export const SearchableSelectControl = Object.assign( { Group, GroupLabel, + Separator, Item, Collection, useFilteredItems, diff --git a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx index 2e59fd20632803..f38d6972ad2fb8 100644 --- a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx @@ -28,6 +28,7 @@ const meta: Meta< typeof SearchableSelectControl > = { 'SearchableSelectControl.Group': SearchableSelectControl.Group, 'SearchableSelectControl.GroupLabel': SearchableSelectControl.GroupLabel, + 'SearchableSelectControl.Separator': SearchableSelectControl.Separator, 'SearchableSelectControl.Item': SearchableSelectControl.Item, 'SearchableSelectControl.Collection': SearchableSelectControl.Collection, diff --git a/packages/ui/src/form/select-control/index.ts b/packages/ui/src/form/select-control/index.ts index 82e16bf320556f..3f4eab3c8118cb 100644 --- a/packages/ui/src/form/select-control/index.ts +++ b/packages/ui/src/form/select-control/index.ts @@ -3,6 +3,7 @@ import { Group } from '../primitives/select/group'; import { GroupLabel } from '../primitives/select/group-label'; import { ItemDescription } from '../primitives/select/item-description'; import { ItemLabel } from '../primitives/select/item-label'; +import { Separator } from '../primitives/select/separator'; import { Item } from './item'; Group.displayName = 'SelectControl.Group'; @@ -37,4 +38,8 @@ export const SelectControl = Object.assign( _SelectControl, { * `aria-describedby`. */ ItemDescription, + /** + * Renders a visual separator between items or groups. + */ + Separator, } ); 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 41231f0e49cdb7..4eb9ce12ea4838 100644 --- a/packages/ui/src/form/select-control/stories/index.story.tsx +++ b/packages/ui/src/form/select-control/stories/index.story.tsx @@ -18,6 +18,7 @@ const meta: Meta< typeof SelectControl > = { 'SelectControl.Item': SelectControl.Item, 'SelectControl.ItemLabel': SelectControl.ItemLabel, 'SelectControl.ItemDescription': SelectControl.ItemDescription, + 'SelectControl.Separator': SelectControl.Separator, }, argTypes: { onValueChange: { action: 'onValueChange' }, @@ -53,6 +54,39 @@ export const Default: Story = { }, }; +const separatorItems = [ ...defaultItems, { value: '3', label: 'Item 3' } ]; + +/** + * Use `SelectControl.Separator` when composing popup items as children. + */ +export const WithSeparator: Story = { + args: { + label: 'Item', + items: separatorItems, + children: [ + ...defaultItems.map( ( item ) => ( + + + { item.label } + + + ) ), + , + + Item 3 + , + ], + }, +}; + /** * When no value is selected, the trigger shows the default placeholder text. * diff --git a/packages/ui/src/utils/css/item-popup.module.css b/packages/ui/src/utils/css/item-popup.module.css index 0d2aacc7bb2f02..1a3b4ef481c76b 100644 --- a/packages/ui/src/utils/css/item-popup.module.css +++ b/packages/ui/src/utils/css/item-popup.module.css @@ -109,6 +109,17 @@ color: var(--wpds-color-foreground-content-neutral-weak); } + .separator { + height: var(--wpds-border-width-xs); + margin-block: var(--wpds-dimension-gap-xs); + margin-inline: calc(var(--wp-ui-popup-padding) + var(--wpds-dimension-padding-md)); + background-color: var(--wpds-color-stroke-surface-neutral-weak); + + @media ( forced-colors: active ) { + background-color: CanvasText; + } + } + .item { --wp-ui-popup-item-height: var(--wpds-dimension-size-md); --wp-ui-popup-item-padding-inline: var(--wpds-dimension-padding-md); From 870540b126bd6ad152b084f7cd9fb5da9f98412f Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Tue, 29 Sep 2026 20:34:11 +0900 Subject: [PATCH 2/8] Link separator changelog entry to PR #83776 --- packages/ui/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index a41b90c42805c4..a8528ae5cc7f6d 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -8,7 +8,7 @@ ### New Features -- `Autocomplete`, `Combobox`, `Select`, `SearchableSelect`, `SearchableChipSelect`, and the select controls: Add `Separator` subcomponents for dividing popup items and groups. +- `Autocomplete`, `Combobox`, `Select`, `SearchableSelect`, `SearchableChipSelect`, and the select controls: Add `Separator` subcomponents for dividing popup items and groups ([#83776](https://github.com/WordPress/gutenberg/pull/83776)). - Add `SearchableSelectControl` component ([#80979](https://github.com/WordPress/gutenberg/pull/80979)). ### Bug Fixes From 53d860117c08ddca76a24c94067ed69fc893fcb6 Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Tue, 29 Sep 2026 21:34:29 +0900 Subject: [PATCH 3/8] Show separators in dedicated searchable stories and focus ref tests --- .../autocomplete/test/index.jsdom.test.tsx | 7 +++++ .../combobox/test/index.jsdom.test.tsx | 8 +++++ .../stories/index.story.tsx | 31 +++++++++++++++++++ .../searchable-select/stories/index.story.tsx | 28 +++++++++++++++++ .../select/test/index.jsdom.test.tsx | 9 +++++- .../primitives/test/separator.jsdom.test.tsx | 22 ------------- .../stories/index.story.tsx | 11 +++++++ .../stories/index.story.tsx | 11 +++++++ 8 files changed, 104 insertions(+), 23 deletions(-) delete mode 100644 packages/ui/src/form/primitives/test/separator.jsdom.test.tsx diff --git a/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx b/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx index e667caa8978e3e..c8381bed614b03 100644 --- a/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx +++ b/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx @@ -20,6 +20,13 @@ function renderDisabledAutocompleteWithClear() { } describe( 'Autocomplete', () => { + it( 'forwards the Separator ref to its element', () => { + const ref = createRef< HTMLDivElement >(); + render( ); + + expect( ref.current ).toBeInstanceOf( HTMLDivElement ); + } ); + it( 'uses the item label as its accessible name and describes it in order', () => { const item = { value: 'apple', label: 'Apple' }; diff --git a/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx b/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx index 001d10cd9b1bda..5460781aaab5e7 100644 --- a/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx +++ b/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; +import { createRef } from '@wordpress/element'; import type { ComponentType, ReactNode } from 'react'; import * as Combobox from '../index'; @@ -53,6 +54,13 @@ function renderDisabledMultiSelect() { } describe( 'Combobox', () => { + it( 'forwards the Separator ref to its element', () => { + const ref = createRef< HTMLDivElement >(); + render( ); + + expect( ref.current ).toBeInstanceOf( HTMLDivElement ); + } ); + it( 'uses the item label as its accessible name and describes it in order', () => { const item = { value: 'apple', label: 'Apple' }; diff --git a/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx index d4f923dd5d499a..c3e99e0232ab2a 100644 --- a/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx @@ -121,6 +121,37 @@ export const Grouped: Story = { args: { 'aria-label': 'Fruit', items: GROUPED_ITEMS, + children: ( group: FixtureGroup ) => ( + + + { group.label } + + + { ( item: FixtureItem ) => ( + + + { item.label } + + + ) } + + + ), + }, +}; + +/** + * Use `SearchableChipSelect.Separator` to divide visible groups of items. + */ +export const WithSeparator: Story = { + args: { + ...Grouped.args, children: ( group: FixtureGroup, index: number ) => ( { index > 0 && } diff --git a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx index 137b68480df784..5ae07d41fedde0 100644 --- a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx @@ -137,6 +137,34 @@ export const Grouped: Story = { args: { ...Default.args, items: GROUPED_ITEMS, + children: ( group: FixtureGroup ) => ( + + + { group.label } + + + { ( item: FixtureItem ) => ( + + + { item.label } + + + ) } + + + ), + }, +}; + +/** + * Use `SearchableSelect.Separator` to divide visible groups of items. + */ +export const WithSeparator: Story = { + args: { + ...Grouped.args, children: ( group: FixtureGroup, index: number ) => ( { index > 0 && } diff --git a/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx b/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx index d41ba8b1b5d9dc..01aae6a800d8b7 100644 --- a/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx +++ b/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx @@ -1,11 +1,18 @@ import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { useId } from '@wordpress/element'; +import { createRef, useId } from '@wordpress/element'; import type { ComponentType, ReactNode } from 'react'; import * as Select from '../index'; describe( 'Select', () => { + it( 'forwards the Separator ref to its element', () => { + const ref = createRef< HTMLDivElement >(); + render( ); + + expect( ref.current ).toBeInstanceOf( HTMLDivElement ); + } ); + it( 'auto-resolves trigger label from items when value is an object', () => { const users = [ { value: '1', label: 'User 1' }, diff --git a/packages/ui/src/form/primitives/test/separator.jsdom.test.tsx b/packages/ui/src/form/primitives/test/separator.jsdom.test.tsx deleted file mode 100644 index a4805108f91cb1..00000000000000 --- a/packages/ui/src/form/primitives/test/separator.jsdom.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import { createRef } from '@wordpress/element'; -import * as Autocomplete from '../autocomplete'; -import * as Combobox from '../combobox'; -import * as Select from '../select'; - -describe.each( [ - [ 'Autocomplete', Autocomplete.Separator ], - [ 'Combobox', Combobox.Separator ], - [ 'Select', Select.Separator ], -] )( '%s.Separator', ( _name, Separator ) => { - it( 'renders a presentational divider and forwards its class and ref', () => { - const ref = createRef< HTMLDivElement >(); - render( ); - const separator = screen.getByRole( 'presentation' ); - - expect( separator ).toHaveAttribute( 'role', 'presentation' ); - expect( separator ).toHaveClass( 'custom-separator' ); - expect( ref.current ).toBe( separator ); - } ); -} ); diff --git a/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx index 73117bb45416e9..a4d97ccbcfe571 100644 --- a/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx @@ -292,6 +292,17 @@ export const Grouped: Story = { }, }; +/** + * Use `SearchableChipSelectControl.Separator` to divide visible groups of items. + */ +export const WithSeparator: Story = { + ...Grouped, + args: { + ...Grouped.args, + children: SearchableChipSelectStories.WithSeparator.args?.children, + }, +}; + /** * Grouped items with a creatable footer item. Include the creatable item in * `items` as a creatable-only group. Handle the creation of the item in diff --git a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx index f52514c324a4c9..e0cb642e486c18 100644 --- a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx @@ -284,6 +284,17 @@ export const Grouped: Story = { }, }; +/** + * Use `SearchableSelectControl.Separator` to divide visible groups of items. + */ +export const WithSeparator: Story = { + ...Grouped, + args: { + ...Grouped.args, + children: SearchableSelectStories.WithSeparator.args?.children, + }, +}; + /** * Grouped items with a creatable footer item. Include the creatable item in * `items` as a creatable-only group. Handle the creation of the item in From c6dbb61aabbfa7ecb041a73f9a4bc69cf3fb43c0 Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Tue, 29 Sep 2026 21:43:59 +0900 Subject: [PATCH 4/8] Move separator ref coverage into browser tests --- .../primitives/autocomplete/test/index.browser.test.tsx | 3 +++ .../primitives/autocomplete/test/index.jsdom.test.tsx | 7 ------- .../form/primitives/combobox/test/index.browser.test.tsx | 3 +++ .../form/primitives/combobox/test/index.jsdom.test.tsx | 8 -------- .../form/primitives/select/test/index.browser.test.tsx | 3 +++ .../src/form/primitives/select/test/index.jsdom.test.tsx | 9 +-------- 6 files changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/ui/src/form/primitives/autocomplete/test/index.browser.test.tsx b/packages/ui/src/form/primitives/autocomplete/test/index.browser.test.tsx index 2690d93e4ecab7..7519fc5459fb28 100644 --- a/packages/ui/src/form/primitives/autocomplete/test/index.browser.test.tsx +++ b/packages/ui/src/form/primitives/autocomplete/test/index.browser.test.tsx @@ -65,6 +65,7 @@ describe( 'Autocomplete', () => { const popupRef = createRef< HTMLDivElement >(); const listRef = createRef< HTMLDivElement >(); const listBodyRef = createRef< HTMLDivElement >(); + const separatorRef = createRef< HTMLDivElement >(); const itemRef = createRef< HTMLDivElement >(); const clearRef = createRef< HTMLButtonElement >(); const emptyRef = createRef< HTMLDivElement >(); @@ -103,6 +104,7 @@ describe( 'Autocomplete', () => { + @@ -118,6 +120,7 @@ describe( 'Autocomplete', () => { } ); expect( listRef.current ).toBeInstanceOf( HTMLDivElement ); expect( listBodyRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( separatorRef.current ).toBeInstanceOf( HTMLDivElement ); expect( itemRef.current ).toBeInstanceOf( HTMLDivElement ); expect( clearRef.current ).toBeInstanceOf( HTMLButtonElement ); expect( emptyRef.current ).toBeInstanceOf( HTMLDivElement ); diff --git a/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx b/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx index c8381bed614b03..e667caa8978e3e 100644 --- a/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx +++ b/packages/ui/src/form/primitives/autocomplete/test/index.jsdom.test.tsx @@ -20,13 +20,6 @@ function renderDisabledAutocompleteWithClear() { } describe( 'Autocomplete', () => { - it( 'forwards the Separator ref to its element', () => { - const ref = createRef< HTMLDivElement >(); - render( ); - - expect( ref.current ).toBeInstanceOf( HTMLDivElement ); - } ); - it( 'uses the item label as its accessible name and describes it in order', () => { const item = { value: 'apple', label: 'Apple' }; diff --git a/packages/ui/src/form/primitives/combobox/test/index.browser.test.tsx b/packages/ui/src/form/primitives/combobox/test/index.browser.test.tsx index 9e276725106720..5dceecbd0264a0 100644 --- a/packages/ui/src/form/primitives/combobox/test/index.browser.test.tsx +++ b/packages/ui/src/form/primitives/combobox/test/index.browser.test.tsx @@ -72,6 +72,7 @@ describe( 'Combobox', () => { const inputRef = createRef< HTMLInputElement >(); const listRef = createRef< HTMLDivElement >(); const listBodyRef = createRef< HTMLDivElement >(); + const separatorRef = createRef< HTMLDivElement >(); const listFooterRef = createRef< HTMLDivElement >(); const itemRef = createRef< HTMLDivElement >(); const chipsRef = createRef< HTMLDivElement >(); @@ -124,6 +125,7 @@ describe( 'Combobox', () => { ) } + @@ -145,6 +147,7 @@ describe( 'Combobox', () => { expect( inputRef.current ).toBeInstanceOf( HTMLInputElement ); expect( listRef.current ).toBeInstanceOf( HTMLDivElement ); expect( listBodyRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( separatorRef.current ).toBeInstanceOf( HTMLDivElement ); expect( listFooterRef.current ).toBeInstanceOf( HTMLDivElement ); expect( itemRef.current ).toBeInstanceOf( HTMLDivElement ); expect( chipsRef.current ).toBeInstanceOf( HTMLDivElement ); diff --git a/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx b/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx index 5460781aaab5e7..001d10cd9b1bda 100644 --- a/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx +++ b/packages/ui/src/form/primitives/combobox/test/index.jsdom.test.tsx @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; -import { createRef } from '@wordpress/element'; import type { ComponentType, ReactNode } from 'react'; import * as Combobox from '../index'; @@ -54,13 +53,6 @@ function renderDisabledMultiSelect() { } describe( 'Combobox', () => { - it( 'forwards the Separator ref to its element', () => { - const ref = createRef< HTMLDivElement >(); - render( ); - - expect( ref.current ).toBeInstanceOf( HTMLDivElement ); - } ); - it( 'uses the item label as its accessible name and describes it in order', () => { const item = { value: 'apple', label: 'Apple' }; diff --git a/packages/ui/src/form/primitives/select/test/index.browser.test.tsx b/packages/ui/src/form/primitives/select/test/index.browser.test.tsx index 9e35eebc94d05a..5ad9c2116d7cc7 100644 --- a/packages/ui/src/form/primitives/select/test/index.browser.test.tsx +++ b/packages/ui/src/form/primitives/select/test/index.browser.test.tsx @@ -56,6 +56,7 @@ describe( 'Select', () => { const user = userEvent; const triggerRef = createRef< HTMLButtonElement >(); const popupRef = createRef< HTMLDivElement >(); + const separatorRef = createRef< HTMLDivElement >(); const itemRef = createRef< HTMLDivElement >(); await render( @@ -65,6 +66,7 @@ describe( 'Select', () => { Item 1 + Item 2 @@ -80,6 +82,7 @@ describe( 'Select', () => { // Now test that the popup and item refs are also available expect( popupRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( separatorRef.current ).toBeInstanceOf( HTMLDivElement ); expect( itemRef.current ).toBeInstanceOf( HTMLDivElement ); } ); diff --git a/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx b/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx index 01aae6a800d8b7..d41ba8b1b5d9dc 100644 --- a/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx +++ b/packages/ui/src/form/primitives/select/test/index.jsdom.test.tsx @@ -1,18 +1,11 @@ import { describe, expect, it } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { createRef, useId } from '@wordpress/element'; +import { useId } from '@wordpress/element'; import type { ComponentType, ReactNode } from 'react'; import * as Select from '../index'; describe( 'Select', () => { - it( 'forwards the Separator ref to its element', () => { - const ref = createRef< HTMLDivElement >(); - render( ); - - expect( ref.current ).toBeInstanceOf( HTMLDivElement ); - } ); - it( 'auto-resolves trigger label from items when value is an object', () => { const users = [ { value: '1', label: 'User 1' }, From 21b418136a181167878142488f5759070eb22783 Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Tue, 29 Sep 2026 21:58:48 +0900 Subject: [PATCH 5/8] Show separators in flat item popup stories --- .../autocomplete/stories/index.story.tsx | 41 +++++++------------ .../combobox/stories/index.story.tsx | 37 ++++++----------- .../stories/index.story.tsx | 35 ++++++---------- .../searchable-select/stories/index.story.tsx | 35 ++++++---------- .../primitives/select/stories/index.story.tsx | 22 ++++++---- .../stories/index.story.tsx | 9 ++-- .../stories/index.story.tsx | 10 +++-- .../select-control/stories/index.story.tsx | 18 ++++---- 8 files changed, 87 insertions(+), 120 deletions(-) diff --git a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx index ceb94d28d1f4a6..3b75860499d67e 100644 --- a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx @@ -128,45 +128,32 @@ export const WithItemDescriptions: Story = { }; /** - * Use `Autocomplete.Separator` to divide related suggestions in the popup. + * Use `Autocomplete.Separator` to set a distinct suggestion apart from commands. */ export const WithSeparator: Story = { args: { - items: [ - { label: 'Suggested', items: URLS.slice( 0, 2 ) }, - { label: 'More links', items: URLS.slice( 2, 4 ) }, - ], + items: [ ...COMMANDS.slice( 0, 3 ), { id: 'help', value: 'Get help' } ], openOnInputClick: true, children: [ , - { ( group: FixtureGroup, index: number ) => ( - - { index > 0 && } - - - { group.label } - - - { ( item: FixtureItem ) => ( - - - { item.value } - - - ) } - - + { ( item: FixtureItem, index: number ) => ( + + { item.id === 'help' && index > 0 && ( + + ) } + + + { item.value } + + ) } 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 035f806ce487c5..2be23bd2694efc 100644 --- a/packages/ui/src/form/primitives/combobox/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/combobox/stories/index.story.tsx @@ -91,14 +91,11 @@ export const Default: Story = { }; /** - * Use `Combobox.Separator` to divide related options in the popup. + * Use `Combobox.Separator` to set an "Other" choice apart from the fruit. */ export const WithSeparator: Story = { args: { - items: [ - { label: 'Suggested', items: ITEMS.slice( 0, 2 ) }, - { label: 'More fruit', items: ITEMS.slice( 2, 4 ) }, - ], + items: [ ...ITEMS.slice( 0, 3 ), { value: 'other', label: 'Other' } ], children: [ , @@ -108,26 +105,16 @@ export const WithSeparator: Story = { - { ( group: FixtureGroup, index: number ) => ( - - { index > 0 && } - - - { group.label } - - - { ( item: FixtureItem ) => ( - - - { item.label } - - - ) } - - + { ( item: FixtureItem, index: number ) => ( + + { item.value === 'other' && index > 0 && ( + + ) } + + + { item.label } + + ) } diff --git a/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx index c3e99e0232ab2a..45ce766bd55149 100644 --- a/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx @@ -147,31 +147,22 @@ export const Grouped: Story = { }; /** - * Use `SearchableChipSelect.Separator` to divide visible groups of items. + * Use `SearchableChipSelect.Separator` to set an "Other" choice apart from the fruit. */ export const WithSeparator: Story = { args: { - ...Grouped.args, - children: ( group: FixtureGroup, index: number ) => ( - - { index > 0 && } - - - { group.label } - - - { ( item: FixtureItem ) => ( - - - { item.label } - - - ) } - - + ...Default.args, + items: [ ...ITEMS.slice( 0, 3 ), { value: 'other', label: 'Other' } ], + children: ( item: FixtureItem, index: number ) => ( + + { item.value === 'other' && index > 0 && ( + + ) } + + + { item.label } + + ), }, diff --git a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx index 5ae07d41fedde0..4d507193896a07 100644 --- a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx @@ -160,31 +160,22 @@ export const Grouped: Story = { }; /** - * Use `SearchableSelect.Separator` to divide visible groups of items. + * Use `SearchableSelect.Separator` to set an "Other" choice apart from the fruit. */ export const WithSeparator: Story = { args: { - ...Grouped.args, - children: ( group: FixtureGroup, index: number ) => ( - - { index > 0 && } - - - { group.label } - - - { ( item: FixtureItem ) => ( - - - { item.label } - - - ) } - - + ...Default.args, + items: [ ...ITEMS.slice( 0, 3 ), { value: 'other', label: 'Other' } ], + children: ( item: FixtureItem, index: number ) => ( + + { item.value === 'other' && index > 0 && ( + + ) } + + + { item.label } + + ), }, 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 6c398117e6eb7d..147807ad582580 100644 --- a/packages/ui/src/form/primitives/select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/select/stories/index.story.tsx @@ -37,6 +37,12 @@ const defaultItems = Array.from( { length: 6 }, ( _, index ) => ( { label: `Item ${ index + 1 }`, } ) ); +const separatorItems = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'other', label: 'Other' }, +]; + export const Default: Story = { args: { items: defaultItems, @@ -54,25 +60,23 @@ export const Default: Story = { }; /** - * Use `Select.Separator` to divide related options in the popup. + * Use `Select.Separator` to set an "Other" choice apart from the fruit. */ export const WithSeparator: Story = { args: { - items: defaultItems.slice( 0, 4 ), + items: separatorItems, children: [ - , + , - { defaultItems.slice( 0, 2 ).map( ( item ) => ( + { separatorItems.slice( 0, 2 ).map( ( item ) => ( { item.label } ) ) } - { defaultItems.slice( 2, 4 ).map( ( item ) => ( - - { item.label } - - ) ) } + + Other + , ], }, diff --git a/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx index a4d97ccbcfe571..dcf4637abe8b04 100644 --- a/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx @@ -293,13 +293,14 @@ export const Grouped: Story = { }; /** - * Use `SearchableChipSelectControl.Separator` to divide visible groups of items. + * Use `SearchableChipSelectControl.Separator` to set an "Other" choice apart from the fruit. */ export const WithSeparator: Story = { - ...Grouped, + ...SearchableChipSelectStories.WithSeparator, args: { - ...Grouped.args, - children: SearchableChipSelectStories.WithSeparator.args?.children, + ...SearchableChipSelectStories.WithSeparator.args, + label: 'Fruit', + description: 'Choose your favorite fruits.', }, }; diff --git a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx index e0cb642e486c18..a8f22529180dca 100644 --- a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx @@ -285,13 +285,15 @@ export const Grouped: Story = { }; /** - * Use `SearchableSelectControl.Separator` to divide visible groups of items. + * Use `SearchableSelectControl.Separator` to set an "Other" choice apart from the fruit. */ export const WithSeparator: Story = { - ...Grouped, + ...SearchableSelectStories.WithSeparator, args: { - ...Grouped.args, - children: SearchableSelectStories.WithSeparator.args?.children, + ...SearchableSelectStories.WithSeparator.args, + defaultValue: ITEMS[ 0 ], + label: 'Fruit', + description: 'Choose your favorite fruit.', }, }; 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 4eb9ce12ea4838..b143841186d5a5 100644 --- a/packages/ui/src/form/select-control/stories/index.story.tsx +++ b/packages/ui/src/form/select-control/stories/index.story.tsx @@ -54,17 +54,21 @@ export const Default: Story = { }, }; -const separatorItems = [ ...defaultItems, { value: '3', label: 'Item 3' } ]; +const separatorItems = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'other', label: 'Other' }, +]; /** - * Use `SelectControl.Separator` when composing popup items as children. + * Use `SelectControl.Separator` to set an "Other" choice apart from the fruit. */ export const WithSeparator: Story = { args: { - label: 'Item', + label: 'Fruit', items: separatorItems, children: [ - ...defaultItems.map( ( item ) => ( + ...separatorItems.slice( 0, 2 ).map( ( item ) => ( , - Item 3 + Other , ], }, From 327f4e737ebefebd2980a257c13a76f0fe0e18af Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Tue, 29 Sep 2026 22:12:36 +0900 Subject: [PATCH 6/8] Place separator stories after grouped examples --- .../autocomplete/stories/index.story.tsx | 74 +++++++++---------- .../combobox/stories/index.story.tsx | 70 +++++++++--------- .../stories/index.story.tsx | 2 +- .../searchable-select/stories/index.story.tsx | 2 +- .../primitives/select/stories/index.story.tsx | 46 ++++++------ .../stories/index.story.tsx | 2 +- .../stories/index.story.tsx | 2 +- .../select-control/stories/index.story.tsx | 62 ++++++++-------- 8 files changed, 130 insertions(+), 130 deletions(-) diff --git a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx index 3b75860499d67e..89628d18ba412c 100644 --- a/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/autocomplete/stories/index.story.tsx @@ -127,43 +127,6 @@ export const WithItemDescriptions: Story = { }, }; -/** - * Use `Autocomplete.Separator` to set a distinct suggestion apart from commands. - */ -export const WithSeparator: Story = { - args: { - items: [ ...COMMANDS.slice( 0, 3 ), { id: 'help', value: 'Get help' } ], - openOnInputClick: true, - children: [ - , - - - - - { ( item: FixtureItem, index: number ) => ( - - { item.id === 'help' && index > 0 && ( - - ) } - - - { item.value } - - - - ) } - - - - , - ], - }, -}; - /** * Controls the popup so it only opens when there is at least one match. */ @@ -656,6 +619,43 @@ export const Grouped: Story = { }, }; +/** + * Use `Autocomplete.Separator` to set choices apart from each other. + */ +export const WithSeparator: Story = { + args: { + items: [ ...COMMANDS.slice( 0, 3 ), { id: 'help', value: 'Get help' } ], + openOnInputClick: true, + children: [ + , + + + + + { ( item: FixtureItem, index: number ) => ( + + { item.id === 'help' && index > 0 && ( + + ) } + + + { item.value } + + + + ) } + + + + , + ], + }, +}; + const EMOJI_COLUMNS = 8; const EMOJI_TILE_SIZE = 'var(--wpds-dimension-size-lg)'; 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 2be23bd2694efc..0496cb134337d5 100644 --- a/packages/ui/src/form/primitives/combobox/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/combobox/stories/index.story.tsx @@ -90,41 +90,6 @@ export const Default: Story = { }, }; -/** - * Use `Combobox.Separator` to set an "Other" choice apart from the fruit. - */ -export const WithSeparator: Story = { - args: { - items: [ ...ITEMS.slice( 0, 3 ), { value: 'other', label: 'Other' } ], - children: [ - , - -
- -
- - - - { ( item: FixtureItem, index: number ) => ( - - { item.value === 'other' && index > 0 && ( - - ) } - - - { item.label } - - - - ) } - - - -
, - ], - }, -}; - export const Compact: Story = { args: { defaultValue: ITEMS[ 0 ], @@ -504,6 +469,41 @@ export const Grouped: Story = { }, }; +/** + * Use `Combobox.Separator` to set choices apart from each other. + */ +export const WithSeparator: Story = { + args: { + items: [ ...ITEMS.slice( 0, 3 ), { value: 'other', label: 'Other' } ], + children: [ + , + +
+ +
+ + + + { ( item: FixtureItem, index: number ) => ( + + { item.value === 'other' && index > 0 && ( + + ) } + + + { 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/searchable-chip-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx index 45ce766bd55149..6e73ea09bf0967 100644 --- a/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-chip-select/stories/index.story.tsx @@ -147,7 +147,7 @@ export const Grouped: Story = { }; /** - * Use `SearchableChipSelect.Separator` to set an "Other" choice apart from the fruit. + * Use `SearchableChipSelect.Separator` to set choices apart from each other. */ export const WithSeparator: Story = { args: { diff --git a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx index 4d507193896a07..3e89175a6884e2 100644 --- a/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/searchable-select/stories/index.story.tsx @@ -160,7 +160,7 @@ export const Grouped: Story = { }; /** - * Use `SearchableSelect.Separator` to set an "Other" choice apart from the fruit. + * Use `SearchableSelect.Separator` to set choices apart from each other. */ export const WithSeparator: Story = { args: { 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 147807ad582580..15d375fb4c9cb3 100644 --- a/packages/ui/src/form/primitives/select/stories/index.story.tsx +++ b/packages/ui/src/form/primitives/select/stories/index.story.tsx @@ -59,29 +59,6 @@ export const Default: Story = { }, }; -/** - * Use `Select.Separator` to set an "Other" choice apart from the fruit. - */ -export const WithSeparator: Story = { - args: { - items: separatorItems, - children: [ - , - - { separatorItems.slice( 0, 2 ).map( ( item ) => ( - - { item.label } - - ) ) } - - - Other - - , - ], - }, -}; - export const Compact: Story = { args: { ...Default.args, @@ -188,6 +165,29 @@ export const Grouped: Story = { }, }; +/** + * Use `Select.Separator` to set choices apart from each other. + */ +export const WithSeparator: Story = { + args: { + items: separatorItems, + children: [ + , + + { separatorItems.slice( 0, 2 ).map( ( item ) => ( + + { item.label } + + ) ) } + + + Other + + , + ], + }, +}; + /** * 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/searchable-chip-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx index dcf4637abe8b04..6be9489660c238 100644 --- a/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-chip-select-control/stories/index.story.tsx @@ -293,7 +293,7 @@ export const Grouped: Story = { }; /** - * Use `SearchableChipSelectControl.Separator` to set an "Other" choice apart from the fruit. + * Use `SearchableChipSelectControl.Separator` to set choices apart from each other. */ export const WithSeparator: Story = { ...SearchableChipSelectStories.WithSeparator, diff --git a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx index a8f22529180dca..ada1da830728d5 100644 --- a/packages/ui/src/form/searchable-select-control/stories/index.story.tsx +++ b/packages/ui/src/form/searchable-select-control/stories/index.story.tsx @@ -285,7 +285,7 @@ export const Grouped: Story = { }; /** - * Use `SearchableSelectControl.Separator` to set an "Other" choice apart from the fruit. + * Use `SearchableSelectControl.Separator` to set choices apart from each other. */ export const WithSeparator: Story = { ...SearchableSelectStories.WithSeparator, 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 b143841186d5a5..b308f798e5b214 100644 --- a/packages/ui/src/form/select-control/stories/index.story.tsx +++ b/packages/ui/src/form/select-control/stories/index.story.tsx @@ -60,37 +60,6 @@ const separatorItems = [ { value: 'other', label: 'Other' }, ]; -/** - * Use `SelectControl.Separator` to set an "Other" choice apart from the fruit. - */ -export const WithSeparator: Story = { - args: { - label: 'Fruit', - items: separatorItems, - children: [ - ...separatorItems.slice( 0, 2 ).map( ( item ) => ( - - - { item.label } - - - ) ), - , - - Other - , - ], - }, -}; - /** * When no value is selected, the trigger shows the default placeholder text. * @@ -235,6 +204,37 @@ export const Grouped: Story = { }, }; +/** + * Use `SelectControl.Separator` to set choices apart from each other. + */ +export const WithSeparator: Story = { + args: { + label: 'Fruit', + items: separatorItems, + children: [ + ...separatorItems.slice( 0, 2 ).map( ( item ) => ( + + + { item.label } + + + ) ), + , + + Other + , + ], + }, +}; + const userOptions: React.ComponentProps< typeof SelectControl >[ 'items' ] = [ { value: '1', From c58f152c19b8136789d298d9526b7dbaaaf4dc3b Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Wed, 30 Sep 2026 12:04:25 +0900 Subject: [PATCH 7/8] Omit unsupported item popup separator orientation --- .../ui/src/form/primitives/autocomplete/separator.tsx | 9 ++++++++- packages/ui/src/form/primitives/autocomplete/types.ts | 5 +++-- packages/ui/src/form/primitives/combobox/separator.tsx | 9 ++++++++- packages/ui/src/form/primitives/combobox/types.ts | 5 +++-- packages/ui/src/form/primitives/select/separator.tsx | 9 ++++++++- packages/ui/src/form/primitives/select/types.ts | 5 ++++- 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/form/primitives/autocomplete/separator.tsx b/packages/ui/src/form/primitives/autocomplete/separator.tsx index de1413095850fa..81ccdd9cd17e35 100644 --- a/packages/ui/src/form/primitives/autocomplete/separator.tsx +++ b/packages/ui/src/form/primitives/autocomplete/separator.tsx @@ -10,7 +10,14 @@ import type { AutocompleteSeparatorProps } from './types'; export const Separator = forwardRef< HTMLDivElement, AutocompleteSeparatorProps ->( function AutocompleteSeparator( { className, ...props }, ref ) { +>( function AutocompleteSeparator( + { + className, + orientation: _orientation, + ...props + }: _Autocomplete.Separator.Props, + ref +) { return ( <_Autocomplete.Separator ref={ ref } diff --git a/packages/ui/src/form/primitives/autocomplete/types.ts b/packages/ui/src/form/primitives/autocomplete/types.ts index f457a54f425cf1..15b08b1a6eaebc 100644 --- a/packages/ui/src/form/primitives/autocomplete/types.ts +++ b/packages/ui/src/form/primitives/autocomplete/types.ts @@ -121,8 +121,9 @@ export type AutocompleteRowProps = ComponentProps< children?: React.ReactNode; }; -export type AutocompleteSeparatorProps = ComponentProps< - typeof _Autocomplete.Separator +export type AutocompleteSeparatorProps = Omit< + ComponentProps< typeof _Autocomplete.Separator >, + 'orientation' >; export type AutocompleteStatusProps = ComponentProps< diff --git a/packages/ui/src/form/primitives/combobox/separator.tsx b/packages/ui/src/form/primitives/combobox/separator.tsx index 60c34ae621a505..6e633972953c88 100644 --- a/packages/ui/src/form/primitives/combobox/separator.tsx +++ b/packages/ui/src/form/primitives/combobox/separator.tsx @@ -8,7 +8,14 @@ import type { ComboboxSeparatorProps } from './types'; * Renders a visual separator between combobox items or groups. */ export const Separator = forwardRef< HTMLDivElement, ComboboxSeparatorProps >( - function ComboboxSeparator( { className, ...props }, ref ) { + function ComboboxSeparator( + { + className, + orientation: _orientation, + ...props + }: _Combobox.Separator.Props, + ref + ) { return ( <_Combobox.Separator ref={ ref } diff --git a/packages/ui/src/form/primitives/combobox/types.ts b/packages/ui/src/form/primitives/combobox/types.ts index a1efbdb15ead0d..746e894604f914 100644 --- a/packages/ui/src/form/primitives/combobox/types.ts +++ b/packages/ui/src/form/primitives/combobox/types.ts @@ -147,8 +147,9 @@ export type ComboboxRootProps< Multiple extends boolean | undefined = false, > = _Combobox.Root.Props< Value, Multiple >; -export type ComboboxSeparatorProps = ComponentProps< - typeof _Combobox.Separator +export type ComboboxSeparatorProps = Omit< + ComponentProps< typeof _Combobox.Separator >, + 'orientation' >; export type ComboboxStatusProps = ComponentProps< typeof _Combobox.Status > & { diff --git a/packages/ui/src/form/primitives/select/separator.tsx b/packages/ui/src/form/primitives/select/separator.tsx index 5239cfeab8b274..06761233229c13 100644 --- a/packages/ui/src/form/primitives/select/separator.tsx +++ b/packages/ui/src/form/primitives/select/separator.tsx @@ -8,7 +8,14 @@ import type { SelectSeparatorProps } from './types'; * Renders a visual separator between select items or groups. */ export const Separator = forwardRef< HTMLDivElement, SelectSeparatorProps >( - function SelectSeparator( { className, ...props }, ref ) { + function SelectSeparator( + { + className, + orientation: _orientation, + ...props + }: _Select.Separator.Props, + ref + ) { return ( <_Select.Separator ref={ ref } diff --git a/packages/ui/src/form/primitives/select/types.ts b/packages/ui/src/form/primitives/select/types.ts index c420d2db3b0939..e9fb28599c0596 100644 --- a/packages/ui/src/form/primitives/select/types.ts +++ b/packages/ui/src/form/primitives/select/types.ts @@ -54,7 +54,10 @@ export type SelectGroupLabelProps = ComponentProps< children?: React.ReactNode; }; -export type SelectSeparatorProps = ComponentProps< typeof _Select.Separator >; +export type SelectSeparatorProps = Omit< + ComponentProps< typeof _Select.Separator >, + 'orientation' +>; export type SelectPopupProps = ComponentProps< typeof _Select.Popup > & ItemPopupWidthProps & { From 4e9b35613e2d3ef7574e017bd100cbd0d4ee4d0b Mon Sep 17 00:00:00 2001 From: Lena Morita Date: Wed, 30 Sep 2026 12:14:04 +0900 Subject: [PATCH 8/8] Update separator component manifest --- storybook/components-manifest.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/storybook/components-manifest.yml b/storybook/components-manifest.yml index f9dd34d43364e4..c0546e9afe0fd7 100644 --- a/storybook/components-manifest.yml +++ b/storybook/components-manifest.yml @@ -160,7 +160,6 @@ - name: Autocomplete.Separator props: - className - - orientation - render - style - name: Autocomplete.Status @@ -1197,7 +1196,6 @@ - name: SearchableChipSelect.Separator props: - className - - orientation - render - style - name: SearchableChipSelectControl @@ -1307,7 +1305,6 @@ - name: SearchableChipSelectControl.Separator props: - className - - orientation - render - style - name: SearchableSelectControl @@ -1408,7 +1405,6 @@ - name: SearchableSelectControl.Separator props: - className - - orientation - render - style - name: SearchControl @@ -1524,7 +1520,6 @@ - name: Select.Separator props: - className - - orientation - render - style - name: Select.Trigger @@ -1611,7 +1606,6 @@ - name: SelectControl.Separator props: - className - - orientation - render - style - name: Shortcut