Skip to content

Commit 3b7c0b6

Browse files
Fix listbox closing immediately after opening on touch devices (#3755)
Fixes #3750 @RobinMalfait is this the right approach? - [ ] probably need to write a test for this somehow? I feel like a playwright-style browser test might really be the only way to actually write this one. --------- Co-authored-by: Robin Malfait <[email protected]>
1 parent 38986df commit 3b7c0b6

File tree

4 files changed

+118
-2
lines changed

4 files changed

+118
-2
lines changed

jest/polyfills.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ class PointerEvent extends Event {
2929
// @ts-expect-error JSDOM doesn't support `button` yet...
3030
this.button = props.button
3131
}
32+
33+
if (props.pointerType != null) {
34+
// @ts-expect-error JSDOM doesn't support `pointerType` yet...
35+
this.pointerType = props.pointerType
36+
}
37+
38+
// @ts-expect-error JSDOM doesn't support `pointerType` yet...
39+
if (this.pointerType === undefined) {
40+
// Fallback to `pointerType` of `'mouse'` if not provided.
41+
// @ts-expect-error JSDOM doesn't support `pointerType` yet...
42+
this.pointerType = 'mouse'
43+
}
3244
}
3345
}
3446
// @ts-expect-error JSDOM doesn't support `PointerEvent` yet...

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+
### Fixed
11+
12+
- Fix listbox closing immediately after opening on touch devices ([#3755](https://github.com/tailwindlabs/headlessui/pull/3755))
1113

1214
## [2.2.4] - 2025-05-20
1315

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import {
8383
import { useDescribedBy } from '../description/description'
8484
import { Keys } from '../keyboard'
8585
import { Label, useLabelledBy, useLabels, type _internal_ComponentLabel } from '../label/label'
86+
import { MouseButton } from '../mouse'
8687
import { Portal } from '../portal/portal'
8788
import { ActionTypes, ActivationTrigger, ListboxStates, ValueMode } from './listbox-machine'
8889
import { ListboxContext, useListboxMachine, useListboxMachineContext } from './listbox-machine-glue'
@@ -434,8 +435,27 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
434435
}
435436
})
436437

438+
let pointerTypeRef = useRef<'touch' | 'mouse' | 'pen' | null>(null)
437439
let handlePointerDown = useEvent((event: ReactPointerEvent) => {
438-
if (event.button !== 0) return // Only handle left clicks
440+
pointerTypeRef.current = event.pointerType
441+
442+
if (event.pointerType !== 'mouse') return
443+
444+
if (event.button !== MouseButton.Left) return // Only handle left clicks
445+
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
446+
if (machine.state.listboxState === ListboxStates.Open) {
447+
flushSync(() => machine.actions.closeListbox())
448+
machine.state.buttonElement?.focus({ preventScroll: true })
449+
} else {
450+
event.preventDefault()
451+
machine.actions.openListbox({ focus: Focus.Nothing })
452+
}
453+
})
454+
455+
let handleClick = useEvent((event: ReactPointerEvent) => {
456+
if (pointerTypeRef.current === 'mouse') return
457+
458+
if (event.button !== MouseButton.Left) return // Only handle left clicks
439459
if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault()
440460
if (machine.state.listboxState === ListboxStates.Open) {
441461
flushSync(() => machine.actions.closeListbox())
@@ -487,6 +507,7 @@ function ButtonFn<TTag extends ElementType = typeof DEFAULT_BUTTON_TAG>(
487507
onKeyUp: handleKeyUp,
488508
onKeyPress: handleKeyPress,
489509
onPointerDown: handlePointerDown,
510+
onClick: handleClick,
490511
},
491512
focusProps,
492513
hoverProps,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'
2+
import { useState } from 'react'
3+
4+
let people = [
5+
'Wade Cooper',
6+
'Arlene Mccoy',
7+
'Devon Webb',
8+
'Tom Cook',
9+
'Tanya Fox',
10+
'Hellen Schmidt',
11+
'Caroline Schultz',
12+
'Mason Heaney',
13+
'Claudie Smitham',
14+
'Emil Schaefer',
15+
]
16+
17+
export default function Home() {
18+
let [active, setActivePerson] = useState(people[0])
19+
20+
return (
21+
<div className="flex h-full w-screen justify-center bg-gray-50 p-12">
22+
<div className="mx-auto w-full max-w-xs">
23+
<div className="space-y-1">
24+
<Listbox value={active} onChange={setActivePerson}>
25+
<Label className="block text-sm font-medium leading-5 text-gray-700">Assigned to</Label>
26+
27+
<div className="relative">
28+
<span className="shadow-xs inline-block w-full rounded-md">
29+
<ListboxButton className="relative w-full cursor-default rounded-md border border-gray-300 bg-white py-2 pl-3 pr-10 text-left transition duration-150 ease-in-out sm:text-sm sm:leading-5">
30+
<span className="block truncate">{active}</span>
31+
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
32+
<svg
33+
className="h-5 w-5 text-gray-400"
34+
viewBox="0 0 20 20"
35+
fill="none"
36+
stroke="currentColor"
37+
>
38+
<path
39+
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
40+
strokeWidth="1.5"
41+
strokeLinecap="round"
42+
strokeLinejoin="round"
43+
/>
44+
</svg>
45+
</span>
46+
</ListboxButton>
47+
</span>
48+
49+
<ListboxOptions
50+
anchor="selection"
51+
transition
52+
className="focus:outline-hidden data-closed:scale-95 data-closed:opacity-0 w-(--button-width) overflow-auto rounded-md border border-gray-300 bg-white py-1 text-base leading-6 shadow-lg transition duration-200 ease-out [--anchor-gap:--spacing(1)] [--anchor-max-height:--spacing(60)] sm:text-sm sm:leading-5"
53+
>
54+
{people.map((name) => (
55+
<ListboxOption
56+
key={name}
57+
value={name}
58+
className="focus:outline-hidden data-active:bg-indigo-600 data-active:text-white group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900"
59+
>
60+
<span className="group-data-selected:font-semibold block truncate font-normal">
61+
{name}
62+
</span>
63+
<span className="group-data-active:text-white group-data-selected:flex absolute inset-y-0 right-0 hidden items-center pr-4 text-indigo-600">
64+
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
65+
<path
66+
fillRule="evenodd"
67+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
68+
clipRule="evenodd"
69+
/>
70+
</svg>
71+
</span>
72+
</ListboxOption>
73+
))}
74+
</ListboxOptions>
75+
</div>
76+
</Listbox>
77+
</div>
78+
</div>
79+
</div>
80+
)
81+
}

0 commit comments

Comments
 (0)