Skip to content

Commit 4266d51

Browse files
authored
[autocomplete] Fix keepHighlight focus sync (#3399)
1 parent c4a9062 commit 4266d51

File tree

3 files changed

+59
-5
lines changed

3 files changed

+59
-5
lines changed

packages/react/src/autocomplete/root/AutocompleteRoot.test.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,44 @@ describe('<Autocomplete.Root />', () => {
422422
await waitFor(() => expect(apple).to.have.attribute('data-highlighted'));
423423
outside.remove();
424424
});
425+
426+
it('continues keyboard navigation from the kept highlight after pointer leave', async () => {
427+
const { user } = await render(
428+
<Autocomplete.Root items={['apple', 'banana', 'carrot']} autoHighlight keepHighlight>
429+
<Autocomplete.Input />
430+
<Autocomplete.Portal>
431+
<Autocomplete.Positioner>
432+
<Autocomplete.Popup>
433+
<Autocomplete.List>
434+
{(item: string) => (
435+
<Autocomplete.Item key={item} value={item}>
436+
{item}
437+
</Autocomplete.Item>
438+
)}
439+
</Autocomplete.List>
440+
</Autocomplete.Popup>
441+
</Autocomplete.Positioner>
442+
</Autocomplete.Portal>
443+
</Autocomplete.Root>,
444+
);
445+
446+
const input = screen.getByRole<HTMLInputElement>('combobox');
447+
await user.click(input);
448+
await user.type(input, 'a');
449+
450+
const apple = await screen.findByRole('option', { name: 'apple' });
451+
await waitFor(() => expect(apple).to.have.attribute('data-highlighted'));
452+
453+
const outside = document.createElement('div');
454+
document.body.appendChild(outside);
455+
fireEvent.pointerLeave(apple, { pointerType: 'mouse', relatedTarget: outside });
456+
457+
await user.keyboard('{ArrowDown}');
458+
459+
const banana = screen.getByRole('option', { name: 'banana' });
460+
await waitFor(() => expect(banana).to.have.attribute('data-highlighted'));
461+
outside.remove();
462+
});
425463
});
426464

427465
describe('prop: mode', () => {

packages/react/src/combobox/root/AriaCombobox.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,7 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
10521052
focusItemOnOpen:
10531053
queryChangedAfterOpen || (selectionMode === 'none' && !autoHighlightMode) ? false : 'auto',
10541054
focusItemOnHover: highlightItemOnHover,
1055+
resetOnPointerLeave: !keepHighlight,
10551056
// `cols` > 1 enables grid navigation.
10561057
// Since <Combobox.Row> infers column sizes (and is required when building a grid),
10571058
// it works correctly even with a value of `2`.
@@ -1065,10 +1066,6 @@ export function AriaCombobox<Value = any, Mode extends SelectionMode = 'none'>(
10651066
return;
10661067
}
10671068

1068-
if (keepHighlight && nextActiveIndex === null && event && event.type === 'pointerleave') {
1069-
return;
1070-
}
1071-
10721069
if (!event) {
10731070
setIndices({
10741071
activeIndex: nextActiveIndex,

packages/react/src/floating-ui-react/hooks/useListNavigation.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,11 @@ export interface UseListNavigationProps {
226226
* The id of the root component.
227227
*/
228228
id?: string | undefined;
229+
/**
230+
* Whether to clear the active index when the pointer leaves an item.
231+
* @default true
232+
*/
233+
resetOnPointerLeave?: boolean;
229234
/**
230235
* External FlatingTree to use when the one provided by context can't be used.
231236
*/
@@ -269,6 +274,7 @@ export function useListNavigation(
269274
itemSizes,
270275
dense = false,
271276
id,
277+
resetOnPointerLeave = true,
272278
externalTree,
273279
} = props;
274280

@@ -322,6 +328,7 @@ export function useListNavigation(
322328
const latestOpenRef = useValueAsRef(open);
323329
const scrollItemIntoViewRef = useValueAsRef(scrollItemIntoView);
324330
const selectedIndexRef = useValueAsRef(selectedIndex);
331+
const resetOnPointerLeaveRef = useValueAsRef(resetOnPointerLeave);
325332

326333
const focusItem = useStableCallback(() => {
327334
function runFocus(item: HTMLElement) {
@@ -553,6 +560,10 @@ export function useListNavigation(
553560
return;
554561
}
555562

563+
if (!resetOnPointerLeaveRef.current) {
564+
return;
565+
}
566+
556567
indexRef.current = -1;
557568
onNavigate(event);
558569

@@ -563,7 +574,15 @@ export function useListNavigation(
563574
};
564575

565576
return itemProps;
566-
}, [latestOpenRef, floatingFocusElementRef, focusItemOnHover, listRef, onNavigate, virtual]);
577+
}, [
578+
latestOpenRef,
579+
floatingFocusElementRef,
580+
focusItemOnHover,
581+
listRef,
582+
onNavigate,
583+
resetOnPointerLeaveRef,
584+
virtual,
585+
]);
567586

568587
const getParentOrientation = React.useCallback(() => {
569588
return (

0 commit comments

Comments
 (0)