Skip to content

Commit 95942b8

Browse files
committed
fix: issue with blur
1 parent c954175 commit 95942b8

3 files changed

Lines changed: 163 additions & 15 deletions

File tree

packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ While the AutocompleteInput component is accessible, it can be complex to intera
2525

2626
## How to use
2727

28-
- 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.
28+
- 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.
2929
- 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.
3030
- Use the `onClear` prop to handle the clearing of the input field, and reset the input's `value` prop to reflect this change.
3131
- 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.

packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.spec.tsx

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,16 @@ import {
2525
vi,
2626
} from 'vitest';
2727
import { within } from '@testing-library/react';
28-
import { createRef } from 'react';
28+
import { createRef, useCallback, useState } from 'react';
2929

30-
import { act, axe, render, userEvent, screen } from '../../util/test-utils.js';
30+
import {
31+
act,
32+
axe,
33+
render,
34+
userEvent,
35+
screen,
36+
waitFor,
37+
} from '../../util/test-utils.js';
3138
import { useMedia } from '../../hooks/useMedia/index.js';
3239
import { Button } from '../Button/index.js';
3340

@@ -111,6 +118,7 @@ describe('AutocompleteInput', () => {
111118
vi.runAllTimers();
112119
});
113120
expect(props.onClear).toHaveBeenCalledOnce();
121+
expect(props.onSearch).toHaveBeenLastCalledWith('');
114122
});
115123

116124
it("should restore display value on blur if user doesn't make a selection", async () => {
@@ -134,6 +142,53 @@ describe('AutocompleteInput', () => {
134142
input.blur();
135143
});
136144
expect(input).toHaveValue('Foo');
145+
expect(props.onSearch).toHaveBeenLastCalledWith('');
146+
});
147+
148+
it('should reset filtered options after blur without selection when options are derived from onSearch', async () => {
149+
const selected = options[0];
150+
const filterByQuery = (query: string) =>
151+
options.filter((option) =>
152+
option.label.toLowerCase().includes(query.trim().toLowerCase()),
153+
);
154+
155+
function ControlledAutocomplete() {
156+
const [value, setValue] = useState(selected);
157+
const [filteredOptions, setFilteredOptions] = useState(options);
158+
const onSearch = useCallback((query: string) => {
159+
props.onSearch(query);
160+
setFilteredOptions(filterByQuery(query));
161+
}, []);
162+
163+
return (
164+
<AutocompleteInput
165+
{...props}
166+
value={value}
167+
options={filteredOptions}
168+
onSearch={onSearch}
169+
onChange={setValue}
170+
/>
171+
);
172+
}
173+
174+
render(<ControlledAutocomplete />);
175+
const input = screen.getByRole('combobox', { name: props.label });
176+
177+
await userEvent.click(input);
178+
await userEvent.clear(input);
179+
await userEvent.type(input, 'lu');
180+
act(() => {
181+
vi.runAllTimers();
182+
});
183+
expect(screen.getAllByRole('option')).toHaveLength(1);
184+
185+
act(() => {
186+
input.blur();
187+
});
188+
expect(props.onSearch).toHaveBeenLastCalledWith('');
189+
190+
await userEvent.click(input);
191+
expect(screen.getAllByRole('option')).toHaveLength(options.length);
137192
});
138193

139194
it('should call onSearch when the user types', async () => {
@@ -331,6 +386,23 @@ describe('AutocompleteInput', () => {
331386
await userEvent.keyboard('{Enter}');
332387
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
333388
});
389+
390+
it('should close the list box when focus moves to another element via Tab', async () => {
391+
render(
392+
<>
393+
<AutocompleteInput {...props} />
394+
<Button>Next field</Button>
395+
</>,
396+
);
397+
await userEvent.click(
398+
screen.getByRole('combobox', { name: props.label }),
399+
);
400+
expect(screen.getByRole('listbox')).toBeVisible();
401+
402+
await userEvent.tab();
403+
404+
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
405+
});
334406
});
335407

336408
describe('Multi-selection', () => {
@@ -541,6 +613,33 @@ describe('AutocompleteInput', () => {
541613
await userEvent.click(screen.getByLabelText(props.label));
542614
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
543615
});
616+
617+
it('should call onSearch with an empty string when the modal is dismissed without selection', async () => {
618+
render(
619+
<AutocompleteInput {...props} variant="immersive" value={options[0]} />,
620+
);
621+
622+
await userEvent.click(screen.getByLabelText(props.label));
623+
const dialog = screen.getByRole('dialog');
624+
const combobox = within(dialog).getByRole('combobox', {
625+
name: props.label,
626+
});
627+
628+
await userEvent.type(combobox, 'zzz');
629+
act(() => {
630+
vi.runAllTimers();
631+
});
632+
633+
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
634+
act(() => {
635+
vi.advanceTimersByTime(300);
636+
});
637+
638+
await waitFor(() => {
639+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
640+
});
641+
expect(props.onSearch).toHaveBeenLastCalledWith('');
642+
});
544643
});
545644

