Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b73964c
Add Combobox primitives
mirka Apr 29, 2026
426e24a
Fix popup slot
mirka May 15, 2026
cc3f7a8
Add `size` support
mirka May 15, 2026
195e576
Use padding tokens in stories
mirka May 15, 2026
a511e33
Move item popup typography styles
mirka May 15, 2026
6885f67
Use line height token
mirka May 15, 2026
48e0c01
Fix chip component name
mirka May 15, 2026
8349f05
Fix chip docs
mirka May 18, 2026
d1973cb
Fix combobox story copy
mirka May 18, 2026
9a74c90
Add changelog
mirka May 18, 2026
1c7e8ab
Add combobox compat overlay slot
mirka May 18, 2026
df7652c
Remove item popup typography changes
mirka May 18, 2026
7fbfa05
Clean up
mirka May 18, 2026
c8345c3
Add support for custom label in remove chip button
mirka May 18, 2026
2adacc3
Add Combobox positioner
mirka May 18, 2026
1837f49
Add Combobox value placeholder support
mirka May 18, 2026
373b068
Add Combobox Storybook status
mirka May 18, 2026
48d73c7
Merge branch 'trunk' into add-combobox-primitives
mirka May 18, 2026
4451d4f
Add placeholder support to Trigger
mirka May 18, 2026
9622476
Update Combobox story item shape
mirka May 18, 2026
97e01e1
Update Combobox z-index story
mirka May 18, 2026
0f5fbd9
Generalize comment and fix accuracy
mirka May 18, 2026
325d77a
Add comment for small size
mirka May 19, 2026
966e6d4
Fix subcomponent types that don't render an HTML element
mirka May 19, 2026
bd8f0f3
Remove inappropriate placeholder tests
mirka May 19, 2026
62ba31d
Add comment about placeholder styling
mirka May 19, 2026
27ddc2b
Document Combobox value wrapper
mirka May 19, 2026
a2fcb99
Add comment for `prefix` omission
mirka May 20, 2026
7a09de1
Update Combobox story subcomponent names
mirka May 20, 2026
35079ff
Use padding tokens for Combobox chips
mirka May 20, 2026
e68bcd8
Remove unused Combobox item variant class
mirka May 20, 2026
c2368af
Simplify Combobox item icon class
mirka May 20, 2026
583ce48
Use shared item icon class
mirka May 20, 2026
422ebb5
Use ComponentProps for Combobox portal
mirka May 20, 2026
309ee1f
Merge branch 'trunk' into add-combobox-primitives
mirka May 27, 2026
1420d79
Use DS padding tokens in item popup
mirka May 27, 2026
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 @@ -17,6 +17,7 @@
- `Card`: `Card.FullBleed` now supports edge-bumping in additional positions ([#77856](https://github.com/WordPress/gutenberg/pull/77856)):
- As the **first child of `Card.Header`**, it extends flush to the card's top and side edges — ideal for hero images. Inter-sibling spacing is consumer-managed: compose `Card.Header` with `Stack` via the `render` prop to add a gap between the hero and following siblings (e.g. `Card.Title`).
- As the **sole child of `Card.Content`**, it extends flush to the card's side edges and additionally to the top edge when `Content` is the first card child, or the bottom edge when it is the last. This enables full-bleed content panels with or without a header above them.
- Add `Combobox` primitive ([#78399](https://github.com/WordPress/gutenberg/pull/78399)).

### Enhancements

Expand Down
50 changes: 50 additions & 0 deletions packages/ui/src/form/primitives/combobox/chip-with-remove.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import { forwardRef } from '@wordpress/element';
import clsx from 'clsx';
import { __ } from '@wordpress/i18n';
import { closeSmall } from '@wordpress/icons';
import { IconButton } from '../../../icon-button';
import type { ComboboxChipWithRemoveProps } from './types';
import styles from './style.module.css';

/**
* A utility component that combines a chip and a remove button,
* with standard styling.
*/
export const ChipWithRemove = forwardRef<
HTMLDivElement,
ComboboxChipWithRemoveProps
>( function ChipWithRemove(
{ className, children, prefix, removeLabel = __( 'Remove' ), ...restProps },
ref
) {
return (
<_Combobox.Chip
ref={ ref }
className={ clsx( styles.chip, className ) }
{ ...restProps }
>
{ prefix && (
<span className={ styles[ 'chip-prefix' ] }>{ prefix }</span>
) }
<span className={ styles[ 'chip-content' ] }>{ children }</span>

<_Combobox.ChipRemove
className={ styles[ 'chip-remove' ] }
render={ ( props, { disabled } ) => (
<IconButton
icon={ closeSmall }
label={ removeLabel }
size="small"
variant="minimal"
tone="neutral"
focusableWhenDisabled={ false }
disabled={ disabled }
aria-hidden={ disabled || undefined }
{ ...props }
/>
) }
/>
</_Combobox.Chip>
);
} );
9 changes: 9 additions & 0 deletions packages/ui/src/form/primitives/combobox/chips.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import { forwardRef } from '@wordpress/element';
import type { ComboboxChipsProps } from './types';

export const Chips = forwardRef< HTMLDivElement, ComboboxChipsProps >(
function Chips( props, ref ) {
return <_Combobox.Chips ref={ ref } { ...props } />;
}
);
34 changes: 34 additions & 0 deletions packages/ui/src/form/primitives/combobox/clear.tsx
Comment thread
ciampo marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import { forwardRef } from '@wordpress/element';
import { closeSmall } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import { IconButton } from '../../../icon-button';
import type { ComboboxClearProps } from './types';

const DEFAULT_RENDER = (
{ 'aria-label': ariaLabel = __( 'Clear' ), ...props }: ComboboxClearProps,
{ disabled }: _Combobox.Clear.State
) => (
<IconButton
icon={ closeSmall }
focusableWhenDisabled={ false }
disabled={ disabled }
aria-hidden={ disabled || undefined }
size="small"
variant="minimal"
tone="neutral"
label={ ariaLabel }
{ ...props }
/>
);

/**
* A button that clears the selected value(s).
*/
export const Clear = forwardRef< HTMLButtonElement, ComboboxClearProps >(
function Clear( { render = DEFAULT_RENDER, ...restProps }, ref ) {
return (
<_Combobox.Clear ref={ ref } render={ render } { ...restProps } />
);
}
);
13 changes: 13 additions & 0 deletions packages/ui/src/form/primitives/combobox/collection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import type { ComboboxCollectionProps } from './types';

export function Collection( {
children,
...restProps
}: ComboboxCollectionProps ) {
return (
<_Combobox.Collection { ...restProps }>
{ children }
</_Combobox.Collection>
);
}
17 changes: 17 additions & 0 deletions packages/ui/src/form/primitives/combobox/empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import type { ComboboxEmptyProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

export const Empty = forwardRef< HTMLDivElement, ComboboxEmptyProps >(
function Empty( { className, ...restProps }, ref ) {
return (
<_Combobox.Empty
className={ clsx( itemPopupStyles.empty, className ) }
ref={ ref }
{ ...restProps }
/>
);
}
);
16 changes: 16 additions & 0 deletions packages/ui/src/form/primitives/combobox/index.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some exported components don't seem to have a JSDoc block yet, should it add one for all components?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm bit torn on the documentation strategy here 😅 Useful JSDoc for the subcomponents is basically going to need to mirror what's documented upstream, which is a substantial burden to keep maintained… Redirecting to upstream Base UI docs, especially with simple pass-through components, may be a better strategy overall. What do you think? Probably something to tackle package-wide as a separate task.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, it may be convenient to simply forward Base UI JSDocs, although I don't know if we can safely apply it to all components, especially when we gate props / merge subcomponents into one etc.

Definitely something that should be investigated separately and across the whole package

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticing that we're not re-exporting Combobox.InputGroup — is that intentional, or maybe it was added recently?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have a need for it in any of our currently known patterns, but we can export it when necessary.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, continuing the comparison vs Autocomplete, should we consider exposing a ListFooter component in Autocomplete, too?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps when necessary, but we currently don't have a known need for it. (This also happens to be a rather abusable pattern 😅)

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export { Chips } from './chips';
export { ChipWithRemove } from './chip-with-remove';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Base Ui exports individual Chip and ChipRemove components, while we export the higher-level composition ChipWithRemove. Do we ever foresee the need for consumers to render a chip without a remove button (or with a custom icon button)?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a specific use case planned where we do in fact need a Chip and ChipRemove for customization. Basically it's going to look more like a list item and less like a chip. However, since this requires some additional design thinking, I would prefer waiting until we actually need to build this custom use case. It may be that we just build out a new dedicated middle-level primitive for this, rather than have consumers build the "list item chip" pattern from scratch.

export { Clear } from './clear';
export { Collection } from './collection';
export { Empty } from './empty';
export { Input } from './input';
export { Item } from './item';
export { List } from './list';
export { ListBody } from './list-body';
export { ListFooter } from './list-footer';
export { Portal } from './portal';
export { Positioner } from './positioner';
export { Popup } from './popup';
export { Root } from './root';
export { Trigger } from './trigger';
export { Value } from './value';
26 changes: 26 additions & 0 deletions packages/ui/src/form/primitives/combobox/input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import { forwardRef } from '@wordpress/element';
import { search } from '@wordpress/icons';
import { Icon } from '../../../icon';
import { Input as InputPrimitive } from '../input';
import { InputLayout } from '../input-layout';
import type { ComboboxInputProps } from './types';

const DEFAULT_RENDER = ( props: ComboboxInputProps ) => (
<InputPrimitive
prefix={
<InputLayout.Slot padding="minimal">
<Icon icon={ search } />
</InputLayout.Slot>
}
{ ...props }
/>
);
Comment thread
mirka marked this conversation as resolved.

export const Input = forwardRef< HTMLInputElement, ComboboxInputProps >(
function Input( { render = DEFAULT_RENDER, ...restProps }, ref ) {
return (
<_Combobox.Input ref={ ref } render={ render } { ...restProps } />
);
}
);
45 changes: 45 additions & 0 deletions packages/ui/src/form/primitives/combobox/item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import { check, plus } from '@wordpress/icons';
import { Icon } from '../../../icon';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';
import resetStyles from '../../../utils/css/resets.module.css';
import type { ComboboxItemProps } from './types';

export const Item = forwardRef< HTMLDivElement, ComboboxItemProps >(
function Item(
{
className,
children,
size = 'default',
variant = 'default',
...restProps
},
ref
) {
return (
<_Combobox.Item
className={ clsx(
resetStyles[ 'box-sizing' ],
itemPopupStyles.item,
itemPopupStyles[ `is-size-${ size }` ],
className
) }
ref={ ref }
{ ...restProps }
>
<Icon
icon={ variant === 'creatable' ? plus : check }
className={ clsx(
itemPopupStyles[ 'item-icon' ],
variant !== 'creatable' &&
itemPopupStyles[ 'item-indicator-icon' ]
) }
size={ 24 }
/>
{ children }
</_Combobox.Item>
);
}
);
23 changes: 23 additions & 0 deletions packages/ui/src/form/primitives/combobox/list-body.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { forwardRef } from '@wordpress/element';
import { mergeProps, useRender } from '@base-ui/react';
import type { ComboboxListBodyProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

/**
* A layout container for the main scrolling area in the `List`, for holding `Item`s.
*/
export const ListBody = forwardRef< HTMLDivElement, ComboboxListBodyProps >(
function ListBody( { render, ...props }, ref ) {
const element = useRender( {
defaultTagName: 'div',
render,
ref,
props: mergeProps< 'div' >(
{ className: itemPopupStyles[ 'list-scrollable-container' ] },
props
),
} );

return element;
}
);
24 changes: 24 additions & 0 deletions packages/ui/src/form/primitives/combobox/list-footer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { forwardRef } from '@wordpress/element';
import { mergeProps, useRender } from '@base-ui/react';
import type { ComboboxListFooterProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

/**
* A layout container for the footer row in the `List`,
* for holding a single `Item` that triggers the creation of a new item.
*/
export const ListFooter = forwardRef< HTMLDivElement, ComboboxListFooterProps >(
function ListFooter( { render, ...props }, ref ) {
const element = useRender( {
defaultTagName: 'div',
render,
ref,
props: mergeProps< 'div' >(
{ className: itemPopupStyles[ 'list-footer' ] },
props
),
} );

return element;
}
);
17 changes: 17 additions & 0 deletions packages/ui/src/form/primitives/combobox/list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import type { ComboboxListProps } from './types';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';

export const List = forwardRef< HTMLDivElement, ComboboxListProps >(
function List( { className, ...restProps }, ref ) {
return (
<_Combobox.List
className={ clsx( itemPopupStyles.list, className ) }
ref={ ref }
{ ...restProps }
/>
);
}
);
38 changes: 38 additions & 0 deletions packages/ui/src/form/primitives/combobox/popup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import {
type ThemeProvider as ThemeProviderType,
privateApis as themePrivateApis,
} from '@wordpress/theme';
import { unlock } from '../../../lock-unlock';
import { Portal } from './portal';
import { Positioner } from './positioner';
import { renderSlotWithChildren } from '../../../utils/render-slot-with-children';
import itemPopupStyles from '../../../utils/css/item-popup.module.css';
import type { ComboboxPopupProps } from './types';

const ThemeProvider: typeof ThemeProviderType =
unlock( themePrivateApis ).ThemeProvider;

export const Popup = forwardRef< HTMLDivElement, ComboboxPopupProps >(
function Popup( { className, portal, positioner, ...restProps }, ref ) {
const popupContent = (
<ThemeProvider>
<_Combobox.Popup
ref={ ref }
className={ clsx( itemPopupStyles.popup, className ) }
{ ...restProps }
/>
</ThemeProvider>
);

const positionedPopup = renderSlotWithChildren(
positioner,
<Positioner />,
popupContent
);

return renderSlotWithChildren( portal, <Portal />, positionedPopup );
}
);
24 changes: 24 additions & 0 deletions packages/ui/src/form/primitives/combobox/portal.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 { forwardRef } from '@wordpress/element';
import type { PortalProps } from './types';
import { getWpCompatOverlaySlot } from '../../../utils/wp-compat-overlay-slot';

/**
* Root element that portals `Combobox` popup content. Pass to
* `Combobox.Popup`'s `portal` prop. When `portal` is omitted,
* `Combobox.Popup` uses this component with default props.
* `container` defaults to the `@wordpress/ui` compat overlay slot.
*/
const Portal = forwardRef< HTMLDivElement, PortalProps >(
function ComboboxPortal( { container, ...restProps }, ref ) {
return (
<_Combobox.Portal
container={ container ?? getWpCompatOverlaySlot() }
{ ...restProps }
ref={ ref }
/>
);
}
);

export { Portal };
29 changes: 29 additions & 0 deletions packages/ui/src/form/primitives/combobox/positioner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import clsx from 'clsx';
import { Combobox as _Combobox } from '@base-ui/react/combobox';
import { forwardRef } from '@wordpress/element';
import type { PositionerProps } from './types';
import resetStyles from '../../../utils/css/resets.module.css';
import styles from './style.module.css';
import { ITEM_POPUP_POSITIONER_PROPS } from '../constants';

/**
* Used to apply custom positioning to `Combobox`'s popup content.
*/
const Positioner = forwardRef< HTMLDivElement, PositionerProps >(
function ComboboxPositioner( { className, ...props }, ref ) {
return (
<_Combobox.Positioner
{ ...ITEM_POPUP_POSITIONER_PROPS }
{ ...props }
ref={ ref }
className={ clsx(
resetStyles[ 'box-sizing' ],
styles.positioner,
className
) }
/>
);
}
);

export { Positioner };
Loading
Loading