Repository navigation
Add Combobox primitives #78399
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add Combobox primitives #78399
Changes from all commits
b73964c
426e24a
cc3f7a8
195e576
a511e33
6885f67
48e0c01
8349f05
d1973cb
9a74c90
1c7e8ab
df7652c
7fbfa05
c8345c3
2adacc3
1837f49
373b068
48d73c7
4451d4f
9622476
97e01e1
0f5fbd9
325d77a
966e6d4
bd8f0f3
62ba31d
27ddc2b
a2fcb99
7a09de1
35079ff
e68bcd8
c2368af
583ce48
422ebb5
309ee1f
1420d79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| ); | ||
| } ); |
| 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 } />; | ||
| } | ||
| ); |
| 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 } /> | ||
| ); | ||
| } | ||
| ); |
| 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> | ||
| ); | ||
| } |
| 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 } | ||
| /> | ||
| ); | ||
| } | ||
| ); |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Noticing that we're not re-exporting
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, continuing the comparison vs
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Base Ui exports individual
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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'; | ||
| 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 } | ||
| /> | ||
| ); | ||
|
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 } /> | ||
| ); | ||
| } | ||
| ); | ||
| 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> | ||
| ); | ||
| } | ||
| ); |
| 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; | ||
| } | ||
| ); |
| 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; | ||
| } | ||
| ); |
| 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 } | ||
| /> | ||
| ); | ||
| } | ||
| ); |
| 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 ); | ||
| } | ||
| ); |
| 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 }; |
| 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 }; |
Uh oh!
There was an error while loading. Please reload this page.