Skip to content

Commit 2e941f8

Browse files
Ignore mouse move/leave events when the cursor hasn’t moved (#2069)
* Ignore mouse move/leave events when the cursor hasn’t moved A mouse enter / leave event where the cursor hasn’t moved happen only because of: - Scrolling - The container moved * Fix linting errors * Update changelog * wip * Fix tests * fix linting error * Tweak tests to bypass tracked pointer checks * Fixup * Add stuff * Fix build script * fix stuff * wip
1 parent a6dea8a commit 2e941f8

File tree

16 files changed

+293
-31
lines changed

16 files changed

+293
-31
lines changed

packages/@headlessui-react/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Apply `enter` and `enterFrom` classes in SSR for `Transition` component ([#2059](https://github.com/tailwindlabs/headlessui/pull/2059))
1919
- Allow passing in your own `id` prop ([#2060](https://github.com/tailwindlabs/headlessui/pull/2060))
2020
- Fix `Dialog` unmounting problem due to incorrect `transitioncancel` event in the `Transition` component on Android ([#2071](https://github.com/tailwindlabs/headlessui/pull/2071))
21+
- Ignore pointer events in Listbox, Menu, and Combobox when cursor hasn't moved ([#2069](https://github.com/tailwindlabs/headlessui/pull/2069))
2122

2223
## [1.7.4] - 2022-11-03
2324

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-cl
4343
import { Keys } from '../keyboard'
4444
import { useControllable } from '../../hooks/use-controllable'
4545
import { useWatch } from '../../hooks/use-watch'
46+
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
4647

4748
enum ComboboxState {
4849
Open,
@@ -1255,13 +1256,19 @@ let Option = forwardRefWithAs(function Option<
12551256
actions.goToOption(Focus.Specific, id)
12561257
})
12571258

1258-
let handleMove = useEvent(() => {
1259+
let pointer = useTrackedPointer()
1260+
1261+
let handleEnter = useEvent((evt) => pointer.update(evt))
1262+
1263+
let handleMove = useEvent((evt) => {
1264+
if (!pointer.wasMoved(evt)) return
12591265
if (disabled) return
12601266
if (active) return
12611267
actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
12621268
})
12631269

1264-
let handleLeave = useEvent(() => {
1270+
let handleLeave = useEvent((evt) => {
1271+
if (!pointer.wasMoved(evt)) return
12651272
if (disabled) return
12661273
if (!active) return
12671274
if (data.optionsPropsRef.current.hold) return
@@ -1286,6 +1293,8 @@ let Option = forwardRefWithAs(function Option<
12861293
disabled: undefined, // Never forward the `disabled` prop
12871294
onClick: handleClick,
12881295
onFocus: handleFocus,
1296+
onPointerEnter: handleEnter,
1297+
onMouseEnter: handleEnter,
12891298
onPointerMove: handleMove,
12901299
onMouseMove: handleMove,
12911300
onPointerLeave: handleLeave,

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { getOwnerDocument } from '../../utils/owner'
3939
import { useEvent } from '../../hooks/use-event'
4040
import { useControllable } from '../../hooks/use-controllable'
4141
import { useLatestValue } from '../../hooks/use-latest-value'
42+
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
4243

4344
enum ListboxStates {
4445
Open,
@@ -957,13 +958,19 @@ let Option = forwardRefWithAs(function Option<
957958
actions.goToOption(Focus.Specific, id)
958959
})
959960

960-
let handleMove = useEvent(() => {
961+
let pointer = useTrackedPointer()
962+
963+
let handleEnter = useEvent((evt) => pointer.update(evt))
964+
965+
let handleMove = useEvent((evt) => {
966+
if (!pointer.wasMoved(evt)) return
961967
if (disabled) return
962968
if (active) return
963969
actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
964970
})
965971

966-
let handleLeave = useEvent(() => {
972+
let handleLeave = useEvent((evt) => {
973+
if (!pointer.wasMoved(evt)) return
967974
if (disabled) return
968975
if (!active) return
969976
actions.goToOption(Focus.Nothing)
@@ -986,6 +993,8 @@ let Option = forwardRefWithAs(function Option<
986993
disabled: undefined, // Never forward the `disabled` prop
987994
onClick: handleClick,
988995
onFocus: handleFocus,
996+
onPointerEnter: handleEnter,
997+
onMouseEnter: handleEnter,
989998
onPointerMove: handleMove,
990999
onMouseMove: handleMove,
9911000
onPointerLeave: handleLeave,

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-cl
4343
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
4444
import { useOwnerDocument } from '../../hooks/use-owner'
4545
import { useEvent } from '../../hooks/use-event'
46+
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
4647

4748
enum MenuStates {
4849
Open,
@@ -631,7 +632,12 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
631632
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
632633
})
633634

634-
let handleMove = useEvent(() => {
635+
let pointer = useTrackedPointer()
636+
637+
let handleEnter = useEvent((evt) => pointer.update(evt))
638+
639+
let handleMove = useEvent((evt) => {
640+
if (!pointer.wasMoved(evt)) return
635641
if (disabled) return
636642
if (active) return
637643
dispatch({
@@ -642,7 +648,8 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
642648
})
643649
})
644650

645-
let handleLeave = useEvent(() => {
651+
let handleLeave = useEvent((evt) => {
652+
if (!pointer.wasMoved(evt)) return
646653
if (disabled) return
647654
if (!active) return
648655
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
@@ -661,6 +668,8 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
661668
disabled: undefined, // Never forward the `disabled` prop
662669
onClick: handleClick,
663670
onFocus: handleFocus,
671+
onPointerEnter: handleEnter,
672+
onMouseEnter: handleEnter,
664673
onPointerMove: handleMove,
665674
onMouseMove: handleMove,
666675
onPointerLeave: handleLeave,
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { useRef } from 'react'
2+
3+
type PointerPosition = [x: number, y: number]
4+
5+
function eventToPosition(evt: PointerEvent): PointerPosition {
6+
return [evt.screenX, evt.screenY]
7+
}
8+
9+
export function useTrackedPointer() {
10+
let lastPos = useRef<PointerPosition>([-1, -1])
11+
12+
return {
13+
wasMoved(evt: PointerEvent) {
14+
// FIXME: Remove this once we use browser testing in all the relevant places.
15+
// NOTE: This is replaced with a compile-time define during the build process
16+
// This hack exists to work around a few failing tests caused by our inability to "move" the virtual pointer in JSDOM pointer events.
17+
if (process.env.TEST_BYPASS_TRACKED_POINTER) {
18+
return true
19+
}
20+
21+
let newPos = eventToPosition(evt)
22+
23+
if (lastPos.current[0] === newPos[0] && lastPos.current[1] === newPos[1]) {
24+
return false
25+
}
26+
27+
lastPos.current = newPos
28+
return true
29+
},
30+
31+
update(evt: PointerEvent) {
32+
lastPos.current = eventToPosition(evt)
33+
},
34+
}
35+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
export class FakePointer {
2+
private x: number = 0
3+
private y: number = 0
4+
5+
constructor(private width: number, private height: number) {
6+
this.width = width
7+
this.height = height
8+
}
9+
10+
get options() {
11+
return {
12+
screenX: this.x,
13+
screenY: this.y,
14+
}
15+
}
16+
17+
randomize() {
18+
this.x = Math.floor(Math.random() * this.width)
19+
this.y = Math.floor(Math.random() * this.height)
20+
}
21+
22+
advance(amount: number = 1) {
23+
this.x += amount
24+
25+
if (this.x >= this.width) {
26+
this.x %= this.width
27+
this.y++
28+
}
29+
30+
if (this.y >= this.height) {
31+
this.y %= this.height
32+
}
33+
}
34+
35+
/**
36+
* JSDOM does not support pointer events.
37+
* Because of this when we try to set the pointer position it returns undefined so our checks fail.
38+
*
39+
* This runs the callback with the TEST_IGNORE_TRACKED_POINTER environment variable set to 1 so we bypass the checks.
40+
*/
41+
bypassingTrackingChecks(callback: () => void) {
42+
let original = process.env.TEST_BYPASS_TRACKED_POINTER
43+
process.env.TEST_BYPASS_TRACKED_POINTER = '1'
44+
callback()
45+
process.env.TEST_BYPASS_TRACKED_POINTER = original
46+
}
47+
}
48+
49+
/**
50+
* A global pointer for use in pointer and mouse event checks
51+
*/
52+
export let pointer = new FakePointer(1920, 1080)

packages/@headlessui-react/src/test-utils/interactions.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { fireEvent } from '@testing-library/react'
22
import { disposables } from '../utils/disposables'
3+
import { pointer } from './fake-pointer'
34

45
let d = disposables()
56

@@ -318,8 +319,13 @@ export async function mouseMove(element: Document | Element | Window | null) {
318319
try {
319320
if (element === null) return expect(element).not.toBe(null)
320321

321-
fireEvent.pointerMove(element)
322-
fireEvent.mouseMove(element)
322+
pointer.advance()
323+
324+
pointer.bypassingTrackingChecks(() => {
325+
fireEvent.pointerMove(element)
326+
})
327+
328+
fireEvent.mouseMove(element, pointer.options)
323329

324330
await new Promise(nextFrame)
325331
} catch (err) {
@@ -332,10 +338,15 @@ export async function mouseLeave(element: Document | Element | Window | null) {
332338
try {
333339
if (element === null) return expect(element).not.toBe(null)
334340

335-
fireEvent.pointerOut(element)
336-
fireEvent.pointerLeave(element)
337-
fireEvent.mouseOut(element)
338-
fireEvent.mouseLeave(element)
341+
pointer.advance()
342+
343+
pointer.bypassingTrackingChecks(() => {
344+
fireEvent.pointerOut(element)
345+
fireEvent.pointerLeave(element)
346+
})
347+
348+
fireEvent.mouseOut(element, pointer.options)
349+
fireEvent.mouseLeave(element, pointer.options)
339350

340351
await new Promise(nextFrame)
341352
} catch (err) {

packages/@headlessui-vue/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Allow passing in your own `id` prop ([#2060](https://github.com/tailwindlabs/headlessui/pull/2060))
1818
- Add `null` as a valid type for Listbox and Combobox in Vue ([#2064](https://github.com/tailwindlabs/headlessui/pull/2064), [#2067](https://github.com/tailwindlabs/headlessui/pull/2067))
1919
- Improve SSR for Tabs in Vue ([#2068](https://github.com/tailwindlabs/headlessui/pull/2068))
20+
- Ignore pointer events in Listbox, Menu, and Combobox when cursor hasn't moved ([#2069](https://github.com/tailwindlabs/headlessui/pull/2069))
2021

2122
## [1.7.4] - 2022-11-03
2223

packages/@headlessui-vue/src/components/combobox/combobox.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click'
3535
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
3636
import { objectToFormEntries } from '../../utils/form'
3737
import { useControllable } from '../../hooks/use-controllable'
38+
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
3839

3940
function defaultComparator<T>(a: T, z: T): boolean {
4041
return a === z
@@ -1057,13 +1058,21 @@ export let ComboboxOption = defineComponent({
10571058
api.goToOption(Focus.Specific, id)
10581059
}
10591060

1060-
function handleMove() {
1061+
let pointer = useTrackedPointer()
1062+
1063+
function handleEnter(evt: PointerEvent) {
1064+
pointer.update(evt)
1065+
}
1066+
1067+
function handleMove(evt: PointerEvent) {
1068+
if (!pointer.wasMoved(evt)) return
10611069
if (props.disabled) return
10621070
if (active.value) return
10631071
api.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
10641072
}
10651073

1066-
function handleLeave() {
1074+
function handleLeave(evt: PointerEvent) {
1075+
if (!pointer.wasMoved(evt)) return
10671076
if (props.disabled) return
10681077
if (!active.value) return
10691078
if (api.optionsPropsRef.value.hold) return
@@ -1086,6 +1095,8 @@ export let ComboboxOption = defineComponent({
10861095
disabled: undefined, // Never forward the `disabled` prop
10871096
onClick: handleClick,
10881097
onFocus: handleFocus,
1098+
onPointerenter: handleEnter,
1099+
onMouseenter: handleEnter,
10891100
onPointermove: handleMove,
10901101
onMousemove: handleMove,
10911102
onPointerleave: handleLeave,

packages/@headlessui-vue/src/components/listbox/listbox.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click'
3434
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
3535
import { objectToFormEntries } from '../../utils/form'
3636
import { useControllable } from '../../hooks/use-controllable'
37+
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
3738

3839
function defaultComparator<T>(a: T, z: T): boolean {
3940
return a === z
@@ -783,13 +784,21 @@ export let ListboxOption = defineComponent({
783784
api.goToOption(Focus.Specific, props.id)
784785
}
785786

786-
function handleMove() {
787+
let pointer = useTrackedPointer()
788+
789+
function handleEnter(evt: PointerEvent) {
790+
pointer.update(evt)
791+
}
792+
793+
function handleMove(evt: PointerEvent) {
794+
if (!pointer.wasMoved(evt)) return
787795
if (props.disabled) return
788796
if (active.value) return
789797
api.goToOption(Focus.Specific, props.id, ActivationTrigger.Pointer)
790798
}
791799

792-
function handleLeave() {
800+
function handleLeave(evt: PointerEvent) {
801+
if (!pointer.wasMoved(evt)) return
793802
if (props.disabled) return
794803
if (!active.value) return
795804
api.goToOption(Focus.Nothing)
@@ -812,6 +821,8 @@ export let ListboxOption = defineComponent({
812821
disabled: undefined, // Never forward the `disabled` prop
813822
onClick: handleClick,
814823
onFocus: handleFocus,
824+
onPointerenter: handleEnter,
825+
onMouseenter: handleEnter,
815826
onPointermove: handleMove,
816827
onMousemove: handleMove,
817828
onPointerleave: handleLeave,

0 commit comments

Comments
 (0)