You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(Dropdown): open the menu on a click without a pointer press from assistive tech - #1261
This is an a11y fix. It's scoped to MacOS's VoiceOver, and even more, to Firefox and Safari (Chrome works w/o it).
The issue: Ctrl+Option+Space, which VO suggests for clicking on things, doesn't open the dropdown. For reasons (read what ai says below for details).
No idea how other a11y tools behave. And this fix isn't strictly required for other changes I'm making in this batch. But it grew out of it. Might be worth.
Description
Stacked on #1259: review only the commits after it.
Radix's DropdownMenu.Trigger opens only on pointerdown and Enter/Space (radix-ui/primitives#1963). VoiceOver in Safari and Firefox activates a control with mousedown, mouseup and click and no pointer events, so VO+Space did not open the menu. This PR opens it on a click that is not part of a pointer press. The workaround is kept together so it can be removed when Radix fixes the issue: grep radix-ui/primitives#1963 finds the hook, the open state in Dropdown and the test block, and the trigger's only use of the hook is its call to useNonPointerClick.
useNonPointerClick (src/hooks/internal/useNonPointerClick.ts): returns onPointerDown and onClick for an element. A pointerdown on the element starts a press; one capture listener per document ends it in the task after the pointerup or pointercancel of the same pointer, so a second finger or a touch while the mouse is held does not end it. A click during a press is left to Radix, whether Radix acted on the press or not, so the code copies none of Radix's conditions. Any other click that is not prevented calls onNonPointerClick. The listener is on the document because a press can end off the element, or on <html> under a modal menu that turns off pointer events; it captures because a handler on the way can stop the event.
Dropdown owns its open state (controlled open/onOpenChange and uncontrolled defaultOpen) through Radix's own useControllableState, and shares the setter with the trigger, because Radix keeps its own state internal. Dropdown.Trigger opens the menu on a non-pointer click unless it is disabled.
New direct dependency:@radix-ui/react-use-controllable-state, pinned to 1.2.6, the exact version @radix-ui/react-dropdown-menu depends on. It was already installed through Radix; click-ui now imports it, so it must be declared.
What VoiceOver sends, recorded with real VoiceOver on the trigger:
Safari and Firefox: the VO keys, then mousedown, mouseup and click in one task, no pointer events. click.detail is 0 in Safari and 1 in Firefox, so detail cannot tell such a click apart.
Chrome: pointerdown, pointerup and click with pointerType: "mouse". Radix handles it like a mouse click, and this PR leaves it alone.
Checked with real VoiceOver in Safari, Firefox and Chrome: the menu opens on VO+Space, also after an earlier mouse click. On main it does not open in Firefox and does open in Chrome; Safari on main was not checked.
Checked with Playwright against the built Storybook, in Chromium, Firefox and WebKit, with mouse and emulated touch: on a modal menu, a click without a pointer press opens it after a mouse click, a drag off and a tap; on a non-modal menu, a click or tap that closes the menu leaves it closed.
click.pointerType === '' would be a simpler test for such a click, but click is a PointerEvent only from Safari 18.2 and Firefox 129, newer than the range in .browserslistrc.
Tests
useNonPointerClick.test.tsx covers telling a click without a pointer press from the click of a press; presses use userEvent, and the click without a pointer press is the sequence VoiceOver sends in Safari and Firefox. Dropdown.test.tsx, in describe('non-pointer click workaround (radix-ui/primitives#1963)'), covers what Dropdown owns: the wiring, one test against Radix's own behavior, and the open state. The block goes away with the workaround.
useNonPointerClick:
Test
Mutation that turned it red
reports a click without a pointer press
delete the onNonPointerClick() call
does not report the click of a pointer press on the element
the click ignores the current press; and ending the press at once instead of after the task
reports a click without a pointer press after a pointer click
the press is never cleared; and the document listener never added
reports a click without a pointer press after a press that ended off the element
the press is never cleared; and no pointerup listener
reports a click without a pointer press after a cancelled press
no pointercancel listener
reports a click without a pointer press after a press whose pointerup a parent stopped
pointerup listener in the bubble phase
does not report the click of a press that another pointer released during (mouse held on the trigger, a touch elsewhere)
the press ends on any pointer's release; and on any release while a press is current
does not fail when a pointer is released after the press is over
reading the press's pointerId without checking that a press is current
does not report a click that onClick prevents
drop event.defaultPrevented
calls the given onPointerDown and onClick
delete either call; and calling either with undefined instead of the event
Dropdown:
Test
Mutation that turned it red
opens the menu on a VoiceOver activation
onNonPointerClick never opens
keeps a non-modal menu closed after a mouse click on the trigger closes it
the trigger gets only the hook's onClick, not its onPointerDown
does not ask a disabled trigger to open on a VoiceOver activation
drop the disabled check
calls the onClick and onPointerDown given to the trigger
either handler not passed to the hook
asks a controlled Dropdown to open without opening it itself
prop: openProp changed to prop: undefined
opens the menu on first render with defaultOpen
defaultProp: false
reports opening and closing to onOpenChange
onChange: onOpenChange removed
Not covered: the check that keeps an older press's timer from clearing a newer press (it needs a new press to start before the previous press's timer runs, an order userEvent cannot produce), and the once-per-document listener guard (without it, the listeners would only be added again).
Checklist
Breaking changes? (add migration notes in changesets)
Visual changes? (specify in changesets)
Design review needed?
Contribution
Sufficient research before PR
Self-reviewed the PR
Manually tested the changes (when applies - visual confirmation in Storybook)
build and build-storybook work locally
Tests and Stories are aligned with the changes
Accessibility
Keyboard - unchanged: Radix opens on Enter/Space; the non-pointer click path does not run for keyboard activation (Radix prevents the default on keydown, so no click follows).
Checked: 2.1.1 Keyboard and 4.1.2 Name, Role, Value with real VoiceOver in Safari, Firefox and Chrome. Not tested: NVDA, JAWS, iOS VoiceOver, TalkBack, Voice Control.
Radix's DropdownMenu.Trigger opens only on pointerdown and Enter/Space,
and VoiceOver activation sends a bare click (radix-ui/primitives#1963),
so the menu stayed closed. Dropdown now owns its open state through
Radix's own useControllableState and shares the setter with the
trigger, which opens the menu on a click that no pointerdown came
before. A click after a pointerdown is left to Radix, whether it
toggled the menu or ignored the press.
@radix-ui/react-use-controllable-state becomes a direct dependency,
pinned to the version @radix-ui/react-dropdown-menu uses.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pointerdown/click pairing that works around radix-ui/primitives#1963
moves from Dropdown.Trigger into an internal hook, so other components
that wrap a pointerdown-driven Radix trigger can use it, and the
workaround can be removed in one place once Radix fixes the issue.
Behavior is unchanged. The tests for the pairing move with it; the
Dropdown tests keep the wiring.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…click
The pointerdown flag was cleared only by the next click on the element,
but a press often sends no click to it: a mouse click that opens a
modal Radix menu releases over pointer-events: none, a touch tap can
end without one, and a press can end off the element. The stale flag
then made the next bare click count as part of that press, so VoiceOver
could not open the menu once.
useBareClick now listens, in the capture phase on the root element, for
the end of the press and clears the flag after the current task. A
click from the same press arrives before that, in Chromium, Firefox and
WebKit, for mouse and touch.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…seBareClick
The hook kept a flag per element and added and removed pointerup and
pointercancel listeners for every press, with a release step for
overlapping presses and a cleanup on unmount. A press is now one shared
value: pointerdown on the element sets it, and one capture listener per
document clears it in the task after any press ends. A click during a
press is left to Radix; any other click is bare. A newer press is not
cleared by an older press's timer.
The disabled check moves to Dropdown, because whether to open is the
component's decision; a native disabled button receives no click at
all. The hook tests now drive presses with userEvent and use Enter for
a click without a pointer press, so they need no knowledge of the
timer. The Dropdown tests for the workaround move into one describe
block and keep only the wiring, one test against Radix, and the open
state that Dropdown now owns.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The tests used Enter on a focused button as the click without a pointer
press. A Radix trigger handles Enter on keydown, so that click never
reaches the hook in real use. Recordings in Safari and Firefox show
VoiceOver's activation as its modifier keys, then mousedown, mouseup and
click with no pointer events; the tests now dispatch that. In Chrome,
VoiceOver sends a pointer press, which Radix handles, so a pointer click
already models it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
That the consumer's handlers receive the real events is the hook's
behavior, and its own test covers it. The Dropdown test keeps the wiring:
the handlers given to the trigger are called.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
"Bare click" was jargon. The hook reports a click that is not part of a
pointer press, whatever produced it; VoiceOver is the source we
recorded, so the hook tests name the behavior and keep VoiceOver as
their input.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ariser
changed the title
fix(Dropdown): open the menu on a bare click from assistive tech
fix(Dropdown): open the menu on a click without a pointer press from assistive tech
Oct 8, 2026
Any pointerup or pointercancel on the document ended the current press,
also one from another pointer: a second finger, or a touch while the
mouse is held on the trigger. The held pointer's click then counted as
a click without a pointer press and could reopen a non-modal menu that
its press had closed. The press now records its pointerId, and only an
end of that pointer clears it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Concurrent pointer presses overwrite state and allow stale menu reopening
src/hooks/internal/useNonPointerClick.ts:54
A second pointerdown on this or another Dropdown trigger replaces the still-held first press. For example, hold the mouse on a non-modal trigger, touch the same trigger, release the touch and let its timer run, then release the mouse. The touch clears currentPress, so the ordinary mouse click is classified as non-pointer and can reopen the menu that Radix closed. Track all outstanding pointer IDs with matching deferred cleanup, preserving the guard against an older timer clearing a newer press. Add a regression with both presses starting on hooked triggers; the existing test starts the second press on document.body, so it misses this overwrite.
🧠 Review effort: Balanced
Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.
Built from commit: 2f71367dac4070e255aa67b04af652413f090be8 · Chromatic run
Statuses as of the end of the run. The build link shows the live review state.
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
a11yAccessibility improvementsbugSomething isn't working
3 participants
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
From human
This is an a11y fix. It's scoped to MacOS's VoiceOver, and even more, to Firefox and Safari (Chrome works w/o it).
The issue: Ctrl+Option+Space, which VO suggests for clicking on things, doesn't open the dropdown. For reasons (read what ai says below for details).
No idea how other a11y tools behave. And this fix isn't strictly required for other changes I'm making in this batch. But it grew out of it. Might be worth.
Description
Stacked on #1259: review only the commits after it.
Radix's
DropdownMenu.Triggeropens only onpointerdownand Enter/Space (radix-ui/primitives#1963). VoiceOver in Safari and Firefox activates a control withmousedown,mouseupandclickand no pointer events, so VO+Space did not open the menu. This PR opens it on a click that is not part of a pointer press. The workaround is kept together so it can be removed when Radix fixes the issue:grep radix-ui/primitives#1963finds the hook, the open state inDropdownand the test block, and the trigger's only use of the hook is its call touseNonPointerClick.useNonPointerClick(src/hooks/internal/useNonPointerClick.ts): returnsonPointerDownandonClickfor an element. Apointerdownon the element starts a press; one capture listener per document ends it in the task after thepointeruporpointercancelof the same pointer, so a second finger or a touch while the mouse is held does not end it. A click during a press is left to Radix, whether Radix acted on the press or not, so the code copies none of Radix's conditions. Any other click that is not prevented callsonNonPointerClick. The listener is on the document because a press can end off the element, or on<html>under a modal menu that turns off pointer events; it captures because a handler on the way can stop the event.Dropdownowns its open state (controlledopen/onOpenChangeand uncontrolleddefaultOpen) through Radix's ownuseControllableState, and shares the setter with the trigger, because Radix keeps its own state internal.Dropdown.Triggeropens the menu on a non-pointer click unless it is disabled.@radix-ui/react-use-controllable-state, pinned to1.2.6, the exact version@radix-ui/react-dropdown-menudepends on. It was already installed through Radix; click-ui now imports it, so it must be declared.Links and tickets
CUI-328, split from CUI-315. Upstream: radix-ui/primitives#1963.
Good to know
mousedown,mouseupandclickin one task, no pointer events.click.detailis 0 in Safari and 1 in Firefox, sodetailcannot tell such a click apart.pointerdown,pointerupandclickwithpointerType: "mouse". Radix handles it like a mouse click, and this PR leaves it alone.mainit does not open in Firefox and does open in Chrome; Safari onmainwas not checked.click.pointerType === ''would be a simpler test for such a click, butclickis aPointerEventonly from Safari 18.2 and Firefox 129, newer than the range in.browserslistrc.Tests
useNonPointerClick.test.tsxcovers telling a click without a pointer press from the click of a press; presses useuserEvent, and the click without a pointer press is the sequence VoiceOver sends in Safari and Firefox.Dropdown.test.tsx, indescribe('non-pointer click workaround (radix-ui/primitives#1963)'), covers whatDropdownowns: the wiring, one test against Radix's own behavior, and the open state. The block goes away with the workaround.useNonPointerClick:onNonPointerClick()callpointeruplistenerpointercancellistenerpointeruplistener in the bubble phasepointerIdwithout checking that a press is currentevent.defaultPreventedundefinedinstead of the eventDropdown:onNonPointerClicknever opensonClick, not itsonPointerDowndisabledcheckprop: openPropchanged toprop: undefineddefaultProp: falseonChange: onOpenChangeremovedNot covered: the check that keeps an older press's timer from clearing a newer press (it needs a new press to start before the previous press's timer runs, an order
userEventcannot produce), and the once-per-document listener guard (without it, the listeners would only be added again).Checklist
Contribution
buildandbuild-storybookwork locallyAccessibility
Checked: 2.1.1 Keyboard and 4.1.2 Name, Role, Value with real VoiceOver in Safari, Firefox and Chrome. Not tested: NVDA, JAWS, iOS VoiceOver, TalkBack, Voice Control.
Screenshots
None: no visual change.
🤖 Generated with Claude Code