Add a quick trigger action to the Menu, Listbox and Combobox components (#3700)

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:

<img width="489" alt="image"
src="https://github.com/user-attachments/assets/e3a90d5a-daa7-4711-9e19-050578be3e02"
/>


## 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
This commit is contained in:
Robin Malfait
2025-04-24 16:01:38 +02:00
committed by GitHub
parent 730ab68345
commit 1461b65810
8 changed files with 255 additions and 33 deletions
+15
View File
@@ -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
+3 -1
View File
@@ -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
@@ -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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
...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<HTMLElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menu/#keyboard-interaction-12
@@ -1044,9 +1080,9 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})
let handleMouseDown = useEvent((event: ReactMouseEvent<HTMLButtonElement>) => {
// 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<HTMLButtonElement>) => {
// 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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
'aria-labelledby': labelledBy,
disabled: disabled || undefined,
autoFocus,
onMouseDown: handleMouseDown,
onPointerDown: handlePointerDown,
onKeyDown: handleKeyDown,
},
focusProps,
@@ -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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
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<HTMLButtonElement>) => {
switch (event.key) {
// Ref: https://www.w3.org/WAI/ARIA/apg/patterns/menubutton/#keyboard-interaction-13
@@ -393,7 +426,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}, [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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onKeyPress: handleKeyPress,
onMouseDown: handleMouseDown,
onPointerDown: handlePointerDown,
},
focusProps,
hoverProps,
@@ -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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
}
})
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<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
autoFocus,
onKeyDown: handleKeyDown,
onKeyUp: handleKeyUp,
onMouseDown: handleMouseDown,
onPointerDown: handlePointerDown,
},
focusProps,
hoverProps,
@@ -640,8 +668,8 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
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<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
})
})
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<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
})
})
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 })
@@ -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<PointerEvent, 'target'> & {
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<Date | null>(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 }
)
}
@@ -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
}
+5 -3
View File
@@ -55,8 +55,10 @@ function CustomMenuItem(props) {
return (
<Menu.Item {...props}>
{({ active, disabled }) => (
<a
href={props.href}
<button
onClick={() => {
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) {
>
<span className={classNames(active && 'font-bold')}>{props.children}</span>
<kbd className={classNames('font-sans', active && 'text-indigo-50')}>K</kbd>
</a>
</button>
)}
</Menu.Item>
)