Skip to content

fix(Dropdown): open the menu on a click without a pointer press from assistive tech - #1261

Draft
ariser wants to merge 11 commits into
nick/cui-315-dropdown-trigger-a11yfrom
nick/dropdown-trigger-bare-click
Draft

ariser wants to merge 11 commits into
nick/cui-315-dropdown-trigger-a11yfrom
nick/dropdown-trigger-bare-click

Conversation

@ariser

@ariser ariser commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

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.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.

Links and tickets

CUI-328, split from CUI-315. Upstream: radix-ui/primitives#1963.

Good to know

  • 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.

Screenshots

None: no visual change.

🤖 Generated with Claude Code

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>
@changeset-bot

changeset-bot Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2f71367

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@clickhouse/click-ui Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

ariser and others added 7 commits October 8, 2026 02:36
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>
The file name now describes the change instead of carrying the author.

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 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
@ariser
ariser requested review from DreaminDani and XOP and a balanced review from Copilot October 8, 2026 03:14

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

An unrelated pointer release can clear an active press and incorrectly reopen a menu.

1 open finding
What changed in this PR

Adds a Dropdown workaround for assistive-technology clicks without pointer events, building on #1259’s trigger changes.

Changes:

  • Adds shared pointer-press tracking and non-pointer click handling.
  • Manages Dropdown open state through Radix’s controllable-state hook.
  • Adds regression tests and a patch changeset.
File Description
yarn.lock Records the direct Radix dependency.
src/​hooks/​internal/​useNonPointerClick.ts Detects clicks without pointer presses.
src/​hooks/​internal/​useNonPointerClick.test.tsx Tests click classification and handler forwarding.
src/​hooks/​internal/​index.ts Exports the internal hook.
src/​components/​Dropdown/​Dropdown.tsx Connects non-pointer clicks to menu state.
src/​components/​Dropdown/​Dropdown.test.tsx Tests activation and state handling.
package.json Declares the controllable-state dependency.
.changeset/​dropdown-trigger-opens-on-non-pointer-click.md Documents the consumer-facing fix.

🧠 Review effort: Balanced


Give feedback about Copilot approvals in this survey to enter a drawing for a $150 gift card.

Comment thread src/hooks/internal/useNonPointerClick.ts Outdated
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Overlapping pointer presses can misclassify an ordinary click and unexpectedly reopen a closed menu.

0 open findings

1 resolved since last review
Previously missed (1)

In code that hasn't changed since last review

Medium severity 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.

@XOP XOP added bug Something isn't working a11y Accessibility improvements labels Oct 8, 2026
ariser added 2 commits October 8, 2026 14:33
…trigger-bare-click

# Conflicts:
#	src/components/Dropdown/Dropdown.test.tsx
#	src/components/Dropdown/Dropdown.tsx
@workflow-authentication-public

Copy link
Copy Markdown
Contributor

Storybook Preview Deployed

✅ Preview URL: https://click-680rn3g0h-clickhouse.vercel.app

Built from commit: d5736801bc6eec53ef84caae72ede4acd32054e7

@workflow-authentication-public

Copy link
Copy Markdown
Contributor

Chromatic Storybook

Check Status Link
Storybook ✅ 656 stories published Open Storybook
UI Tests ⏳ 1 change must be accepted as baseline Open build

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y Accessibility improvements bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants