From 1461b658105cd97a69eed163c9de6b1037ab96a5 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 24 Apr 2025 16:01:38 +0200 Subject: [PATCH] Add a quick trigger action to the `Menu`, `Listbox` and `Combobox` components (#3700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a new quick trigger feature to the `Menu`. Not sure what the best name for this is, but essentially this is the behavior: Recently we made sure that the `Menu` opens on `mousedown` (not just `click`). This means that we can perform the following quick action: 1. `mousedown` on the `MenuButton` — this will open the `Menu` 2. Without releasing the mouse button yet, move your mouse over one of the `MenuItem`s — this will highlight the currently active `MenuItem`. 3. Release the mouse button — this will invoke the currently active `MenuItem` and close the `Menu`. This now means that you can perform actions very quickly. What this PR doesn't do yet is if you have a scrollable list, then it won't scroll up or down when you reach the ends of the list. For this we would need to introduce some new elements. The native Menu items on macOS show a little placeholder arrow. If you put your cursor in that area, it starts scrolling: image ## Test plan 1. Everything still works as expected 2. Quick release has been added: - Listbox: https://headlessui-react-git-feat-quick-trigger-tailwindlabs.vercel.app/listbox/listbox-with-pure-tailwind - Menu: https://headlessui-react-git-feat-quick-trigger-tailwindlabs.vercel.app/menu/menu - Combobox: https://headlessui-react-git-feat-quick-trigger-tailwindlabs.vercel.app/combobox/combobox-countries --- jest/polyfills.ts | 15 +++ packages/@headlessui-react/CHANGELOG.md | 4 +- .../src/components/combobox/combobox.tsx | 51 ++++++++-- .../src/components/listbox/listbox.tsx | 45 +++++++-- .../src/components/menu/menu.tsx | 48 ++++++++-- .../src/hooks/use-quick-release.ts | 96 +++++++++++++++++++ packages/@headlessui-react/src/utils/dom.ts | 21 ++++ playgrounds/react/pages/menu/menu.tsx | 8 +- 8 files changed, 255 insertions(+), 33 deletions(-) create mode 100644 packages/@headlessui-react/src/hooks/use-quick-release.ts create mode 100644 packages/@headlessui-react/src/utils/dom.ts diff --git a/jest/polyfills.ts b/jest/polyfills.ts index 6fd55eb..620b8c1 100644 --- a/jest/polyfills.ts +++ b/jest/polyfills.ts @@ -18,3 +18,18 @@ Object.defineProperty(HTMLElement.prototype, 'innerText', { this.textContent = value }, }) + +// Source: https://github.com/testing-library/react-testing-library/issues/838#issuecomment-735259406 +// +// Polyfill the PointerEvent class for JSDOM +class PointerEvent extends Event { + constructor(type, props) { + super(type, props) + if (props.button != null) { + // @ts-expect-error JSDOM doesn't support `button` yet... + this.button = props.button + } + } +} +// @ts-expect-error JSDOM doesn't support `PointerEvent` yet... +window.PointerEvent = PointerEvent diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 52ced91..86e612f 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Added + +- Add a quick trigger action to the `Menu`, `Listbox` and `Combobox` components ([#3700](https://github.com/tailwindlabs/headlessui/pull/3700)) ## [2.2.2] - 2025-04-17 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index f192364..3bb115f 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -17,6 +17,7 @@ import React, { type FocusEvent as ReactFocusEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type Ref, } from 'react' import { flushSync } from 'react-dom' @@ -34,6 +35,7 @@ import { useLatestValue } from '../../hooks/use-latest-value' import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOutsideClick } from '../../hooks/use-outside-click' import { useOwnerDocument } from '../../hooks/use-owner' +import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release' import { useRefocusableInput } from '../../hooks/use-refocusable-input' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useScrollLock } from '../../hooks/use-scroll-lock' @@ -989,9 +991,43 @@ function ButtonFn( ...theirProps } = props - let inputElement = useSlice(machine, (state) => state.inputElement) + let [comboboxState, inputElement, optionsElement] = useSlice(machine, (state) => [ + state.comboboxState, + state.inputElement, + state.optionsElement, + ]) let refocusInput = useRefocusableInput(inputElement) + let enableQuickRelease = comboboxState === ComboboxState.Open + useQuickRelease(enableQuickRelease, { + trigger: localButtonElement, + action: useCallback( + (e) => { + if (localButtonElement?.contains(e.target)) { + return QuickReleaseAction.Ignore + } + + if (inputElement?.contains(e.target)) { + return QuickReleaseAction.Ignore + } + + let option = e.target.closest('[role="option"]:not([data-disabled])') + if (option !== null) { + return QuickReleaseAction.Select(option as HTMLElement) + } + + if (optionsElement?.contains(e.target)) { + return QuickReleaseAction.Ignore + } + + return QuickReleaseAction.Close + }, + [localButtonElement, inputElement, optionsElement] + ), + close: machine.actions.closeCombobox, + select: machine.actions.selectActiveOption, + }) + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12 @@ -1044,9 +1080,9 @@ function ButtonFn( } }) - let handleMouseDown = useEvent((event: ReactMouseEvent) => { - // We use the `mousedown` event here since it fires before the focus event, - // allowing us to cancel the event before focus is moved from the + let handlePointerDown = useEvent((event: ReactPointerEvent) => { + // We use the `poitnerdown` event here since it fires before the focus + // event, allowing us to cancel the event before focus is moved from the // `ComboboxInput` to the `ComboboxButton`. This keeps the input focused, // preserving the cursor position and any text selection. event.preventDefault() @@ -1074,11 +1110,6 @@ function ButtonFn( let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let { pressed: active, pressProps } = useActivePress({ disabled }) - let [comboboxState, optionsElement] = useSlice(machine, (state) => [ - state.comboboxState, - state.optionsElement, - ]) - let slot = useMemo(() => { return { open: comboboxState === ComboboxState.Open, @@ -1102,7 +1133,7 @@ function ButtonFn( 'aria-labelledby': labelledBy, disabled: disabled || undefined, autoFocus, - onMouseDown: handleMouseDown, + onPointerDown: handlePointerDown, onKeyDown: handleKeyDown, }, focusProps, diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 6384a2e..78f9c3a 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -15,7 +15,7 @@ import React, { type ElementType, type MutableRefObject, type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type Ref, } from 'react' import { flushSync } from 'react-dom' @@ -34,6 +34,7 @@ import { useLatestValue } from '../../hooks/use-latest-value' import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOutsideClick } from '../../hooks/use-outside-click' import { useOwnerDocument } from '../../hooks/use-owner' +import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useScrollLock } from '../../hooks/use-scroll-lock' import { useSyncRefs } from '../../hooks/use-sync-refs' @@ -359,6 +360,38 @@ function ButtonFn( let buttonRef = useSyncRefs(ref, useFloatingReference(), machine.actions.setButtonElement) let getFloatingReferenceProps = useFloatingReferenceProps() + let [listboxState, buttonElement, optionsElement] = useSlice(machine, (state) => [ + state.listboxState, + state.buttonElement, + state.optionsElement, + ]) + + let enableQuickRelease = listboxState === ListboxStates.Open + useQuickRelease(enableQuickRelease, { + trigger: buttonElement, + action: useCallback( + (e) => { + if (buttonElement?.contains(e.target)) { + return QuickReleaseAction.Ignore + } + + let option = e.target.closest('[role="option"]:not([data-disabled])') + if (option !== null) { + return QuickReleaseAction.Select(option as HTMLElement) + } + + if (optionsElement?.contains(e.target)) { + return QuickReleaseAction.Ignore + } + + return QuickReleaseAction.Close + }, + [buttonElement, optionsElement] + ), + close: machine.actions.closeListbox, + select: machine.actions.selectActiveOption, + }) + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { switch (event.key) { // Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/#keyboard-interaction-13 @@ -393,7 +426,7 @@ function ButtonFn( } }) - let handleMouseDown = useEvent((event: ReactMouseEvent) => { + let handlePointerDown = useEvent((event: ReactPointerEvent) => { if (event.button !== 0) return // Only handle left clicks if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (machine.state.listboxState === ListboxStates.Open) { @@ -415,8 +448,6 @@ function ButtonFn( let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) let { pressed: active, pressProps } = useActivePress({ disabled }) - let listboxState = useSlice(machine, (state) => state.listboxState) - let slot = useMemo(() => { return { open: listboxState === ListboxStates.Open, @@ -431,10 +462,6 @@ function ButtonFn( }, [listboxState, data.value, disabled, hover, focus, active, data.invalid, autoFocus]) let open = useSlice(machine, (state) => state.listboxState === ListboxStates.Open) - let [buttonElement, optionsElement] = useSlice(machine, (state) => [ - state.buttonElement, - state.optionsElement, - ]) let ourProps = mergeProps( getFloatingReferenceProps(), { @@ -451,7 +478,7 @@ function ButtonFn( onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, onKeyPress: handleKeyPress, - onMouseDown: handleMouseDown, + onPointerDown: handlePointerDown, }, focusProps, hoverProps, diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 29d0acf..4ad25b5 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -13,7 +13,7 @@ import React, { type CSSProperties, type ElementType, type KeyboardEvent as ReactKeyboardEvent, - type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, type Ref, } from 'react' import { flushSync } from 'react-dom' @@ -28,6 +28,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useOnDisappear } from '../../hooks/use-on-disappear' import { useOutsideClick } from '../../hooks/use-outside-click' import { useOwnerDocument } from '../../hooks/use-owner' +import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release' import { useResolveButtonType } from '../../hooks/use-resolve-button-type' import { useScrollLock } from '../../hooks/use-scroll-lock' import { useSyncRefs } from '../../hooks/use-sync-refs' @@ -224,12 +225,39 @@ function ButtonFn( } }) - let [menuState, itemsElement] = useSlice(machine, (state) => [ + let [menuState, buttonElement, itemsElement] = useSlice(machine, (state) => [ state.menuState, + state.buttonElement, state.itemsElement, ]) - let handleMouseDown = useEvent((event: ReactMouseEvent) => { + let enableQuickRelease = menuState === MenuState.Open + useQuickRelease(enableQuickRelease, { + trigger: buttonElement, + action: useCallback( + (e) => { + if (buttonElement?.contains(e.target)) { + return QuickReleaseAction.Ignore + } + + let item = e.target.closest('[role="menuitem"]:not([data-disabled])') + if (item !== null) { + return QuickReleaseAction.Select(item as HTMLElement) + } + + if (itemsElement?.contains(e.target)) { + return QuickReleaseAction.Ignore + } + + return QuickReleaseAction.Close + }, + [buttonElement, itemsElement] + ), + close: useCallback(() => machine.send({ type: ActionTypes.CloseMenu }), []), + select: useCallback((target) => target.click(), []), + }) + + let handlePointerDown = useEvent((event: ReactPointerEvent) => { if (event.button !== 0) return // Only handle left clicks if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() if (disabled) return @@ -274,7 +302,7 @@ function ButtonFn( autoFocus, onKeyDown: handleKeyDown, onKeyUp: handleKeyUp, - onMouseDown: handleMouseDown, + onPointerDown: handlePointerDown, }, focusProps, hoverProps, @@ -640,8 +668,8 @@ function ItemFn( let pointer = useTrackedPointer() - let handleEnter = useEvent((evt) => { - pointer.update(evt) + let handleEnter = useEvent((event) => { + pointer.update(event) if (disabled) return if (active) return machine.send({ @@ -652,8 +680,8 @@ function ItemFn( }) }) - let handleMove = useEvent((evt) => { - if (!pointer.wasMoved(evt)) return + let handleMove = useEvent((event) => { + if (!pointer.wasMoved(event)) return if (disabled) return if (active) return machine.send({ @@ -664,8 +692,8 @@ function ItemFn( }) }) - let handleLeave = useEvent((evt) => { - if (!pointer.wasMoved(evt)) return + let handleLeave = useEvent((event) => { + if (!pointer.wasMoved(event)) return if (disabled) return if (!active) return machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) diff --git a/packages/@headlessui-react/src/hooks/use-quick-release.ts b/packages/@headlessui-react/src/hooks/use-quick-release.ts new file mode 100644 index 0000000..2b99591 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-quick-release.ts @@ -0,0 +1,96 @@ +import { useRef } from 'react' +import * as DOM from '../utils/dom' +import { useDocumentEvent } from './use-document-event' + +enum ActionKind { + Ignore, + Select, + Close, +} + +export const Action = { + /** Do nothing */ + Ignore: { kind: ActionKind.Ignore } as const, + + /** Select the current item */ + Select: (target: HTMLElement) => ({ kind: ActionKind.Select, target }) as const, + + /** Close the dropdown */ + Close: { kind: ActionKind.Close } as const, +} + +// If the time difference between pointerdown and pointerup is less than this, +// it is very likely a normal click, and nothing special should happen. +// +// Once we reach this threshold, we assume the user is trying to select an item +// in the dropdown, and we should close the dropdown after the click. +// +// Pointerdown -> drag over an item -> pointer up -> "click" on the item +const POINTER_HOLD_THRESHOLD = 200 + +type PointerEventWithTarget = Exclude & { + target: HTMLElement +} + +export function useQuickRelease( + enabled: boolean, + { + trigger, + action, + close, + select, + }: { + trigger: HTMLElement | null + action: ( + e: PointerEventWithTarget + ) => + | { kind: ActionKind.Ignore } + | { kind: ActionKind.Select; target: HTMLElement } + | { kind: ActionKind.Close } + close: () => void + select: (target: HTMLElement) => void + } +) { + // Capture the timestamp of when the `pointerdown` event happened on the + // trigger. + let triggeredAtRef = useRef(null) + useDocumentEvent(enabled && trigger !== null, 'pointerdown', (e) => { + if (!DOM.isNode(e?.target)) return + if (!trigger?.contains(e.target)) return + + triggeredAtRef.current = new Date() + }) + + useDocumentEvent( + enabled && trigger !== null, + 'pointerup', + (e) => { + if (triggeredAtRef.current === null) return + if (!DOM.isHTMLElement(e.target)) return + + let result = action(e as PointerEventWithTarget) + + let diffInMs = new Date().getTime() - triggeredAtRef.current.getTime() + triggeredAtRef.current = null + + switch (result.kind) { + case ActionKind.Ignore: + return + + case ActionKind.Select: { + if (diffInMs > POINTER_HOLD_THRESHOLD) { + select(result.target) + close() + } + break + } + + case ActionKind.Close: { + close() + break + } + } + }, + { capture: true } + ) +} diff --git a/packages/@headlessui-react/src/utils/dom.ts b/packages/@headlessui-react/src/utils/dom.ts new file mode 100644 index 0000000..9e44891 --- /dev/null +++ b/packages/@headlessui-react/src/utils/dom.ts @@ -0,0 +1,21 @@ +// This file contains a bunch of utilities to verify that an element is of a +// specific type. +// +// Normally you can use `elemenent instanceof HTMLElement`, but if you are in +// different JS Context (e.g.: inside an iframe) then the `HTMLElement` will be +// a different class and the check will fail. +// +// Instead, we will check for certain properties to determine if the element +// is of a specific type. + +export function isNode(element: unknown): element is Node { + if (typeof element !== 'object') return false + if (element === null) return false + return 'nodeType' in element && 'nodeName' in element +} + +export function isHTMLElement(element: unknown): element is HTMLElement { + if (typeof element !== 'object') return false + if (element === null) return false + return 'nodeName' in element +} diff --git a/playgrounds/react/pages/menu/menu.tsx b/playgrounds/react/pages/menu/menu.tsx index 627da06..2f5fb23 100644 --- a/playgrounds/react/pages/menu/menu.tsx +++ b/playgrounds/react/pages/menu/menu.tsx @@ -55,8 +55,10 @@ function CustomMenuItem(props) { return ( {({ active, disabled }) => ( - { + alert(`You clicked on ${props.href}`) + }} className={classNames( 'flex w-full justify-between px-4 py-2 text-left text-sm leading-5', active ? 'bg-indigo-500 text-white' : 'text-gray-700', @@ -65,7 +67,7 @@ function CustomMenuItem(props) { > {props.children} ⌘K - + )} )