Skip to content

Commit 1461b65

Browse files
authored
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
1 parent 730ab68 commit 1461b65

File tree

8 files changed

+255
-33
lines changed

8 files changed

+255
-33
lines changed

jest/polyfills.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,18 @@ Object.defineProperty(HTMLElement.prototype, 'innerText', {
1818
this.textContent = value
1919
},
2020
})
21+
22+
// Source: https://github.com/testing-library/react-testing-library/issues/838#issuecomment-735259406
23+
//
24+
// Polyfill the PointerEvent class for JSDOM
25+
class PointerEvent extends Event {
26+
constructor(type, props) {
27+
super(type, props)
28+
if (props.button != null) {
29+
// @ts-expect-error JSDOM doesn't support `button` yet...
30+
this.button = props.button
31+
}
32+
}
33+
}
34+
// @ts-expect-error JSDOM doesn't support `PointerEvent` yet...
35+
window.PointerEvent = PointerEvent

packages/@headlessui-react/CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
- Nothing yet!
10+
### Added
11+
12+
- Add a quick trigger action to the `Menu`, `Listbox` and `Combobox` components ([#3700](https://github.com/tailwindlabs/headlessui/pull/3700))
1113

1214
## [2.2.2] - 2025-04-17
1315

packages/@headlessui-react/src/components/combobox/combobox.tsx

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import React, {
1717
type FocusEvent as ReactFocusEvent,
1818
type KeyboardEvent as ReactKeyboardEvent,
1919
type MouseEvent as ReactMouseEvent,
20+
type PointerEvent as ReactPointerEvent,
2021
type Ref,
2122
} from 'react'
2223
import { flushSync } from 'react-dom'
@@ -34,6 +35,7 @@ import { useLatestValue } from '../../hooks/use-latest-value'
3435
import { useOnDisappear } from '../../hooks/use-on-disappear'
3536
import { useOutsideClick } from '../../hooks/use-outside-click'
3637
import { useOwnerDocument } from '../../hooks/use-owner'
38+
import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release'
3739
import { useRefocusableInput } from '../../hooks/use-refocusable-input'
3840
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3941
import { useScrollLock } from '../../hooks/use-scroll-lock'
@@ -989,9 +991,43 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
989991
...theirProps
990992
} = props
991993

992-
let inputElement = useSlice(machine, (state) => state.inputElement)
994+
let [comboboxState, inputElement, optionsElement] = useSlice(machine, (state) => [
995+
state.comboboxState,
996+
state.inputElement,
997+
state.optionsElement,
998+
])
993999
let refocusInput = useRefocusableInput(inputElement)
9941000

1001+
let enableQuickRelease = comboboxState === ComboboxState.Open
1002+
useQuickRelease(enableQuickRelease, {
1003+
trigger: localButtonElement,
1004+
action: useCallback(
1005+
(e) => {
1006+
if (localButtonElement?.contains(e.target)) {
1007+
return QuickReleaseAction.Ignore
1008+
}
1009+
1010+
if (inputElement?.contains(e.target)) {
1011+
return QuickReleaseAction.Ignore
1012+
}
1013+
1014+
let option = e.target.closest('[role="option"]:not([data-disabled])')
1015+
if (option !== null) {
1016+
return QuickReleaseAction.Select(option as HTMLElement)
1017+
}
1018+
1019+
if (optionsElement?.contains(e.target)) {
1020+
return QuickReleaseAction.Ignore
1021+
}
1022+
1023+
return QuickReleaseAction.Close
1024+
},
1025+
[localButtonElement, inputElement, optionsElement]
1026+
),
1027+
close: machine.actions.closeCombobox,
1028+
select: machine.actions.selectActiveOption,
1029+
})
1030+
9951031
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLElement>) => {
9961032
switch (event.key) {
9971033
// 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>(
10441080
}
10451081
})
10461082

