From 60e10a92c0fe7e257c074854391ebbf0850da17a Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 6 Aug 2026 16:06:59 -0400 Subject: [PATCH 1/3] feat: add combobox root to combobox components to allow for a custom input into combobox --- src/components/ComboBox/ComboBox.tsx | 5 +- src/components/ComboBox/ComboBoxRoot.tsx | 177 +++++++++++++++++++++++ 2 files changed, 179 insertions(+), 3 deletions(-) create mode 100644 src/components/ComboBox/ComboBoxRoot.tsx diff --git a/src/components/ComboBox/ComboBox.tsx b/src/components/ComboBox/ComboBox.tsx index 23a9926..bcd29c5 100644 --- a/src/components/ComboBox/ComboBox.tsx +++ b/src/components/ComboBox/ComboBox.tsx @@ -1,7 +1,5 @@ import * as React from 'react'; -import { Combobox as ComboboxPrimitive } from '@base-ui/react'; - import { ComboboxChip, ComboboxChips, ComboboxChipsInput } from './ComboBoxChips.tsx'; import { ComboboxClear } from './ComboBoxClear.tsx'; import { ComboboxCollection } from './ComboBoxCollection.tsx'; @@ -12,6 +10,7 @@ import { ComboboxInput } from './ComboBoxInput.tsx'; import { ComboboxItem } from './ComboBoxItem.tsx'; import { ComboboxLabel } from './ComboBoxLabel.tsx'; import { ComboboxList } from './ComboBoxList.tsx'; +import { ComboboxRoot } from './ComboBoxRoot.tsx'; import { ComboboxSeparator } from './ComboBoxSeparator.tsx'; import { ComboboxTrigger } from './ComboBoxTrigger.tsx'; import { ComboboxValue } from './ComboBoxValue.tsx'; @@ -22,7 +21,7 @@ function useComboboxAnchor() { export { useComboboxAnchor }; -export const ComboBox = Object.assign(ComboboxPrimitive.Root.bind(null), { +export const ComboBox = Object.assign(ComboboxRoot, { Chip: ComboboxChip, Chips: ComboboxChips, ChipsInput: ComboboxChipsInput, diff --git a/src/components/ComboBox/ComboBoxRoot.tsx b/src/components/ComboBox/ComboBoxRoot.tsx new file mode 100644 index 0000000..20aa319 --- /dev/null +++ b/src/components/ComboBox/ComboBoxRoot.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; + +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; + +type ChangeEventDetails = ComboboxPrimitive.Root.ChangeEventDetails; + +/** + * Closing for one of these reasons discards whatever the user typed, so the text must not be + * committed as a custom value: an item was selected, or the user explicitly reverted. + */ +const DISCARDED_CLOSE_REASONS: readonly string[] = ['escape-key', 'item-press']; + +/** Mirrors how base UI derives the text shown in the input for a given item. */ +const stringifyItem = (item: null | undefined | Value, itemToStringLabel?: (itemValue: Value) => string) => { + if (item === null || item === undefined) { + return ''; + } + if (itemToStringLabel) { + return itemToStringLabel(item) ?? ''; + } + if (typeof item === 'object') { + const { label, value } = item as { label?: number | string; value?: number | string }; + if (label !== null && label !== undefined) { + return String(label); + } + return value === null || value === undefined ? '' : String(value); + } + return String(item); +}; + +/** Finds the item whose label is the text the user typed, so exact matches select the real item. */ +const findItemByLabel = ( + items: readonly unknown[] | undefined, + label: string, + itemToStringLabel?: (itemValue: Value) => string +) => { + if (!items) { + return undefined; + } + const flatItems = items.flatMap((item) => + typeof item === 'object' && item !== null && 'items' in item ? ((item as { items: unknown[] }).items ?? []) : [item] + ) as Value[]; + return flatItems.find((item) => stringifyItem(item, itemToStringLabel).toLowerCase() === label.toLowerCase()); +}; + +/** + * Base UI cancellation is scoped to a single event, so the value change emitted on close gets its + * own details object. Sharing the one from `onOpenChange` would let a consumer cancelling the value + * change also cancel the close. + */ +const forkChangeEventDetails = (details: ChangeEventDetails): ChangeEventDetails => { + let canceled = false; + let propagationAllowed = false; + return { + allowPropagation: () => { + propagationAllowed = true; + }, + cancel: () => { + canceled = true; + }, + event: details.event, + get isCanceled() { + return canceled; + }, + get isPropagationAllowed() { + return propagationAllowed; + }, + reason: details.reason, + trigger: details.trigger + } as ChangeEventDetails; +}; + +type ComboboxRootProps = ComboboxPrimitive.Root.Props< + Value, + Multiple +> & { + /** + * Whether text that does not match any item may be entered. + * + * By default, text that does not resolve to an item is discarded when the popup closes and the + * input reverts to the selected item. When enabled, the text is instead kept and committed as the + * value (through `onValueChange`) when the user clicks away, tabs out, or presses `Enter`. + * Pressing `Escape` still reverts, and selecting an item is unaffected. If the text exactly + * matches the label of an item in `items`, that item is selected instead of a custom value. + * + * Pass a function to build the value from the text, which is required when items are objects + * rather than strings. The returned value must render the same text (see `itemToStringLabel`), + * otherwise the input is reverted to the label of the value. + * + * Ignored when `multiple` is set. + * @default false + */ + allowCustomValue?: ((inputValue: string) => Value) | boolean; +}; + +const ComboboxRoot = ({ + allowCustomValue = false, + ...props +}: ComboboxRootProps) => { + type RootValue = ComboboxPrimitive.Root.Props['value']; + + const { defaultValue, items, itemToStringLabel, multiple, onInputValueChange, onOpenChange, onValueChange } = props; + + const isEnabled = allowCustomValue !== false && !multiple; + const isControlled = props.value !== undefined; + + const [uncontrolledValue, setUncontrolledValue] = React.useState(() => defaultValue ?? null); + const value = isControlled ? props.value : uncontrolledValue; + + const inputValueRef = React.useRef(''); + const didTypeRef = React.useRef(false); + + const changeValue = (nextValue: RootValue, details: ChangeEventDetails) => { + onValueChange?.(nextValue as Parameters>[0], details); + if (details.isCanceled || isControlled) { + return; + } + setUncontrolledValue(nextValue); + }; + + const handleInputValueChange = (inputValue: string, details: ChangeEventDetails) => { + onInputValueChange?.(inputValue, details); + if (details.isCanceled) { + return; + } + inputValueRef.current = inputValue; + if (details.reason === 'input-change') { + didTypeRef.current = true; + } + }; + + const handleOpenChange = (open: boolean, details: ChangeEventDetails) => { + onOpenChange?.(open, details); + if (details.isCanceled) { + return; + } + if (open) { + // The first keystroke opens the popup, so that open must not discard what was just typed. + if (details.reason !== 'input-change') { + didTypeRef.current = false; + } + return; + } + const didType = didTypeRef.current; + didTypeRef.current = false; + if (!didType || DISCARDED_CLOSE_REASONS.includes(details.reason)) { + return; + } + const inputValue = inputValueRef.current.trim(); + // An empty input already clears the value, and unchanged text is not a custom value. + if (inputValue === '' || inputValue === stringifyItem(value as Value, itemToStringLabel)) { + return; + } + const match = findItemByLabel(items, inputValue, itemToStringLabel); + const nextValue = + match ?? + (typeof allowCustomValue === 'function' ? allowCustomValue(inputValue) : (inputValue as unknown as Value)); + changeValue(nextValue as RootValue, forkChangeEventDetails(details)); + }; + + if (!isEnabled) { + return ; + } + + return ( + + ); +}; + +export { ComboboxRoot, type ComboboxRootProps }; From b7c128cf01b9278d944c920dbf0f9f46dbb6d525 Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 6 Aug 2026 16:07:34 -0400 Subject: [PATCH 2/3] feat: add combobox with flag to stories --- src/components/ComboBox/ComboBox.stories.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/components/ComboBox/ComboBox.stories.tsx b/src/components/ComboBox/ComboBox.stories.tsx index d15c95b..b01ae88 100644 --- a/src/components/ComboBox/ComboBox.stories.tsx +++ b/src/components/ComboBox/ComboBox.stories.tsx @@ -29,3 +29,23 @@ export default { } as Meta; export const Default: Story = {}; + +export const AllowCustomValue: Story = { + args: { + children: ( + + + + No items found. + + {(item: string) => ( + + {item} + + )} + + + + ) + } +}; From 6176cbbd0c8ca5abaf532241494aad49b8032dc3 Mon Sep 17 00:00:00 2001 From: David Roper Date: Thu, 6 Aug 2026 16:07:55 -0400 Subject: [PATCH 3/3] test: add test file for all combobox behaviour --- src/components/ComboBox/ComboBox.test.tsx | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/components/ComboBox/ComboBox.test.tsx diff --git a/src/components/ComboBox/ComboBox.test.tsx b/src/components/ComboBox/ComboBox.test.tsx new file mode 100644 index 0000000..4197cf8 --- /dev/null +++ b/src/components/ComboBox/ComboBox.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { ComboBox } from './ComboBox.tsx'; + +describe('ComboBox', () => { + const frameworks = ['Next.js', 'SvelteKit', 'Nuxt.js', 'Remix', 'Astro']; + + const setup = ({ allowCustomValue = false, onValueChange = vi.fn() } = {}) => { + render( +
+ + + + + {(item: string) => ( + + {item} + + )} + + + + +
+ ); + return { + input: screen.getByTestId('input'), + onValueChange, + outside: screen.getByRole('button', { name: 'Outside' }) + }; + }; + + it('should discard text that does not match an item by default', async () => { + const { input, onValueChange, outside } = setup(); + await userEvent.type(input, 'Qwik'); + await userEvent.click(outside); + await waitFor(() => { + expect(input).toHaveValue(''); + }); + expect(onValueChange).not.toHaveBeenCalledWith('Qwik', expect.anything()); + }); + + it('should keep text that does not match an item when allowCustomValue is set', async () => { + const { input, onValueChange, outside } = setup({ allowCustomValue: true }); + await userEvent.type(input, 'Qwik'); + await userEvent.click(outside); + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith('Qwik', expect.anything()); + }); + expect(input).toHaveValue('Qwik'); + }); + + it('should keep custom text typed while the popup is still closed', async () => { + const { input, onValueChange, outside } = setup({ allowCustomValue: true }); + // Typing without clicking first (e.g. after tabbing in) is what opens the popup. + input.focus(); + await userEvent.keyboard('Q'); + await userEvent.click(outside); + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith('Q', expect.anything()); + }); + expect(input).toHaveValue('Q'); + }); + + it('should select the matching item when the custom text is an exact match', async () => { + const { input, onValueChange, outside } = setup({ allowCustomValue: true }); + await userEvent.type(input, 'astro'); + await userEvent.click(outside); + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith('Astro', expect.anything()); + }); + expect(input).toHaveValue('Astro'); + }); + + it('should discard custom text when the user presses escape', async () => { + const { input, onValueChange } = setup({ allowCustomValue: true }); + await userEvent.type(input, 'Qwik'); + await userEvent.keyboard('{Escape}'); + await waitFor(() => { + expect(input).toHaveValue(''); + }); + expect(onValueChange).not.toHaveBeenCalledWith('Qwik', expect.anything()); + }); + + it('should select an item without creating a custom value', async () => { + const { input, onValueChange } = setup({ allowCustomValue: true }); + await userEvent.type(input, 'Rem'); + await userEvent.click(await screen.findByRole('option', { name: 'Remix' })); + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith('Remix', expect.anything()); + }); + expect(input).toHaveValue('Remix'); + }); +});