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
20 changes: 20 additions & 0 deletions src/components/ComboBox/ComboBox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,23 @@ export default {
} as Meta<typeof ComboBox>;

export const Default: Story = {};

export const AllowCustomValue: Story = {
args: {
children: (
<ComboBox allowCustomValue items={frameworks}>
<ComboBox.Input placeholder="Select or enter a framework" />
<ComboBox.Content>
<ComboBox.Empty>No items found.</ComboBox.Empty>
<ComboBox.List>
{(item: string) => (
<ComboBox.Item key={item} value={item}>
{item}
</ComboBox.Item>
)}
</ComboBox.List>
</ComboBox.Content>
</ComboBox>
)
}
};
96 changes: 96 additions & 0 deletions src/components/ComboBox/ComboBox.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<div>
<ComboBox allowCustomValue={allowCustomValue} items={frameworks} onValueChange={onValueChange}>
<ComboBox.Input data-testid="input" />
<ComboBox.Content>
<ComboBox.List>
{(item: string) => (
<ComboBox.Item key={item} value={item}>
{item}
</ComboBox.Item>
)}
</ComboBox.List>
</ComboBox.Content>
</ComboBox>
<button type="button">Outside</button>
</div>
);
return {
input: screen.getByTestId<HTMLInputElement>('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');
});
});
5 changes: 2 additions & 3 deletions src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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,
Expand Down
177 changes: 177 additions & 0 deletions src/components/ComboBox/ComboBoxRoot.tsx
Original file line number Diff line number Diff line change
@@ -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 = <Value,>(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 = <Value,>(
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<Value, Multiple extends boolean | undefined = false> = 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 = <Value, Multiple extends boolean | undefined = false>({
allowCustomValue = false,
...props
}: ComboboxRootProps<Value, Multiple>) => {
type RootValue = ComboboxPrimitive.Root.Props<Value, Multiple>['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<RootValue>(() => 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<NonNullable<typeof onValueChange>>[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<Value>(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 <ComboboxPrimitive.Root {...props} />;
}

return (
<ComboboxPrimitive.Root
{...props}
defaultValue={undefined}
value={value}
onInputValueChange={handleInputValueChange}
onOpenChange={handleOpenChange}
onValueChange={changeValue}
/>
);
};

export { ComboboxRoot, type ComboboxRootProps };
Loading