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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user