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

Filter by extension

Filter by extension

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

Expand Down
27 changes: 27 additions & 0 deletions packages/ui/src/form/primitives/combobox/group-label.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text
variant="heading-sm"
className={ clsx(
itemPopupStyles[ 'group-label' ],
className
) }
render={ <_Combobox.GroupLabel ref={ ref } { ...restProps } /> }
>
{ children }
</Text>
);
}
);
24 changes: 24 additions & 0 deletions packages/ui/src/form/primitives/combobox/group.tsx
Original file line number Diff line number Diff line change
@@ -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 }
</_Combobox.Group>
);
}
);
2 changes: 2 additions & 0 deletions packages/ui/src/form/primitives/combobox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
32 changes: 32 additions & 0 deletions packages/ui/src/form/primitives/combobox/stories/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
],
},
];
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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: [
<Combobox.Trigger key="trigger" />,
<Combobox.Popup key="popup">
<div style={ inputWrapperStyle }>
<Combobox.Input placeholder="Search" />
</div>
<Combobox.Empty>No results found.</Combobox.Empty>
<Combobox.List>
<Combobox.ListBody>
<Combobox.Collection>
{ ( group: FixtureGroup ) => (
<Combobox.Group
key={ group.label }
items={ group.items }
>
<Combobox.GroupLabel>
{ group.label }
</Combobox.GroupLabel>
<Combobox.Collection>
{ ( item: FixtureItem ) => (
<Combobox.Item
key={ item.value }
value={ item }
>
{ item.label }
</Combobox.Item>
) }
</Combobox.Collection>
</Combobox.Group>
) }
</Combobox.Collection>
</Combobox.ListBody>
</Combobox.List>
</Combobox.Popup>,
],
},
};

/**
* 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.
Expand Down
75 changes: 75 additions & 0 deletions packages/ui/src/form/primitives/combobox/test/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Combobox.Root items={ GROUPED_ITEMS }>
<Combobox.Trigger />
<Combobox.Popup>
<Combobox.Input placeholder="Search" />
<Combobox.List>
<Combobox.ListBody>
<Combobox.Collection>
{ ( group ) => (
<Combobox.Group
key={ group.label }
ref={
group.label === 'Group 1'
? groupRef
: undefined
}
items={ group.items }
>
<Combobox.GroupLabel
ref={
group.label === 'Group 1'
? groupLabelRef
: undefined
}
>
{ group.label }
</Combobox.GroupLabel>
<Combobox.Collection>
{ ( item ) => (
<Combobox.Item
key={ item.id }
value={ item }
>
{ item.value }
</Combobox.Item>
) }
</Combobox.Collection>
</Combobox.Group>
) }
</Combobox.Collection>
</Combobox.ListBody>
</Combobox.List>
</Combobox.Popup>
</Combobox.Root>
);

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();
Expand Down
10 changes: 10 additions & 0 deletions packages/ui/src/form/primitives/combobox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
27 changes: 27 additions & 0 deletions packages/ui/src/form/primitives/select/group-label.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text
variant="heading-sm"
className={ clsx(
itemPopupStyles[ 'group-label' ],
className
) }
render={ <_Select.GroupLabel ref={ ref } { ...restProps } /> }
>
{ children }
</Text>
);
}
);
23 changes: 23 additions & 0 deletions packages/ui/src/form/primitives/select/group.tsx
Original file line number Diff line number Diff line change
@@ -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 }
</_Select.Group>
);
}
);
2 changes: 2 additions & 0 deletions packages/ui/src/form/primitives/select/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading
Loading