Skip to content

Commit

Permalink
Ignore mouse move/leave events when the cursor hasn’t moved (#2069)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
thecrypticace committed Dec 7, 2022
1 parent a6dea8a commit 2e941f8
Show file tree
Hide file tree
Showing 16 changed files with 293 additions and 31 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions packages/@headlessui-react/src/components/listbox/listbox.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions packages/@headlessui-react/src/components/menu/menu.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -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 })
Expand All @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions packages/@headlessui-react/src/hooks/use-tracked-pointer.ts
@@ -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)
},
}
}
52 changes: 52 additions & 0 deletions packages/@headlessui-react/src/test-utils/fake-pointer.ts
@@ -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)
23 changes: 17 additions & 6 deletions packages/@headlessui-react/src/test-utils/interactions.ts
@@ -1,5 +1,6 @@
import { fireEvent } from '@testing-library/react'
import { disposables } from '../utils/disposables'
import { pointer } from './fake-pointer'

let d = disposables()

Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Expand Up @@ -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

Expand Down
15 changes: 13 additions & 2 deletions packages/@headlessui-vue/src/components/combobox/combobox.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions packages/@headlessui-vue/src/components/listbox/listbox.ts
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/@headlessui-vue/src/components/menu/menu.test.tsx
Expand Up @@ -716,8 +716,10 @@ describe('Rendering', () => {
' - id',
' - onClick',
' - onFocus',
' - onMouseenter',
' - onMouseleave',
' - onMousemove',
' - onPointerenter',
' - onPointerleave',
' - onPointermove',
' - ref',
Expand Down

0 comments on commit 2e941f8

Please sign in to comment.