1047-
let handleMouseDown = useEvent((event: ReactMouseEvent<HTMLButtonElement>) => {
1048-
// We use the `mousedown` event here since it fires before the focus event,
1049-
// allowing us to cancel the event before focus is moved from the
1083+
let handlePointerDown = useEvent((event: ReactPointerEvent<HTMLButtonElement>) => {
1084+
// We use the `poitnerdown` event here since it fires before the focus
1085+
// event, allowing us to cancel the event before focus is moved from the
10501086
// `ComboboxInput` to the `ComboboxButton`. This keeps the input focused,
10511087
// preserving the cursor position and any text selection.
10521088
event.preventDefault()
@@ -1074,11 +1110,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
10741110
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
10751111
let { pressed: active, pressProps } = useActivePress({ disabled })
10761112

1077-
let [comboboxState, optionsElement] = useSlice(machine, (state) => [
1078-
state.comboboxState,
1079-
state.optionsElement,
1080-
])
1081-
10821113
let slot = useMemo(() => {
10831114
return {
10841115
open: comboboxState === ComboboxState.Open,
@@ -1102,7 +1133,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
11021133
'aria-labelledby': labelledBy,
11031134
disabled: disabled || undefined,
11041135
autoFocus,
1105-
onMouseDown: handleMouseDown,
1136+
onPointerDown: handlePointerDown,
11061137
onKeyDown: handleKeyDown,
11071138
},
11081139
focusProps,

packages/@headlessui-react/src/components/listbox/listbox.tsx

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import React, {
1515
type ElementType,
1616
type MutableRefObject,
1717
type KeyboardEvent as ReactKeyboardEvent,
18-
type MouseEvent as ReactMouseEvent,
18+
type PointerEvent as ReactPointerEvent,
1919
type Ref,
2020
} from 'react'
2121
import { flushSync } from 'react-dom'
@@ -34,6 +34,7 @@ import { useLatestValue } from '../../hooks/use-latest-value'
3434
import { useOnDisappear } from '../../hooks/use-on-disappear'
3535
import { useOutsideClick } from '../../hooks/use-outside-click'
3636
import { useOwnerDocument } from '../../hooks/use-owner'
37+
import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release'
3738
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3839
import { useScrollLock } from '../../hooks/use-scroll-lock'
3940
import { useSyncRefs } from '../../hooks/use-sync-refs'
@@ -359,6 +360,38 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
359360
let buttonRef = useSyncRefs(ref, useFloatingReference(), machine.actions.setButtonElement)
360361
let getFloatingReferenceProps = useFloatingReferenceProps()
361362

363+
let [listboxState, buttonElement, optionsElement] = useSlice(machine, (state) => [
364+
state.listboxState,
365+
state.buttonElement,
366+
state.optionsElement,
367+
])
368+
369+
let enableQuickRelease = listboxState === ListboxStates.Open
370+
useQuickRelease(enableQuickRelease, {
371+
trigger: buttonElement,
372+
action: useCallback(
373+
(e) => {
374+
if (buttonElement?.contains(e.target)) {
375+
return QuickReleaseAction.Ignore
376+
}
377+
378+
let option = e.target.closest('[role="option"]:not([data-disabled])')
379+
if (option !== null) {
380+
return QuickReleaseAction.Select(option as HTMLElement)
381+
}
382+
383+
if (optionsElement?.contains(e.target)) {
384+
return QuickReleaseAction.Ignore
385+
}
386+
387+
return QuickReleaseAction.Close
388+
},
389+
[buttonElement, optionsElement]
390+
),
391+
close: machine.actions.closeListbox,
392+
select: machine.actions.selectActiveOption,
393+
})
394+
362395
let handleKeyDown = useEvent((event: ReactKeyboardEvent<HTMLButtonElement>) => {
363396
switch (event.key) {
364397
// 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>(
393426
}
394427
})
395428

396-
let handleMouseDown = useEvent((event: ReactMouseEvent) => {
429+
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
397430
if (event.button !== 0) return // Only handle left clicks
398431
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
399432
if (machine.state.listboxState === ListboxStates.Open) {
@@ -415,8 +448,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
415448
let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled })
416449
let { pressed: active, pressProps } = useActivePress({ disabled })
417450

418-
let listboxState = useSlice(machine, (state) => state.listboxState)
419-
420451
let slot = useMemo(() => {
421452
return {
422453
open: listboxState === ListboxStates.Open,
@@ -431,10 +462,6 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
431462
}, [listboxState, data.value, disabled, hover, focus, active, data.invalid, autoFocus])
432463

433464
let open = useSlice(machine, (state) => state.listboxState === ListboxStates.Open)
434-
let [buttonElement, optionsElement] = useSlice(machine, (state) => [
435-
state.buttonElement,
436-
state.optionsElement,
437-
])
438465
let ourProps = mergeProps(
439466
getFloatingReferenceProps(),
440467
{
@@ -451,7 +478,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
451478
onKeyDown: handleKeyDown,
452479
onKeyUp: handleKeyUp,
453480
onKeyPress: handleKeyPress,
454-
onMouseDown: handleMouseDown,
481+
onPointerDown: handlePointerDown,
455482
},
456483
focusProps,
457484
hoverProps,

packages/@headlessui-react/src/components/menu/menu.tsx

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import React, {
1313
type CSSProperties,
1414
type ElementType,
1515
type KeyboardEvent as ReactKeyboardEvent,
16-
type MouseEvent as ReactMouseEvent,
16+
type PointerEvent as ReactPointerEvent,
1717
type Ref,
1818
} from 'react'
1919
import { flushSync } from 'react-dom'
@@ -28,6 +28,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect'
2828
import { useOnDisappear } from '../../hooks/use-on-disappear'
2929
import { useOutsideClick } from '../../hooks/use-outside-click'
3030
import { useOwnerDocument } from '../../hooks/use-owner'
31+
import { Action as QuickReleaseAction, useQuickRelease } from '../../hooks/use-quick-release'
3132
import { useResolveButtonType } from '../../hooks/use-resolve-button-type'
3233
import { useScrollLock } from '../../hooks/use-scroll-lock'
3334
import { useSyncRefs } from '../../hooks/use-sync-refs'
@@ -224,12 +225,39 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
224225
}
225226
})
226227

227-
let [menuState, itemsElement] = useSlice(machine, (state) => [
228+
let [menuState, buttonElement, itemsElement] = useSlice(machine, (state) => [
228229
state.menuState,
230+
state.buttonElement,
229231
state.itemsElement,
230232
])
231233

232-
let handleMouseDown = useEvent((event: ReactMouseEvent) => {
234+
let enableQuickRelease = menuState === MenuState.Open
235+
useQuickRelease(enableQuickRelease, {
236+
trigger: buttonElement,
237+
action: useCallback(
238+
(e) => {
239+
if (buttonElement?.contains(e.target)) {
240+
return QuickReleaseAction.Ignore
241+
}
242+
243+
let item = e.target.closest('[role="menuitem"]:not([data-disabled])')
244+
if (item !== null) {
245+
return QuickReleaseAction.Select(item as HTMLElement)
246+
}
247+
248+
if (itemsElement?.contains(e.target)) {
249+
return QuickReleaseAction.Ignore
250+
}
251+
252+
return QuickReleaseAction.Close
253+
},
254+
[buttonElement, itemsElement]
255+
),
256+
close: useCallback(() => machine.send({ type: ActionTypes.CloseMenu }), []),
257+
select: useCallback((target) => target.click(), []),
258+
})
259+
260+
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
233261
if (event.button !== 0) return // Only handle left clicks
234262
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
235263
if (disabled) return
@@ -274,7 +302,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
274302
autoFocus,
275303
onKeyDown: handleKeyDown,
276304
onKeyUp: handleKeyUp,
277-
onMouseDown: handleMouseDown,
305+
onPointerDown: handlePointerDown,
278306
},
279307
focusProps,
280308
hoverProps,
@@ -640,8 +668,8 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
640668

641669
let pointer = useTrackedPointer()
642670

643-
let handleEnter = useEvent((evt) => {
644-
pointer.update(evt)
671+
let handleEnter = useEvent((event) => {
672+
pointer.update(event)
645673
if (disabled) return
646674
if (active) return
647675
machine.send({
@@ -652,8 +680,8 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
652680
})
653681
})
654682

655-
let handleMove = useEvent((evt) => {
656-
if (!pointer.wasMoved(evt)) return
683+
let handleMove = useEvent((event) => {
684+
if (!pointer.wasMoved(event)) return
657685
if (disabled) return
658686
if (active) return
659687
machine.send({
@@ -664,8 +692,8 @@ function ItemFn<TTag extends ElementType = typeof DEFAULT_ITEM_TAG>(
664692
})
665693
})
666694

667-
let handleLeave = useEvent((evt) => {
668-
if (!pointer.wasMoved(evt)) return
695+
let handleLeave = useEvent((event) => {
696+
if (!pointer.wasMoved(event)) return
669697
if (disabled) return
670698
if (!active) return
671699
machine.send({ type: ActionTypes.GoToItem, focus: Focus.Nothing })

0 commit comments

Comments
 (0)