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 - + )} )