Skip to content
Open
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
18 changes: 15 additions & 3 deletions docs/reference/generated/autocomplete-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@
"detailedType": "boolean | undefined"
},
"autoHighlight": {
"type": "boolean",
"type": "boolean | 'always'",
"default": "false",
"description": "Whether to automatically highlight the first item while filtering.",
"detailedType": "boolean | undefined"
"description": "Controls whether the first matching item is highlighted automatically.\n- `true`: highlight after the user types and keep the highlight while the query changes.\n- `'always'`: always highlight the first item.",
"detailedType": "boolean | 'always' | undefined"
},
"filter": {
"type": "((itemValue: any, query: string, itemToString: ((itemValue: any) => string) | undefined) => boolean) | ((itemValue: ItemValue, query: string, itemToString: ((itemValue: ItemValue) => string) | undefined) => boolean) | null",
Expand All @@ -66,6 +66,12 @@
"description": "Whether list items are presented in a grid layout.\nWhen enabled, arrow keys navigate across rows and columns inferred from DOM rows.",
"detailedType": "boolean | undefined"
},
"highlightItemOnHover": {
"type": "boolean",
"default": "true",
"description": "Whether moving the pointer over items should highlight them.",
"detailedType": "boolean | undefined"
},
"itemToStringValue": {
"type": "((itemValue: any) => string) | ((itemValue: ItemValue) => string)",
"description": "When the item values are objects (`<Autocomplete.Item value={object}>`), this function converts the object value to a string representation for both display in the input and form submission.\nIf the shape of the object is `{ value, label }`, the label will be used automatically without needing to specify this prop.",
Expand All @@ -76,6 +82,12 @@
"description": "The items to be displayed in the list.\nCan be either a flat array of items or an array of groups with items.",
"detailedType": "{ items: any[] }[] | ItemValue[] | undefined"
},
"keepHighlight": {
"type": "boolean",
"default": "false",
"description": "Whether the highlighted item should be preserved when the pointer leaves the list.",
"detailedType": "boolean | undefined"
},
"limit": {
"type": "number",
"default": "-1",
Expand Down
14 changes: 13 additions & 1 deletion docs/reference/generated/combobox-root.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"autoHighlight": {
"type": "boolean",
"default": "false",
"description": "Whether to automatically highlight the first item while filtering.",
"description": "Controls whether the first matching item is highlighted automatically while filtering.",
"detailedType": "boolean | undefined"
},
"defaultInputValue": {
Expand All @@ -65,6 +65,12 @@
"description": "Whether list items are presented in a grid layout.\nWhen enabled, arrow keys navigate across rows and columns inferred from DOM rows.",
"detailedType": "boolean | undefined"
},
"highlightItemOnHover": {
"type": "boolean",
"default": "true",
"description": "Whether moving the pointer over items should highlight them.",
"detailedType": "boolean | undefined"
},
"inputValue": {
"type": "string | number | string[]",
"description": "The input value of the combobox. Use when controlled.",
Expand All @@ -90,6 +96,12 @@
"description": "The items to be displayed in the list.\nCan be either a flat array of items or an array of groups with items.",
"detailedType": "any[] | Group[] | undefined"
},
"keepHighlight": {
"type": "boolean",
"default": "false",
"description": "Whether the highlighted item should be preserved when the pointer leaves the list.",
"detailedType": "boolean | undefined"
},
"limit": {
"type": "number",
"default": "-1",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ Limit the number of visible items using the `limit` prop and guide users to refi

### Auto highlight

Automatically highlight the first matching option as the user types using the `autoHighlight` prop.
Automatically highlight the first matching option as the user types by adding the `autoHighlight` prop.

Set to `"always"` if the highlight should always be present, such as when the list is rendered inline within a dialog, and combine with the `keepHighlight` and `highlightItemOnHover` props to configure how the highlight behaves during mouse interactions.

<Demo path="./demos/auto-highlight" compact />

Expand Down
63 changes: 63 additions & 0 deletions packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,69 @@ describe('<Autocomplete.Root />', () => {
expect(input).to.have.attribute('aria-activedescendant');
});
});

it('retains highlight when clearing the query with autoHighlight enabled', async () => {
const { user } = await render(
<Autocomplete.Root items={['apple', 'banana', 'cherry']} autoHighlight openOnInputClick>
<Autocomplete.Input />
<Autocomplete.Portal>
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>,
);

const input = screen.getByRole<HTMLInputElement>('combobox');

await user.click(input);
await user.type(input, 'ban');
await waitFor(() => expect(screen.getByRole('listbox')).not.to.equal(null));
await waitFor(() => expect(input).to.have.attribute('aria-activedescendant'));
const highlightedBefore = input.getAttribute('aria-activedescendant');
expect(highlightedBefore).to.not.equal(null);

await user.clear(input);
await waitFor(() => expect(screen.getByRole('listbox')).not.to.equal(null));
await waitFor(() =>
expect(input.getAttribute('aria-activedescendant')).to.equal(highlightedBefore),
);
});

