Skip to content
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### Enhancements

- `Autocomplete`: Add `Autocomplete.Group` and `Autocomplete.GroupLabel` subcomponents ([#78901](https://github.com/WordPress/gutenberg/pull/78901)).
- `Tooltip.Provider`: Widen the types to accept all props of the equivalent `Tooltip.Provider` from `@base-ui/react` (types-only change) ([#78642](https://github.com/WordPress/gutenberg/pull/78642)).

## 0.14.0 (2026-05-27)
Expand Down
20 changes: 20 additions & 0 deletions packages/ui/src/form/primitives/autocomplete/group-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import type { AutocompleteGroupLabelProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

export const GroupLabel = forwardRef<
Comment thread
ciampo marked this conversation as resolved.
Resolved
HTMLDivElement,
AutocompleteGroupLabelProps
>( function GroupLabel( { className, children, ...restProps }, ref ) {
return (
<_Autocomplete.GroupLabel
className={ clsx( itemPopupStyles[ 'group-label' ], className ) }
ref={ ref }
{ ...restProps }
>
{ children }
</_Autocomplete.GroupLabel>
);
} );
24 changes: 24 additions & 0 deletions packages/ui/src/form/primitives/autocomplete/group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Autocomplete as _Autocomplete } from '@base-ui/react/autocomplete';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import type { AutocompleteGroupProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

/**
* Groups related items together with an associated label rendered by
* `Autocomplete.GroupLabel`. When `items` is provided, child
* `Autocomplete.Collection` components iterate over them.
*/
export const Group = forwardRef< HTMLDivElement, AutocompleteGroupProps >(
function Group( { className, children, ...restProps }, ref ) {
return (
<_Autocomplete.Group
className={ clsx( itemPopupStyles.group, className ) }
ref={ ref }
{ ...restProps }
>
{ children }
</_Autocomplete.Group>
);
}
);
2 changes: 2 additions & 0 deletions packages/ui/src/form/primitives/autocomplete/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export { Clear } from './clear';
export { Collection } from './collection';
export { Empty } from './empty';
export { Group } from './group';
export { GroupLabel } from './group-label';
export { InputGroup } from './input-group';
export { Input } from './input';
export { Item } from './item';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ export interface FixtureItem {
value: string;
}

export interface FixtureGroup {
label: string;
items: FixtureItem[];
}

export const COMMANDS: FixtureItem[] = [
{ id: 'c1', value: 'Add new page' },
{ id: 'c2', value: 'Add new post' },
Expand All @@ -14,6 +19,32 @@ export const COMMANDS: FixtureItem[] = [
{ id: 'c8', value: 'Toggle dark mode' },
];

export const GROUPED_COMMANDS: FixtureGroup[] = [
{
label: 'Content',
items: [
{ id: 'g1', value: 'Add new page' },
{ id: 'g2', value: 'Add new post' },
{ id: 'g3', value: 'Edit site' },
],
},
{
label: 'Management',
items: [
{ id: 'g4', value: 'Manage plugins' },
{ id: 'g5', value: 'Manage users' },
{ id: 'g6', value: 'Open settings' },
],
},
{
label: 'Appearance',
items: [
{ id: 'g7', value: 'Toggle dark mode' },
{ id: 'g8', value: 'View site' },
],
},
];

export const USERS: FixtureItem[] = [
{ id: 'm1', value: 'alice' },
{ id: 'm2', value: 'bob' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import { Icon } from '../../../../icon';
import { Input } from '../../input';
import { InputLayout } from '../../input-layout';
import { Textarea } from '../../textarea';
import { COMMANDS, URLS, USERS, type FixtureItem } from './fixtures';
import {
COMMANDS,
GROUPED_COMMANDS,
URLS,
USERS,
type FixtureGroup,
type FixtureItem,
} from './fixtures';

const meta: Meta< typeof Autocomplete.Root > = {
title: 'Design System/Components/Form/Primitives/Autocomplete',
Expand All @@ -20,6 +27,8 @@ const meta: Meta< typeof Autocomplete.Root > = {
'Autocomplete.List': Autocomplete.List,
'Autocomplete.ListBody': Autocomplete.ListBody,
'Autocomplete.Collection': Autocomplete.Collection,
'Autocomplete.Group': Autocomplete.Group,
'Autocomplete.GroupLabel': Autocomplete.GroupLabel,
'Autocomplete.Item': Autocomplete.Item,
'Autocomplete.Value': Autocomplete.Value,
'Autocomplete.Empty': Autocomplete.Empty,
Expand Down Expand Up @@ -444,3 +453,47 @@ export const WithCustomZIndex: Story = {
),
},
};

/**
* Suggestions can be organized into labeled groups with `Autocomplete.Group`
* and `Autocomplete.GroupLabel`.
*/
export const Grouped: Story = {
args: {
items: GROUPED_COMMANDS,
children: (
<>
<Autocomplete.Input placeholder="Type a command" />
<Autocomplete.Popup>
<Autocomplete.Empty>No matching items.</Autocomplete.Empty>
<Autocomplete.List>
<Autocomplete.ListBody>
<Autocomplete.Collection>
{ ( group: FixtureGroup ) => (
<Autocomplete.Group
key={ group.label }
items={ group.items }
>
<Autocomplete.GroupLabel>
{ group.label }
</Autocomplete.GroupLabel>
<Autocomplete.Collection>
{ ( item: FixtureItem ) => (
<Autocomplete.Item
key={ item.id }
value={ item }
>
{ item.value }
</Autocomplete.Item>
) }
</Autocomplete.Collection>
</Autocomplete.Group>
) }
</Autocomplete.Collection>
</Autocomplete.ListBody>
</Autocomplete.List>
</Autocomplete.Popup>
</>
),
},
};
126 changes: 125 additions & 1 deletion packages/ui/src/form/primitives/autocomplete/test/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { createRef } from '@wordpress/element';
import type { ReactNode } from 'react';
import type { ReactNode, Ref } from 'react';
import * as Autocomplete from '../index';
import { useEnableWpCompatOverlaySlot } from '../../../../utils/use-enable-wp-compat-overlay-slot';

Expand Down Expand Up @@ -378,4 +378,128 @@ describe( 'Autocomplete', () => {
expect( screen.queryByRole( 'tooltip' ) ).not.toBeInTheDocument();
} );
} );

describe( 'grouped items', () => {
Comment thread
ciampo marked this conversation as resolved.
Resolved
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' } ],
},
];

function renderGrouped( {
groupRef,
groupLabelRef,
}: {
groupRef?: Ref< HTMLDivElement >;
groupLabelRef?: Ref< HTMLDivElement >;
} = {} ) {
return render(
<Autocomplete.Root items={ GROUPED_ITEMS }>
<Autocomplete.Input placeholder="Search" />
<Autocomplete.Popup>
<Autocomplete.List>
<Autocomplete.ListBody>
<Autocomplete.Collection>
{ ( group ) => (
<Autocomplete.Group
key={ group.label }
ref={
group.label === 'Group 1'
? groupRef
: undefined
}
items={ group.items }
>
<Autocomplete.GroupLabel
ref={
group.label === 'Group 1'
? groupLabelRef
: undefined
}
>
{ group.label }
</Autocomplete.GroupLabel>
<Autocomplete.Collection>
{ ( item ) => (
<Autocomplete.Item
key={ item.id }
value={ item }
>
{ item.value }
</Autocomplete.Item>
) }
</Autocomplete.Collection>
</Autocomplete.Group>
) }
</Autocomplete.Collection>
</Autocomplete.ListBody>
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Root>
);
}

it( 'forwards refs', async () => {
const user = userEvent.setup();
const groupRef = createRef< HTMLDivElement >();
const groupLabelRef = createRef< HTMLDivElement >();

renderGrouped( { groupRef, groupLabelRef } );

await user.type( screen.getByRole( 'combobox' ), 'Item' );

await waitFor( () => {
expect( groupRef.current ).toBeInstanceOf( HTMLDivElement );
} );
expect( groupLabelRef.current ).toBeInstanceOf( HTMLDivElement );
} );

it( 'renders group labels and associates them with their items', async () => {
const user = userEvent.setup();

renderGrouped();

await user.type( screen.getByRole( 'combobox' ), 'Item' );

const group1 = await screen.findByRole( 'group', {
name: 'Group 1',
} );
expect( group1 ).toBeVisible();

expect(
screen.getByRole( 'option', { name: 'Item 1' } )
).toBeVisible();
expect( group1 ).toContainElement(
screen.getByRole( 'option', { name: 'Item 1' } )
);
expect( group1 ).not.toContainElement(
screen.getByRole( 'option', { name: 'Item 3' } )
);
} );

it( 'hides groups whose items are all filtered out', async () => {
const user = userEvent.setup();

renderGrouped();

await user.type( screen.getByRole( 'combobox' ), 'Item 3' );

await waitFor( () => {
expect(
screen.getByRole( 'group', { name: 'Group 2' } )
).toBeVisible();
} );
expect(
screen.queryByRole( 'group', { name: 'Group 1' } )
).not.toBeInTheDocument();
} );
} );
} );
12 changes: 12 additions & 0 deletions packages/ui/src/form/primitives/autocomplete/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ export type AutocompleteEmptyProps = ComponentProps<
children?: React.ReactNode;
};

export type AutocompleteGroupProps = ComponentProps<
typeof _Autocomplete.Group
> & {
children?: React.ReactNode;
};

export type AutocompleteGroupLabelProps = ComponentProps<
typeof _Autocomplete.GroupLabel
> & {
children?: React.ReactNode;
};

export type AutocompleteInputProps = Omit<
ComponentProps< typeof _Autocomplete.Input >,
'size'
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/utils/css/item-popup.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@
}
}

.group:not(:first-child) {
margin-block-start: var(--wpds-dimension-gap-sm);
}

.group-label {
display: flex;
align-items: center;
min-height: 32px;
padding-inline: var(--wpds-dimension-padding-md);
font-size: var(--wpds-typography-font-size-xs);
line-height: var(--wpds-typography-line-height-xs);
font-weight: var(--wpds-typography-font-weight-medium);
Comment thread
ciampo marked this conversation as resolved.
Resolved
text-transform: uppercase;
color: var(--wpds-color-fg-content-neutral-weak);
}

.item {
--wp-ui-popup-item-height: 40px;
--wp-ui-popup-item-padding-inline: var(--wpds-dimension-padding-md);
Expand Down
Loading