From 95942b8371d7f685732754fdc235cf5cc7e05963 Mon Sep 17 00:00:00 2001 From: nico <1702755+nicosommi@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:59:15 +0200 Subject: [PATCH 1/3] fix: issue with blur --- .../AutocompleteInput/AutocompleteInput.mdx | 2 +- .../AutocompleteInput.spec.tsx | 103 +++++++++++++++++- .../AutocompleteInput/AutocompleteInput.tsx | 73 +++++++++++-- 3 files changed, 163 insertions(+), 15 deletions(-) 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..9e03e6d796 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx @@ -249,6 +249,34 @@ export const AutocompleteInput = forwardRef< setActiveOption(undefined); }, []); + const resetSearchQuery = useCallback(() => { + onSearch?.(''); + }, [onSearch]); + + const restoreSearchToSelectedValue = useCallback(() => { + if (Array.isArray(value)) { + return; + } + setSearchText(value?.label ?? ''); + if (isImmersive) { + setPresentationFieldValue(value?.label ?? ''); + } + }, [value, isImmersive]); + + const dismissWithoutSelection = useCallback(() => { + if (!Array.isArray(value) && searchText !== value?.value) { + restoreSearchToSelectedValue(); + } + resetSearchQuery(); + closeResults(); + }, [ + value, + searchText, + restoreSearchToSelectedValue, + resetSearchQuery, + closeResults, + ]); + const debouncedOnSearch = useMemo( () => debounce( @@ -273,9 +301,10 @@ export const AutocompleteInput = forwardRef< const onComboboxClear = useCallback( (event: ClickEvent) => { changeInputValue(comboboxRef.current, ''); + resetSearchQuery(); onClear?.(event); }, - [onClear], + [onClear, resetSearchQuery], ); const onPresentationFieldClear = useCallback( @@ -284,9 +313,10 @@ export const AutocompleteInput = forwardRef< setSearchText(''); changeInputValue(presentationFieldRef.current, ''); setIsOpen(true); + resetSearchQuery(); onClear?.(event); }, - [onClear], + [onClear, resetSearchQuery], ); const onPresentationFieldKeyDown = useCallback(() => { @@ -345,14 +375,16 @@ export const AutocompleteInput = forwardRef< (selectedValue: AutocompleteInputOption) => { if (multiple) { setSearchText(''); + resetSearchQuery(); // put focus back on the input field after selection comboboxRef.current?.focus({ preventScroll: true }); } else { + resetSearchQuery(); closeResults(); } onChange(selectedValue); }, - [onChange, closeResults, multiple], + [onChange, closeResults, multiple, resetSearchQuery], ); const onTagRemove = useCallback( @@ -444,12 +476,12 @@ export const AutocompleteInput = forwardRef< const handleClickOutside = useCallback(() => { if (!isImmersive) { - closeResults(); + dismissWithoutSelection(); } - }, [closeResults, isImmersive]); + }, [dismissWithoutSelection, isImmersive]); useClickOutside([inputWrapperRef, refs.floating], handleClickOutside); - useEscapeKey(closeResults, isOpen); + useEscapeKey(dismissWithoutSelection, isOpen); useEffect(() => { // if readOnly or disabled props become truthy, close the list box @@ -504,15 +536,32 @@ export const AutocompleteInput = forwardRef< const restoreValue: FocusEventHandler = useCallback( (event) => { - if (!Array.isArray(value) && searchText !== value?.value) { - setSearchText(value?.label ?? ''); - if (isImmersive) { - setPresentationFieldValue(value?.label ?? ''); + if (!Array.isArray(value)) { + const nextTarget = event.relatedTarget as Node | null; + const movingInside = + inputWrapperRef.current?.contains(nextTarget) || + refs.floating.current?.contains(nextTarget) || + resultsRef.current?.contains(nextTarget); + + if (!movingInside) { + if (searchText !== value?.value) { + restoreSearchToSelectedValue(); + } + resetSearchQuery(); + closeResults(); } } props.onBlur?.(event); }, - [value, searchText, isImmersive, props.onBlur], + [ + value, + searchText, + restoreSearchToSelectedValue, + resetSearchQuery, + closeResults, + refs.floating, + props.onBlur, + ], ); const comboboxProps = { @@ -573,7 +622,7 @@ export const AutocompleteInput = forwardRef< open={isOpen} className={classes.modal} contentClassName={classes['modal-content']} - onClose={closeResults} + onClose={dismissWithoutSelection} >
Date: Wed, 10 Jun 2026 13:20:53 +0200 Subject: [PATCH 2/3] fix: slim implementation --- .../AutocompleteInput/AutocompleteInput.tsx | 89 +++++++------------ 1 file changed, 30 insertions(+), 59 deletions(-) diff --git a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx index 9e03e6d796..2d36dc2e49 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx @@ -249,33 +249,20 @@ export const AutocompleteInput = forwardRef< setActiveOption(undefined); }, []); - const resetSearchQuery = useCallback(() => { - onSearch?.(''); - }, [onSearch]); - - const restoreSearchToSelectedValue = useCallback(() => { - if (Array.isArray(value)) { - return; - } - setSearchText(value?.label ?? ''); - if (isImmersive) { - setPresentationFieldValue(value?.label ?? ''); - } - }, [value, isImmersive]); - - const dismissWithoutSelection = useCallback(() => { - if (!Array.isArray(value) && searchText !== value?.value) { - restoreSearchToSelectedValue(); + const syncSearchOnDismiss = useCallback(() => { + if (!Array.isArray(value)) { + if (searchText !== value?.value) { + setSearchText(value?.label ?? ''); + if (isImmersive) { + setPresentationFieldValue(value?.label ?? ''); + } + } + } else if (searchText) { + setSearchText(''); } - resetSearchQuery(); + onSearch?.(''); closeResults(); - }, [ - value, - searchText, - restoreSearchToSelectedValue, - resetSearchQuery, - closeResults, - ]); + }, [value, searchText, isImmersive, onSearch, closeResults]); const debouncedOnSearch = useMemo( () => @@ -301,10 +288,10 @@ export const AutocompleteInput = forwardRef< const onComboboxClear = useCallback( (event: ClickEvent) => { changeInputValue(comboboxRef.current, ''); - resetSearchQuery(); + onSearch?.(''); onClear?.(event); }, - [onClear, resetSearchQuery], + [onClear, onSearch], ); const onPresentationFieldClear = useCallback( @@ -313,10 +300,10 @@ export const AutocompleteInput = forwardRef< setSearchText(''); changeInputValue(presentationFieldRef.current, ''); setIsOpen(true); - resetSearchQuery(); + onSearch?.(''); onClear?.(event); }, - [onClear, resetSearchQuery], + [onClear, onSearch], ); const onPresentationFieldKeyDown = useCallback(() => { @@ -375,16 +362,14 @@ export const AutocompleteInput = forwardRef< (selectedValue: AutocompleteInputOption) => { if (multiple) { setSearchText(''); - resetSearchQuery(); // put focus back on the input field after selection comboboxRef.current?.focus({ preventScroll: true }); } else { - resetSearchQuery(); closeResults(); } onChange(selectedValue); }, - [onChange, closeResults, multiple, resetSearchQuery], + [onChange, closeResults, multiple], ); const onTagRemove = useCallback( @@ -476,12 +461,12 @@ export const AutocompleteInput = forwardRef< const handleClickOutside = useCallback(() => { if (!isImmersive) { - dismissWithoutSelection(); + closeResults(); } - }, [dismissWithoutSelection, isImmersive]); + }, [closeResults, isImmersive]); useClickOutside([inputWrapperRef, refs.floating], handleClickOutside); - useEscapeKey(dismissWithoutSelection, isOpen); + useEscapeKey(syncSearchOnDismiss, isOpen); useEffect(() => { // if readOnly or disabled props become truthy, close the list box @@ -536,32 +521,18 @@ export const AutocompleteInput = forwardRef< const restoreValue: FocusEventHandler = useCallback( (event) => { - if (!Array.isArray(value)) { - const nextTarget = event.relatedTarget as Node | null; - const movingInside = - inputWrapperRef.current?.contains(nextTarget) || - refs.floating.current?.contains(nextTarget) || - resultsRef.current?.contains(nextTarget); - - if (!movingInside) { - if (searchText !== value?.value) { - restoreSearchToSelectedValue(); - } - resetSearchQuery(); - closeResults(); - } + 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, - restoreSearchToSelectedValue, - resetSearchQuery, - closeResults, - refs.floating, - props.onBlur, - ], + [syncSearchOnDismiss, refs.floating, props.onBlur], ); const comboboxProps = { @@ -622,7 +593,7 @@ export const AutocompleteInput = forwardRef< open={isOpen} className={classes.modal} contentClassName={classes['modal-content']} - onClose={dismissWithoutSelection} + onClose={syncSearchOnDismiss} >
Date: Mon, 15 Jun 2026 10:07:09 +0200 Subject: [PATCH 3/3] fix: adjust to use changeInputValue --- .changeset/autocomplete-input-contract.md | 12 ++++++++++++ .../AutocompleteInput/AutocompleteInput.tsx | 6 ++++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .changeset/autocomplete-input-contract.md 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.tsx b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx index 2d36dc2e49..8a56da769f 100644 --- a/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx +++ b/packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx @@ -251,14 +251,16 @@ export const AutocompleteInput = forwardRef< const syncSearchOnDismiss = useCallback(() => { if (!Array.isArray(value)) { - if (searchText !== value?.value) { + if (!value && searchText !== '') { + changeInputValue(comboboxRef.current, ''); + } else if (searchText !== value?.value) { setSearchText(value?.label ?? ''); if (isImmersive) { setPresentationFieldValue(value?.label ?? ''); } } } else if (searchText) { - setSearchText(''); + changeInputValue(comboboxRef.current, ''); } onSearch?.(''); closeResults();