it('highlights the first item immediately when behavior is "always"', async () => {
await render(
<Autocomplete.Root items={['alpha', 'beta', 'gamma']} autoHighlight="always" defaultOpen>
<Autocomplete.Input />
<Autocomplete.Portal>
<Autocomplete.Positioner>
<Autocomplete.Popup>
<Autocomplete.List>
{(item: string) => (
<Autocomplete.Item key={item} value={item}>
{item}
</Autocomplete.Item>
)}
</Autocomplete.List>
</Autocomplete.Popup>
</Autocomplete.Positioner>
</Autocomplete.Portal>
</Autocomplete.Root>,
);

const input = screen.getByRole<HTMLInputElement>('combobox');
const firstOption = screen.getByRole('option', { name: 'alpha' });

expect(input).to.have.attribute('aria-activedescendant', firstOption.id);
expect(firstOption).to.have.attribute('data-highlighted');
});
});

describe('prop: mode', () => {
Expand Down
20 changes: 20 additions & 0 deletions packages/react/src/autocomplete/root/AutocompleteRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ export interface AutocompleteRootProps<ItemValue>
| 'autoComplete' // mode
| 'itemToStringLabel' // itemToStringValue
// Custom JSDoc
| 'autoHighlight'
| 'keepHighlight'
| 'highlightItemOnHover'
| 'actionsRef'
| 'onOpenChange'
| 'onInputValueChange'
Expand All @@ -179,6 +182,23 @@ export interface AutocompleteRootProps<ItemValue>
* @default 'list'
*/
mode?: 'list' | 'both' | 'inline' | 'none';
/**
* Controls whether the first matching item is highlighted automatically.
* - `true`: highlight after the user types and keep the highlight while the query changes.
* - `'always'`: always highlight the first item.
* @default false
*/
autoHighlight?: boolean | 'always';
/**
* Whether the highlighted item should be preserved when the pointer leaves the list.
* @default false
*/
keepHighlight?: boolean;
/**
* Whether moving the pointer over items should highlight them.
* @default true
*/
highlightItemOnHover?: boolean;
/**
* The uncontrolled input value of the autocomplete when it's initially rendered.
*
Expand Down
25 changes: 10 additions & 15 deletions packages/react/src/combobox/input/ComboboxInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
const openOnInputClick = useStore(store, selectors.openOnInputClick);
const name = useStore(store, selectors.name);
const selectionMode = useStore(store, selectors.selectionMode);
const autoHighlight = useStore(store, selectors.autoHighlight);
const autoHighlightMode = useStore(store, selectors.autoHighlight);
const inputProps = useStore(store, selectors.inputProps);
const triggerProps = useStore(store, selectors.triggerProps);
const open = useStore(store, selectors.open);
const selectedValue = useStore(store, selectors.selectedValue);
const autoHighlightEnabled = Boolean(autoHighlightMode);

const id = useBaseUiId(idProp);

Expand Down Expand Up @@ -221,14 +222,16 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
);
}

const trimmed = nextVal.trim();
const shouldMaintainHighlight = autoHighlightEnabled && trimmed !== '';

if (!readOnly && !disabled) {
const trimmed = nextVal.trim();
if (trimmed !== '') {
store.state.setOpen(
true,
createChangeEventDetails('input-change', event.nativeEvent),
);
if (!autoHighlight) {
if (!autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
Expand All @@ -238,11 +241,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
}
}

if (
open &&
store.state.activeIndex !== null &&
!(autoHighlight && nextVal.trim() !== '')
) {
if (open && store.state.activeIndex !== null && !shouldMaintainHighlight) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
Expand Down Expand Up @@ -271,15 +270,15 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
}
}

const trimmed = event.currentTarget.value.trim();
if (!readOnly && !disabled) {
const trimmed = event.currentTarget.value.trim();
if (trimmed !== '') {
store.state.setOpen(
true,
createChangeEventDetails('input-change', event.nativeEvent),
);
// When autoHighlight is enabled, keep the highlight (will be set to 0 in root).
if (!autoHighlight) {
if (!autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
Expand All @@ -292,11 +291,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput(
// When the user types, ensure the list resets its highlight so that
// virtual focus returns to the input (aria-activedescendant is
// cleared).
if (
open &&
store.state.activeIndex !== null &&
!(autoHighlight && event.currentTarget.value.trim() !== '')
) {
if (open && store.state.activeIndex !== null && !autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
Expand Down
Loading
Loading