Skip to content

Commit 7d655ba

Browse files
Ensure mouse doesn't active item/option while navigating using the keyboard (#3769)
This PR fixes an issue where the mouse will activate the `MenuItem` or `ListboxOption` below the cursor position even if you are using the keyboard while navigating the menu or listbox. This behavior is now consistent between the Menu, Listbox and Combobox components. The reason this was happening is that we are scrolling the active item into view when using the keyboard. That in turn triggers events such as `pointerenter`, `mouseenter`, `pointerleave` and `mouseleave` even though you didn't move your cursor. Luckily `mousemove` events are not triggered in this case. Now we will ensure that the activation trigger (what tool you used to navigate: keyboard or mouse) is the same and that the mouse did in fact move before activating the item under the cursor. Fixes: #3739 ## Test plan ### Before: https://github.com/user-attachments/assets/39a46b82-58dd-4e20-b81c-94760511301a The Combobox was already behaving correctly. ### After: https://github.com/user-attachments/assets/4c90dfb3-e2bc-4202-b4a9-fe682d7d49cf --------- Co-authored-by: Jordan Pittman <[email protected]>
1 parent c1501a8 commit 7d655ba

File tree

4 files changed

+44
-19
lines changed

4 files changed

+44
-19
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Fix incorrect double invocation of menu items, listbox options and combobox options ([#3766](https://github.com/tailwindlabs/headlessui/pull/3766))
1313
- Fix memory leak in SSR environment ([#3767](https://github.com/tailwindlabs/headlessui/pull/3767))
1414
- Ensure programmatic `.click()` on `MenuButton` ref works ([#3768](https://github.com/tailwindlabs/headlessui/pull/3768))
15+
- Don't activate hovered items while using the keyboard ([#3769](https://github.com/tailwindlabs/headlessui/pull/3769))
1516

1617
## [2.2.6] - 2025-07-24
1718

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1534,8 +1534,17 @@ function OptionFn<
15341534
let handleMove = useEvent((evt) => {
15351535
if (!pointer.wasMoved(evt)) return
15361536
if (disabled) return
1537-
if (active) return
1537+
1538+
// Skip if the option is already active, however, if the activation trigger
1539+
// is not `Pointer` we have to convert it to a `Pointer` trigger
1540+
// activation instead.
1541+
if (active && machine.state.activationTrigger === ActivationTrigger.Pointer) return
1542+
15381543
let idx = data.calculateIndex(value)
1544+
1545+
// pointermove / mousemove will only be fired when the pointer is actually
1546+
// moving, therefore we can go to the option with the `Pointer` activation
1547+
// trigger.
15391548
machine.actions.goToOption({ focus: Focus.Specific, idx }, ActivationTrigger.Pointer)
15401549
})
15411550

@@ -1544,6 +1553,12 @@ function OptionFn<
15441553
if (disabled) return
15451554
if (!active) return
15461555
if (data.optionsPropsRef.current.hold) return
1556+
1557+
// pointerenter / mouseenter will be fired when the mouse is on top of an
1558+
// element that scrolls into view even when using the keyboard to
1559+
// navigate. Only handle the event when the pointer was actually moved.
1560+
if (machine.state.activationTrigger !== ActivationTrigger.Pointer) return
1561+
15471562
machine.actions.goToOption({ focus: Focus.Nothing })
15481563
})
15491564

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -919,24 +919,33 @@ function OptionFn<
919919

920920
let pointer = useTrackedPointer()
921921

922-
let handleEnter = useEvent((evt) => {
923-
pointer.update(evt)
924-
if (disabled) return
925-
if (active) return
926-
machine.actions.goToOption({ focus: Focus.Specific, id }, ActivationTrigger.Pointer)
927-
})
922+
let handleEnter = useEvent((evt) => pointer.update(evt))
928923

929924
let handleMove = useEvent((evt) => {
930925
if (!pointer.wasMoved(evt)) return
931926
if (disabled) return
932-
if (active) return
927+
928+
// Skip if the option is already active, however, if the activation trigger
929+
// is not `Pointer` we have to convert it to a `Pointer` trigger
930+
// activation instead.
931+
if (active && machine.state.activationTrigger === ActivationTrigger.Pointer) return
932+
933+
// pointermove / mousemove will only be fired when the pointer is actually
934+
// moving, therefore we can go to the option with the `Pointer` activation
935+
// trigger.
933936
machine.actions.goToOption({ focus: Focus.Specific, id }, ActivationTrigger.Pointer)
934937
})
935938

936939
let handleLeave = useEvent((evt) => {
937940
if (!pointer.wasMoved(evt)) return
938941
if (disabled) return
939942
if (!active) return
943+
944+
// pointerenter / mouseenter will be fired when the mouse is on top of an
945+
// element that scrolls into view even when using the keyboard to
946+
// navigate. Only handle the event when the pointer was actually moved.
947+
if (machine.state.activationTrigger !== ActivationTrigger.Pointer) return
948+
940949
machine.actions.goToOption({ focus: Focus.Nothing })
941950
})
942951

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -690,22 +690,16 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
690690

691691
let pointer = useTrackedPointer()
692692

693-
let handleEnter = useEvent((event) => {
694-
pointer.update(event)
695-
if (disabled) return
696-
if (active) return
697-
machine.send({
698-
type: ActionTypes.GoToItem,
699-
focus: Focus.Specific,
700-
id,
701-
trigger: ActivationTrigger.Pointer,
702-
})
703-
})
693+
let handleEnter = useEvent((event) => pointer.update(event))
704694

705695
let handleMove = useEvent((event) => {
706696
if (!pointer.wasMoved(event)) return
707697
if (disabled) return
708698
if (active) return
699+
700+
// pointermove / mousemove will only be fired when the pointer is actually
701+
// moving, therefore we can go to the option with the `Pointer` activation
702+
// trigger.
709703
machine.send({
710704
type: ActionTypes.GoToItem,
711705
focus: Focus.Specific,
@@ -718,6 +712,12 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
718712
if (!pointer.wasMoved(event)) return
719713
if (disabled) return
720714
if (!active) return
715+
716+
// pointerenter / mouseenter will be fired when the mouse is on top of an
717+
// element that scrolls into view even when using the keyboard to
718+
// navigate. Only handle the event when the pointer was actually moved.
719+
if (machine.state.activationTrigger !== ActivationTrigger.Pointer) return
720+
721721
machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
722722
})
723723

0 commit comments

Comments
 (0)