546645
describe('Loading state', () => {

packages/circuit-ui/components/AutocompleteInput/AutocompleteInput.tsx

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,34 @@ export const AutocompleteInput = forwardRef<
249249
setActiveOption(undefined);
250250
}, []);
251251

252+
const resetSearchQuery = useCallback(() => {
253+
onSearch?.('');
254+
}, [onSearch]);
255+
256+
const restoreSearchToSelectedValue = useCallback(() => {
257+
if (Array.isArray(value)) {
258+
return;
259+
}
260+
setSearchText(value?.label ?? '');
261+
if (isImmersive) {
262+
setPresentationFieldValue(value?.label ?? '');
263+
}
264+
}, [value, isImmersive]);
265+
266+
const dismissWithoutSelection = useCallback(() => {
267+
if (!Array.isArray(value) && searchText !== value?.value) {
268+
restoreSearchToSelectedValue();
269+
}
270+
resetSearchQuery();
271+
closeResults();
272+
}, [
273+
value,
274+
searchText,
275+
restoreSearchToSelectedValue,
276+
resetSearchQuery,
277+
closeResults,
278+
]);
279+
252280
const debouncedOnSearch = useMemo(
253281
() =>
254282
debounce(
@@ -273,9 +301,10 @@ export const AutocompleteInput = forwardRef<
273301
const onComboboxClear = useCallback(
274302
(event: ClickEvent) => {
275303
changeInputValue(comboboxRef.current, '');
304+
resetSearchQuery();
276305
onClear?.(event);
277306
},
278-
[onClear],
307+
[onClear, resetSearchQuery],
279308
);
280309

281310
const onPresentationFieldClear = useCallback(
@@ -284,9 +313,10 @@ export const AutocompleteInput = forwardRef<
284313
setSearchText('');
285314
changeInputValue(presentationFieldRef.current, '');
286315
setIsOpen(true);
316+
resetSearchQuery();
287317
onClear?.(event);
288318
},
289-
[onClear],
319+
[onClear, resetSearchQuery],
290320
);
291321

292322
const onPresentationFieldKeyDown = useCallback(() => {
@@ -345,14 +375,16 @@ export const AutocompleteInput = forwardRef<
345375
(selectedValue: AutocompleteInputOption) => {
346376
if (multiple) {
347377
setSearchText('');
378+
resetSearchQuery();
348379
// put focus back on the input field after selection
349380
comboboxRef.current?.focus({ preventScroll: true });
350381
} else {
382+
resetSearchQuery();
351383
closeResults();
352384
}
353385
onChange(selectedValue);
354386
},
355-
[onChange, closeResults, multiple],
387+
[onChange, closeResults, multiple, resetSearchQuery],
356388
);
357389

358390
const onTagRemove = useCallback(
@@ -444,12 +476,12 @@ export const AutocompleteInput = forwardRef<
444476

445477
const handleClickOutside = useCallback(() => {
446478
if (!isImmersive) {
447-
closeResults();
479+
dismissWithoutSelection();
448480
}
449-
}, [closeResults, isImmersive]);
481+
}, [dismissWithoutSelection, isImmersive]);
450482
useClickOutside([inputWrapperRef, refs.floating], handleClickOutside);
451483

452-
useEscapeKey(closeResults, isOpen);
484+
useEscapeKey(dismissWithoutSelection, isOpen);
453485

454486
useEffect(() => {
455487
// if readOnly or disabled props become truthy, close the list box
@@ -504,15 +536,32 @@ export const AutocompleteInput = forwardRef<
504536

505537
const restoreValue: FocusEventHandler<HTMLInputElement> = useCallback(
506538
(event) => {
507-
if (!Array.isArray(value) && searchText !== value?.value) {
508-
setSearchText(value?.label ?? '');
509-
if (isImmersive) {
510-
setPresentationFieldValue(value?.label ?? '');
539+
if (!Array.isArray(value)) {
540+
const nextTarget = event.relatedTarget as Node | null;
541+
const movingInside =
542+
inputWrapperRef.current?.contains(nextTarget) ||
543+
refs.floating.current?.contains(nextTarget) ||
544+
resultsRef.current?.contains(nextTarget);
545+
546+
if (!movingInside) {
547+
if (searchText !== value?.value) {
548+
restoreSearchToSelectedValue();
549+
}
550+
resetSearchQuery();
551+
closeResults();
511552
}
512553
}
513554
props.onBlur?.(event);
514555
},
515-
[value, searchText, isImmersive, props.onBlur],
556+
[
557+
value,
558+
searchText,
559+
restoreSearchToSelectedValue,
560+
resetSearchQuery,
561+
closeResults,
562+
refs.floating,
563+
props.onBlur,
564+
],
516565
);
517566

518567
const comboboxProps = {
@@ -573,7 +622,7 @@ export const AutocompleteInput = forwardRef<
573622
open={isOpen}
574623
className={classes.modal}
575624
contentClassName={classes['modal-content']}
576-
onClose={closeResults}
625+
onClose={dismissWithoutSelection}
577626
>
578627
<div ref={inputWrapperRef} className={classes['modal-input']}>
579628
<ComboboxInput

0 commit comments

Comments
 (0)