diff --git a/.changeset/autocomplete-input-contract.md b/.changeset/autocomplete-input-contract.md
new file mode 100644
index 0000000000..61e98799b8
--- /dev/null
+++ b/.changeset/autocomplete-input-contract.md
@@ -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.
diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx
index 7dc3e685e7..c922dbb8d2 100644
--- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx
+++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx
@@ -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.
- 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.
diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx
index 521eb3e668..9bd8cb0a25 100644
--- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx
+++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx
@@ -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';
@@ -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 () => {
@@ -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 (
+
+ );
+ }
+
+ render();
+ 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 () => {
@@ -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(
+ <>
+
+
+ >,
+ );
+ 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', () => {
@@ -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(
+ ,
+ );
+
+ 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', () => {
diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx
index 42a0f254bf..8a56da769f 100644
--- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx
+++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx
@@ -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, '');
+ }
+ onSearch?.('');
+ closeResults();
+ }, [value, searchText, isImmersive, onSearch, closeResults]);
+
const debouncedOnSearch = useMemo(
() =>
debounce(
@@ -273,9 +290,10 @@ export const AutocompleteInput = forwardRef<
const onComboboxClear = useCallback(
(event: ClickEvent) => {
changeInputValue(comboboxRef.current, '');
+ onSearch?.('');
onClear?.(event);
},
- [onClear],
+ [onClear, onSearch],
);
const onPresentationFieldClear = useCallback(
@@ -284,9 +302,10 @@ export const AutocompleteInput = forwardRef<
setSearchText('');
changeInputValue(presentationFieldRef.current, '');
setIsOpen(true);
+ onSearch?.('');
onClear?.(event);
},
- [onClear],
+ [onClear, onSearch],
);
const onPresentationFieldKeyDown = useCallback(() => {
@@ -449,7 +468,7 @@ export const AutocompleteInput = forwardRef<
}, [closeResults, isImmersive]);
useClickOutside([inputWrapperRef, refs.floating], handleClickOutside);
- useEscapeKey(closeResults, isOpen);
+ useEscapeKey(syncSearchOnDismiss, isOpen);
useEffect(() => {
// if readOnly or disabled props become truthy, close the list box
@@ -504,15 +523,18 @@ export const AutocompleteInput = forwardRef<
const restoreValue: FocusEventHandler = 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();
}
props.onBlur?.(event);
},
- [value, searchText, isImmersive, props.onBlur],
+ [syncSearchOnDismiss, refs.floating, props.onBlur],
);
const comboboxProps = {
@@ -573,7 +595,7 @@ export const AutocompleteInput = forwardRef<
open={isOpen}
className={classes.modal}
contentClassName={classes['modal-content']}
- onClose={closeResults}
+ onClose={syncSearchOnDismiss}
>