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
12 changes: 12 additions & 0 deletions .changeset/autocomplete-input-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@sumup-oss/circuit-ui": patch
---

**AutocompleteInput:** emit `onSearch` when the search text is reset internally (on
blur-restore, clear, and immersive modal dismiss), so consumers that derive `options`
from `onSearch` no longer show a stale, previously-filtered list after the field is
dismissed without a selection.

**AutocompleteInput:** close the results list when focus leaves the field (e.g. via
`Tab`), not only on pointer click-outside, so keyboard users don't leave an orphaned
open listbox.
Comment on lines +5 to +12

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.

It is best to create one changeset file per change. It helps with the visibility and discovery of the different changes.

Suggested change
**AutocompleteInput:** emit `onSearch` when the search text is reset internally (on
blur-restore, clear, and immersive modal dismiss), so consumers that derive `options`
from `onSearch` no longer show a stale, previously-filtered list after the field is
dismissed without a selection.
**AutocompleteInput:** close the results list when focus leaves the field (e.g. via
`Tab`), not only on pointer click-outside, so keyboard users don't leave an orphaned
open listbox.
Updated the AutocompleteInput to fire the `onSearch` callback whenever the suggestion box is closed, allowing consumers to reset the suggestion list.

In a separate changeset file:

Updated the AutocompleteInput focus management to close the suggestion box whenever the focus leaves the elements of the component (combobox or suggestion box).

Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ While the AutocompleteInput component is accessible, it can be complex to intera

## How to use

- Use the `onSearch` prop to update the list of options as the user types. You can optionally set the `minQueryLength` prop to delay this behavior until a minimum number of characters has been typed.
- Use the `onSearch` prop to update the list of options as the user types. You can optionally set the `minQueryLength` prop to delay this behavior until a minimum number of characters has been typed. When the component resets its search text internally (for example after blur without a selection, clear, or closing the immersive modal), it emits `onSearch` with an empty string so consumers can restore the full option list.

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.

Suggested change
- Use the `onSearch` prop to update the list of options as the user types. You can optionally set the `minQueryLength` prop to delay this behavior until a minimum number of characters has been typed. When the component resets its search text internally (for example after blur without a selection, clear, or closing the immersive modal), it emits `onSearch` with an empty string so consumers can restore the full option list.
- Use the `onSearch` prop to update the list of options as the user types. You can optionally set the `minQueryLength` prop to delay this behavior until a minimum number of characters has been typed. Whenever the suggestion box is closed, the `onSearch` callback is fired with an empty string, allowing consumers to reset the suggestion list.

- Use the `onChange` prop to handle the selection of an option and set the input's `value` prop to the selected option. After the selection, make sure to reset the value of the `options` prop.
- Use the `onClear` prop to handle the clearing of the input field, and reset the input's `value` prop to reflect this change.
- The component can receive a flat list or a list of grouped options via the `options` prop. To ensure a consistent and user-friendly experience, keep the visual format uniform. For example, make sure all options include (or not) an image or a description. Keep labels and descriptions short and easy to scan.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,16 @@ import {
vi,
} from 'vitest';
import { within } from '@testing-library/react';
import { createRef } from 'react';
import { createRef, useCallback, useState } from 'react';

import { act, axe, render, userEvent, screen } from '../../util/test-utils.js';
import {
act,
axe,
render,
userEvent,
screen,
waitFor,
} from '../../util/test-utils.js';
import { useMedia } from '../../hooks/useMedia/index.js';
import { Button } from '../Button/index.js';

Expand Down Expand Up @@ -111,6 +118,7 @@ describe('AutocompleteInput', () => {
vi.runAllTimers();
});
expect(props.onClear).toHaveBeenCalledOnce();
expect(props.onSearch).toHaveBeenLastCalledWith('');
});

