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} >