Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ignore mouse move/leave events when the cursor hasn’t moved #2069

Merged
merged 12 commits into from Dec 7, 2022
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