it("should restore display value on blur if user doesn't make a selection", async () => {
Expand All @@ -134,6 +142,53 @@ describe('AutocompleteInput', () => {
input.blur();
});
expect(input).toHaveValue('Foo');
expect(props.onSearch).toHaveBeenLastCalledWith('');
});

it('should reset filtered options after blur without selection when options are derived from onSearch', async () => {
const selected = options[0];
const filterByQuery = (query: string) =>
options.filter((option) =>
option.label.toLowerCase().includes(query.trim().toLowerCase()),
);

function ControlledAutocomplete() {
const [value, setValue] = useState(selected);
const [filteredOptions, setFilteredOptions] = useState(options);
const onSearch = useCallback((query: string) => {
props.onSearch(query);
setFilteredOptions(filterByQuery(query));
}, []);

return (
<AutocompleteInput
{...props}
value={value}
options={filteredOptions}
onSearch={onSearch}
onChange={setValue}
/>
);
}

render(<ControlledAutocomplete />);
const input = screen.getByRole('combobox', { name: props.label });

await userEvent.click(input);
await userEvent.clear(input);
await userEvent.type(input, 'lu');
act(() => {
vi.runAllTimers();
});
expect(screen.getAllByRole('option')).toHaveLength(1);

act(() => {
input.blur();
});
expect(props.onSearch).toHaveBeenLastCalledWith('');

await userEvent.click(input);
expect(screen.getAllByRole('option')).toHaveLength(options.length);
});

