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
This commit is contained in:
Jordan Pittman
2022-12-07 13:50:57 -05:00
committed by GitHub
parent a6dea8af4b
commit 2e941f85dd
16 changed files with 293 additions and 31 deletions
+1
View File
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Apply `enter` and `enterFrom` classes in SSR for `Transition` component ([#2059](https://github.com/tailwindlabs/headlessui/pull/2059))
- Allow passing in your own `id` prop ([#2060](https://github.com/tailwindlabs/headlessui/pull/2060))
- Fix `Dialog` unmounting problem due to incorrect `transitioncancel` event in the `Transition` component on Android ([#2071](https://github.com/tailwindlabs/headlessui/pull/2071))
- Ignore pointer events in Listbox, Menu, and Combobox when cursor hasn't moved ([#2069](https://github.com/tailwindlabs/headlessui/pull/2069))
## [1.7.4] - 2022-11-03
@@ -43,6 +43,7 @@ import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-cl
import { Keys } from '../keyboard'
import { useControllable } from '../../hooks/use-controllable'
import { useWatch } from '../../hooks/use-watch'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
enum ComboboxState {
Open,
@@ -1255,13 +1256,19 @@ let Option = forwardRefWithAs(function Option<
actions.goToOption(Focus.Specific, id)
})
let handleMove = useEvent(() => {
let pointer = useTrackedPointer()
let handleEnter = useEvent((evt) => pointer.update(evt))
let handleMove = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (active) return
actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
})
let handleLeave = useEvent(() => {
let handleLeave = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (!active) return
if (data.optionsPropsRef.current.hold) return
@@ -1286,6 +1293,8 @@ let Option = forwardRefWithAs(function Option<
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerEnter: handleEnter,
onMouseEnter: handleEnter,
onPointerMove: handleMove,
onMouseMove: handleMove,
onPointerLeave: handleLeave,
@@ -39,6 +39,7 @@ import { getOwnerDocument } from '../../utils/owner'
import { useEvent } from '../../hooks/use-event'
import { useControllable } from '../../hooks/use-controllable'
import { useLatestValue } from '../../hooks/use-latest-value'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
enum ListboxStates {
Open,
@@ -957,13 +958,19 @@ let Option = forwardRefWithAs(function Option<
actions.goToOption(Focus.Specific, id)
})
let handleMove = useEvent(() => {
let pointer = useTrackedPointer()
let handleEnter = useEvent((evt) => pointer.update(evt))
let handleMove = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (active) return
actions.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
})
let handleLeave = useEvent(() => {
let handleLeave = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (!active) return
actions.goToOption(Focus.Nothing)
@@ -986,6 +993,8 @@ let Option = forwardRefWithAs(function Option<
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerEnter: handleEnter,
onMouseEnter: handleEnter,
onPointerMove: handleMove,
onMouseMove: handleMove,
onPointerLeave: handleLeave,
@@ -43,6 +43,7 @@ import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-cl
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
import { useOwnerDocument } from '../../hooks/use-owner'
import { useEvent } from '../../hooks/use-event'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
enum MenuStates {
Open,
@@ -631,7 +632,12 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Specific, id })
})
let handleMove = useEvent(() => {
let pointer = useTrackedPointer()
let handleEnter = useEvent((evt) => pointer.update(evt))
let handleMove = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (active) return
dispatch({
@@ -642,7 +648,8 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
})
})
let handleLeave = useEvent(() => {
let handleLeave = useEvent((evt) => {
if (!pointer.wasMoved(evt)) return
if (disabled) return
if (!active) return
dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing })
@@ -661,6 +668,8 @@ let Item = forwardRefWithAs(function Item<TTag extends ElementType = typeof DEFA
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerEnter: handleEnter,
onMouseEnter: handleEnter,
onPointerMove: handleMove,
onMouseMove: handleMove,
onPointerLeave: handleLeave,
@@ -0,0 +1,35 @@
import { useRef } from 'react'
type PointerPosition = [x: number, y: number]
function eventToPosition(evt: PointerEvent): PointerPosition {
return [evt.screenX, evt.screenY]
}
export function useTrackedPointer() {
let lastPos = useRef<PointerPosition>([-1, -1])
return {
wasMoved(evt: PointerEvent) {
// FIXME: Remove this once we use browser testing in all the relevant places.
// NOTE: This is replaced with a compile-time define during the build process
// This hack exists to work around a few failing tests caused by our inability to "move" the virtual pointer in JSDOM pointer events.
if (process.env.TEST_BYPASS_TRACKED_POINTER) {
return true
}
let newPos = eventToPosition(evt)
if (lastPos.current[0] === newPos[0] && lastPos.current[1] === newPos[1]) {
return false
}
lastPos.current = newPos
return true
},
update(evt: PointerEvent) {
lastPos.current = eventToPosition(evt)
},
}
}
@@ -0,0 +1,52 @@
export class FakePointer {
private x: number = 0
private y: number = 0
constructor(private width: number, private height: number) {
this.width = width
this.height = height
}
get options() {
return {
screenX: this.x,
screenY: this.y,
}
}
randomize() {
this.x = Math.floor(Math.random() * this.width)
this.y = Math.floor(Math.random() * this.height)
}
advance(amount: number = 1) {
this.x += amount
if (this.x >= this.width) {
this.x %= this.width
this.y++
}
if (this.y >= this.height) {
this.y %= this.height
}
}
/**
* JSDOM does not support pointer events.
* Because of this when we try to set the pointer position it returns undefined so our checks fail.
*
* This runs the callback with the TEST_IGNORE_TRACKED_POINTER environment variable set to 1 so we bypass the checks.
*/
bypassingTrackingChecks(callback: () => void) {
let original = process.env.TEST_BYPASS_TRACKED_POINTER
process.env.TEST_BYPASS_TRACKED_POINTER = '1'
callback()
process.env.TEST_BYPASS_TRACKED_POINTER = original
}
}
/**
* A global pointer for use in pointer and mouse event checks
*/
export let pointer = new FakePointer(1920, 1080)
@@ -1,5 +1,6 @@
import { fireEvent } from '@testing-library/react'
import { disposables } from '../utils/disposables'
import { pointer } from './fake-pointer'
let d = disposables()
@@ -318,8 +319,13 @@ export async function mouseMove(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
fireEvent.pointerMove(element)
fireEvent.mouseMove(element)
pointer.advance()
pointer.bypassingTrackingChecks(() => {
fireEvent.pointerMove(element)
})
fireEvent.mouseMove(element, pointer.options)
await new Promise(nextFrame)
} catch (err) {
@@ -332,10 +338,15 @@ export async function mouseLeave(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
fireEvent.pointerOut(element)
fireEvent.pointerLeave(element)
fireEvent.mouseOut(element)
fireEvent.mouseLeave(element)
pointer.advance()
pointer.bypassingTrackingChecks(() => {
fireEvent.pointerOut(element)
fireEvent.pointerLeave(element)
})
fireEvent.mouseOut(element, pointer.options)
fireEvent.mouseLeave(element, pointer.options)
await new Promise(nextFrame)
} catch (err) {
+1
View File
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Allow passing in your own `id` prop ([#2060](https://github.com/tailwindlabs/headlessui/pull/2060))
- 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))
- Improve SSR for Tabs in Vue ([#2068](https://github.com/tailwindlabs/headlessui/pull/2068))
- Ignore pointer events in Listbox, Menu, and Combobox when cursor hasn't moved ([#2069](https://github.com/tailwindlabs/headlessui/pull/2069))
## [1.7.4] - 2022-11-03
@@ -35,6 +35,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { objectToFormEntries } from '../../utils/form'
import { useControllable } from '../../hooks/use-controllable'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
function defaultComparator<T>(a: T, z: T): boolean {
return a === z
@@ -1057,13 +1058,21 @@ export let ComboboxOption = defineComponent({
api.goToOption(Focus.Specific, id)
}
function handleMove() {
let pointer = useTrackedPointer()
function handleEnter(evt: PointerEvent) {
pointer.update(evt)
}
function handleMove(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (active.value) return
api.goToOption(Focus.Specific, id, ActivationTrigger.Pointer)
}
function handleLeave() {
function handleLeave(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (!active.value) return
if (api.optionsPropsRef.value.hold) return
@@ -1086,6 +1095,8 @@ export let ComboboxOption = defineComponent({
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerenter: handleEnter,
onMouseenter: handleEnter,
onPointermove: handleMove,
onMousemove: handleMove,
onPointerleave: handleLeave,
@@ -34,6 +34,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click'
import { Hidden, Features as HiddenFeatures } from '../../internal/hidden'
import { objectToFormEntries } from '../../utils/form'
import { useControllable } from '../../hooks/use-controllable'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
function defaultComparator<T>(a: T, z: T): boolean {
return a === z
@@ -783,13 +784,21 @@ export let ListboxOption = defineComponent({
api.goToOption(Focus.Specific, props.id)
}
function handleMove() {
let pointer = useTrackedPointer()
function handleEnter(evt: PointerEvent) {
pointer.update(evt)
}
function handleMove(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (active.value) return
api.goToOption(Focus.Specific, props.id, ActivationTrigger.Pointer)
}
function handleLeave() {
function handleLeave(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (!active.value) return
api.goToOption(Focus.Nothing)
@@ -812,6 +821,8 @@ export let ListboxOption = defineComponent({
disabled: undefined, // Never forward the `disabled` prop
onClick: handleClick,
onFocus: handleFocus,
onPointerenter: handleEnter,
onMouseenter: handleEnter,
onPointermove: handleMove,
onMousemove: handleMove,
onPointerleave: handleLeave,
@@ -716,8 +716,10 @@ describe('Rendering', () => {
' - id',
' - onClick',
' - onFocus',
' - onMouseenter',
' - onMouseleave',
' - onMousemove',
' - onPointerenter',
' - onPointerleave',
' - onPointermove',
' - ref',
@@ -31,6 +31,7 @@ import {
restoreFocusIfNecessary,
} from '../../utils/focus-management'
import { useOutsideClick } from '../../hooks/use-outside-click'
import { useTrackedPointer } from '../../hooks/use-tracked-pointer'
enum MenuStates {
Open,
@@ -540,13 +541,21 @@ export let MenuItem = defineComponent({
api.goToItem(Focus.Specific, props.id)
}
function handleMove() {
let pointer = useTrackedPointer()
function handleEnter(evt: PointerEvent) {
pointer.update(evt)
}
function handleMove(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (active.value) return
api.goToItem(Focus.Specific, props.id, ActivationTrigger.Pointer)
}
function handleLeave() {
function handleLeave(evt: PointerEvent) {
if (!pointer.wasMoved(evt)) return
if (props.disabled) return
if (!active.value) return
api.goToItem(Focus.Nothing)
@@ -564,6 +573,8 @@ export let MenuItem = defineComponent({
'aria-disabled': disabled === true ? true : undefined,
onClick: handleClick,
onFocus: handleFocus,
onPointerenter: handleEnter,
onMouseenter: handleEnter,
onPointermove: handleMove,
onMousemove: handleMove,
onPointerleave: handleLeave,
@@ -0,0 +1,35 @@
import { ref } from 'vue'
type PointerPosition = [x: number, y: number]
function eventToPosition(evt: PointerEvent): PointerPosition {
return [evt.screenX, evt.screenY]
}
export function useTrackedPointer() {
let lastPos = ref<PointerPosition>([-1, -1])
return {
wasMoved(evt: PointerEvent) {
// FIXME: Remove this once we use browser testing in all the relevant places.
// NOTE: This is replaced with a compile-time define during the build process
// This hack exists to work around a few failing tests caused by our inability to "move" the virtual pointer in JSDOM pointer events.
if (process.env.TEST_BYPASS_TRACKED_POINTER) {
return true
}
let newPos = eventToPosition(evt)
if (lastPos.value[0] === newPos[0] && lastPos.value[1] === newPos[1]) {
return false
}
lastPos.value = newPos
return true
},
update(evt: PointerEvent) {
lastPos.value = eventToPosition(evt)
},
}
}
@@ -0,0 +1,52 @@
export class FakePointer {
private x: number = 0
private y: number = 0
constructor(private width: number, private height: number) {
this.width = width
this.height = height
}
get options() {
return {
screenX: this.x,
screenY: this.y,
}
}
randomize() {
this.x = Math.floor(Math.random() * this.width)
this.y = Math.floor(Math.random() * this.height)
}
advance(amount: number = 1) {
this.x += amount
if (this.x >= this.width) {
this.x %= this.width
this.y++
}
if (this.y >= this.height) {
this.y %= this.height
}
}
/**
* JSDOM does not support pointer events.
* Because of this when we try to set the pointer position it returns undefined so our checks fail.
*
* This runs the callback with the TEST_IGNORE_TRACKED_POINTER environment variable set to 1 so we bypass the checks.
*/
bypassingTrackingChecks(callback: () => void) {
let original = process.env.TEST_BYPASS_TRACKED_POINTER
process.env.TEST_BYPASS_TRACKED_POINTER = '1'
callback()
process.env.TEST_BYPASS_TRACKED_POINTER = original
}
}
/**
* A global pointer for use in pointer and mouse event checks
*/
export let pointer = new FakePointer(1920, 1080)
@@ -1,5 +1,6 @@
import { fireEvent } from '@testing-library/dom'
import { disposables } from '../utils/disposables'
import { pointer } from './fake-pointer'
let d = disposables()
@@ -297,9 +298,11 @@ export async function mouseEnter(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
fireEvent.pointerOver(element)
fireEvent.pointerEnter(element)
fireEvent.mouseOver(element)
pointer.randomize()
fireEvent.pointerOver(element, pointer.options)
fireEvent.pointerEnter(element, pointer.options)
fireEvent.mouseOver(element, pointer.options)
await new Promise(nextFrame)
} catch (err) {
@@ -312,8 +315,13 @@ export async function mouseMove(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
fireEvent.pointerMove(element)
fireEvent.mouseMove(element)
pointer.advance()
pointer.bypassingTrackingChecks(() => {
fireEvent.pointerMove(element)
})
fireEvent.mouseMove(element, pointer.options)
await new Promise(nextFrame)
} catch (err) {
@@ -326,10 +334,15 @@ export async function mouseLeave(element: Document | Element | Window | null) {
try {
if (element === null) return expect(element).not.toBe(null)
fireEvent.pointerOut(element)
fireEvent.pointerLeave(element)
fireEvent.mouseOut(element)
fireEvent.mouseLeave(element)
pointer.advance()
pointer.bypassingTrackingChecks(() => {
fireEvent.pointerOut(element)
fireEvent.pointerLeave(element)
})
fireEvent.mouseOut(element, pointer.options)
fireEvent.mouseLeave(element, pointer.options)
await new Promise(nextFrame)
} catch (err) {
+4 -4
View File
@@ -28,12 +28,12 @@ resolverOptions+=('/**/*.{ts,tsx}')
resolverOptions+=('--ignore=.test.,__mocks__')
INPUT_FILES=$($resolver ${resolverOptions[@]})
NODE_ENV=production $esbuild $INPUT_FILES --format=esm --outdir=$DST --outbase=$SRC --minify --pure:React.createElement ${sharedOptions[@]} &
NODE_ENV=production $esbuild $input --format=esm --outfile=$DST/$name.esm.js --outbase=$SRC --minify --pure:React.createElement ${sharedOptions[@]} &
NODE_ENV=production $esbuild $INPUT_FILES --format=esm --outdir=$DST --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} &
NODE_ENV=production $esbuild $input --format=esm --outfile=$DST/$name.esm.js --outbase=$SRC --minify --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} &
# Common JS
NODE_ENV=production $esbuild $input --format=cjs --outfile=$DST/$name.prod.cjs --minify --bundle --pure:React.createElement ${sharedOptions[@]} $@ &
NODE_ENV=development $esbuild $input --format=cjs --outfile=$DST/$name.dev.cjs --bundle --pure:React.createElement ${sharedOptions[@]} $@ &
NODE_ENV=production $esbuild $input --format=cjs --outfile=$DST/$name.prod.cjs --minify --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} $@ &
NODE_ENV=development $esbuild $input --format=cjs --outfile=$DST/$name.dev.cjs --bundle --pure:React.createElement --define:process.env.TEST_BYPASS_TRACKED_POINTER="false" ${sharedOptions[@]} $@ &
# Generate types
tsc --emitDeclarationOnly --outDir $DST &