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',