it('should call onSearch when the user types', async () => {
Expand Down Expand Up @@ -331,6 +386,23 @@ describe('AutocompleteInput', () => {
await userEvent.keyboard('{Enter}');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});

it('should close the list box when focus moves to another element via Tab', async () => {
render(
<>
<AutocompleteInput {...props} />
<Button>Next field</Button>
</>,
);
await userEvent.click(
screen.getByRole('combobox', { name: props.label }),
);
expect(screen.getByRole('listbox')).toBeVisible();

await userEvent.tab();

expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});

describe('Multi-selection', () => {
Expand Down Expand Up @@ -541,6 +613,33 @@ describe('AutocompleteInput', () => {
await userEvent.click(screen.getByLabelText(props.label));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('should call onSearch with an empty string when the modal is dismissed without selection', async () => {
render(
<AutocompleteInput {...props} variant="immersive" value={options[0]} />,
);

await userEvent.click(screen.getByLabelText(props.label));
const dialog = screen.getByRole('dialog');
const combobox = within(dialog).getByRole('combobox', {
name: props.label,
});

await userEvent.type(combobox, 'zzz');
act(() => {
vi.runAllTimers();
});

await userEvent.click(screen.getByRole('button', { name: 'Close' }));
act(() => {
vi.advanceTimersByTime(300);
});

await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
expect(props.onSearch).toHaveBeenLastCalledWith('');
});
});

describe('Loading state', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,23 @@ export const AutocompleteInput = forwardRef<
setActiveOption(undefined);
}, []);

const syncSearchOnDismiss = useCallback(() => {
if (!Array.isArray(value)) {
if (!value && searchText !== '') {
changeInputValue(comboboxRef.current, '');
} else if (searchText !== value?.value) {
setSearchText(value?.label ?? '');
if (isImmersive) {
setPresentationFieldValue(value?.label ?? '');
}
}
} else if (searchText) {
changeInputValue(comboboxRef.current, '');
}
Comment on lines +252 to +264

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.

with the suggestions below, this can be simplified

Suggested change
const syncSearchOnDismiss = useCallback(() => {
if (!Array.isArray(value)) {
if (!value && searchText !== '') {
changeInputValue(comboboxRef.current, '');
} else if (searchText !== value?.value) {
setSearchText(value?.label ?? '');
if (isImmersive) {
setPresentationFieldValue(value?.label ?? '');
}
}
} else if (searchText) {
changeInputValue(comboboxRef.current, '');
}
const syncSearchOnDismiss = useCallback(() => {
if (!Array.isArray(value) && searchText !== value?.value) {
setSearchText(value?.label ?? '');
}

onSearch?.('');

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.

I noticed that whenever we close the suggestion box, we always call onSearch('') , which actually makes sense. How about moving it into the closeResults method ?

closeResults();
}, [value, searchText, isImmersive, onSearch, closeResults]);

const debouncedOnSearch = useMemo(
() =>
debounce(
Expand All @@ -273,9 +290,10 @@ export const AutocompleteInput = forwardRef<
const onComboboxClear = useCallback(
(event: ClickEvent) => {
changeInputValue(comboboxRef.current, '');
onSearch?.('');

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.

I think onClear is enough to allow consumers to do the necessary (reset suggestions) I think calling onSearch here is superfluous

onClear?.(event);
},
[onClear],
[onClear, onSearch],
);

const onPresentationFieldClear = useCallback(
Expand All @@ -284,9 +302,10 @@ export const AutocompleteInput = forwardRef<
setSearchText('');
changeInputValue(presentationFieldRef.current, '');
setIsOpen(true);
onSearch?.('');

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.

same comment about onClear

onClear?.(event);
},
[onClear],
[onClear, onSearch],
);

const onPresentationFieldKeyDown = useCallback(() => {
Expand Down Expand Up @@ -449,7 +468,7 @@ export const AutocompleteInput = forwardRef<
}, [closeResults, isImmersive]);
useClickOutside([inputWrapperRef, refs.floating], handleClickOutside);

useEscapeKey(closeResults, isOpen);
useEscapeKey(syncSearchOnDismiss, isOpen);

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.

I think closeResults (with onSearch('') ) will do the trick here


useEffect(() => {
// if readOnly or disabled props become truthy, close the list box
Expand Down Expand Up @@ -504,15 +523,18 @@ export const AutocompleteInput = forwardRef<

const restoreValue: FocusEventHandler<HTMLInputElement> = useCallback(
(event) => {
if (!Array.isArray(value) && searchText !== value?.value) {
setSearchText(value?.label ?? '');
if (isImmersive) {
setPresentationFieldValue(value?.label ?? '');
}
const nextTarget = event.relatedTarget as Node | null;
const movingInside =
inputWrapperRef.current?.contains(nextTarget) ||
refs.floating.current?.contains(nextTarget) ||
resultsRef.current?.contains(nextTarget);

if (!movingInside) {
syncSearchOnDismiss();
}

@sirineJ sirineJ Jun 11, 2026

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.

Thank you for catching this issue, @nicosommi 👏
I believe there could be a simpler way to address it. If we fire a change event here (like we do in the onComboboxClear callback) I believe we could achieve the same thing:

Suggested change
}
}
if (!value && searchText !== '') {
changeInputValue(comboboxRef.current, '');
}

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.

Good call on reusing changeInputValue for the clear-to-empty path — adopted that in syncSearchOnDismiss for !value && searchText !== '' (and multi-select filter reset), same as onComboboxClear.

Kept setSearchText for the preselected-value restore path because firing a change to the label would debounce onSearch(label) and leave client-filtered options stale again. Still emit onSearch('') explicitly so consumers get the full list on reopen.

movingInside + syncSearchOnDismiss on Escape/modal close remain for Tab-close and immersive dismiss.

props.onBlur?.(event);
},
[value, searchText, isImmersive, props.onBlur],
[syncSearchOnDismiss, refs.floating, props.onBlur],
);

const comboboxProps = {
Expand Down Expand Up @@ -573,7 +595,7 @@ export const AutocompleteInput = forwardRef<
open={isOpen}
className={classes.modal}
contentClassName={classes['modal-content']}
onClose={closeResults}
onClose={syncSearchOnDismiss}

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.

I think closeResults (with onSearch('') ) will do the trick here

>
<div ref={inputWrapperRef} className={classes['modal-input']}>
<ComboboxInput
Expand Down
Loading