diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index d9e8b5041..331e8d38f 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index ea2309838..594b95604 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -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, diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 6311113af..a5cacb515 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -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, diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index b94fbdaf4..2e2848fbb 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -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 { + 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 { + 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([-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) + }, + } +} diff --git a/packages/@headlessui-react/src/test-utils/fake-pointer.ts b/packages/@headlessui-react/src/test-utils/fake-pointer.ts new file mode 100644 index 000000000..62dabd6a6 --- /dev/null +++ b/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) diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index 2a71b9656..22db647ba 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/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() @@ -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) { diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index d31bb3cba..a44981e4c 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 8805957e0..2a9bf8380 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -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(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, diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index 48fb4df82..f65714463 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -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(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, diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index ad3347cc6..8f87562c1 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -716,8 +716,10 @@ describe('Rendering', () => { ' - id', ' - onClick', ' - onFocus', + ' - onMouseenter', ' - onMouseleave', ' - onMousemove', + ' - onPointerenter', ' - onPointerleave', ' - onPointermove', ' - ref', diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index b58dce9df..0a703bb96 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -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, diff --git a/packages/@headlessui-vue/src/hooks/use-tracked-pointer.ts b/packages/@headlessui-vue/src/hooks/use-tracked-pointer.ts new file mode 100644 index 000000000..7313656ce --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-tracked-pointer.ts @@ -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([-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) + }, + } +} diff --git a/packages/@headlessui-vue/src/test-utils/fake-pointer.ts b/packages/@headlessui-vue/src/test-utils/fake-pointer.ts new file mode 100644 index 000000000..62dabd6a6 --- /dev/null +++ b/packages/@headlessui-vue/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) diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index 01c7a3d7a..35bbd2d60 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -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) { diff --git a/scripts/build.sh b/scripts/build.sh index 4e26a45e4..7e20bc892 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -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 &