Skip to content

fix(Dropdown)!: put the menu-button semantics on the trigger's child and open it on assistive-tech clicks - #1259

Open
ariser wants to merge 4 commits into
mainfrom
nick/cui-315-dropdown-trigger-a11y
Open

ariser wants to merge 4 commits into
mainfrom
nick/cui-315-dropdown-trigger-a11y

Conversation

@ariser

@ariser ariser commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Three commits with code, each reviewable on its own. Commit 2 holds only the two regenerated snapshots.

1. The trigger's child is the menu button (breaking)

  • Dropdown.Trigger no longer wraps its child in a <div>: a single element child becomes the menu button through Radix asChild, so aria-haspopup, aria-expanded, focus and the handlers land on the element that takes focus. Text children (or asChild={false}) render inside a reset <button>, which also makes text triggers keyboard-focusable (CUI-195).
  • Radix's type="button" is passed only to a native <button> child, because click-ui Button reads type as its variant.
  • A disabled trigger also sets aria-disabled="true" next to disabled, on the text-trigger button and on a slotted child. A click-ui Button child already did this itself.
  • In-repo consumers: DateRangePicker / DateTimeRangePicker pass asChild={false} (their input display forwards neither ref nor props) and are now focusable buttons. The SplitButton chevron is now a BaseButton with type="button", like the primary half, so it keeps its cursor and gets the same focus ring.
  • Sub-menu triggers are unchanged (Radix's SubTrigger already opens on click).

3. A bare click opens the menu (VoiceOver)

  • Radix's DropdownMenu.Trigger opens only on pointerdown and Enter/Space, so a bare click, which is what VoiceOver's activation sends, did nothing. The trigger now opens the menu on a click that no pointerdown came before. A click after a pointerdown is left to Radix, whether Radix toggled the menu or ignored the press (for example with Ctrl held), so the code copies none of Radix's conditions.
  • To open the menu, Dropdown owns its open state (controlled open/onOpenChange and uncontrolled defaultOpen) through Radix's own useControllableState, and shares the setter with the trigger. Radix keeps its own state internal, so this is the supported way for the trigger to open it.
  • 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.

4. A Button child no longer submits its form

  • A click-ui Button takes its native type from htmlType, which has no default, so a Button child of Dropdown.Trigger was a submit button: a click on it opened the menu and submitted an enclosing form. This was already the case on main, where the Button sat inside the wrapper <div>. The trigger now passes htmlType="button" to a Button child, as it passes type="button" to a native <button>. A Button's own htmlType still wins.

Links and tickets

CUI-315, CUI-195. Upstream: radix-ui/primitives#1963, radix-ui/primitives#1912, radix-ui/primitives#2616. Related: #1260 changes nearby lines in Dropdown.tsx; the PR merged second needs a small conflict resolution.

Good to know

  • Breaking for a trigger child that does not forward its ref and props: the menu will not open. Fix by forwarding them or passing asChild={false} (only for non-interactive content). See the changeset.
  • Visual: tests/overlays/genericmenu.spec.ts "trigger focus-visible" (light and dark) now shows the focus outline on the text trigger, which could not take focus before. These two snapshots are regenerated in a separate commit; all other Dropdown, SplitButton and DatePicker specs pass unchanged.
  • Known edge: a press that starts on the trigger and ends off it leaves the pointer flag set, so the next bare click is ignored once.
Tests

Dropdown.test.tsx (describe('trigger')) covers the trigger's semantics and the bare-click path; SplitButton.test.tsx covers the chevron.

Commit 1, the trigger:

Test Mutation that turned it red
puts the menu-button attributes on the consumer button that takes focus delete asChild in the slotted branch
renders text children as a focusable menu button put back the old asChild + <div> in the fallback
marks a disabled text trigger with aria-disabled delete 'aria-disabled': disabled || undefined from the shared trigger props
marks a disabled native button child with aria-disabled same deletion; and moving aria-disabled onto the text-trigger button only
keeps a native button child from submitting its form 'button' changed to undefined in the type ternary
SplitButton: focuses the chevron menu button with Tab and opens the menu with Enter chevron <BaseButton> changed to <span>
SplitButton: does not submit an enclosing form from the chevron type="button" removed from the chevron BaseButton

Commit 3, the bare click:

Test Mutation that turned it red
opens the menu on a click that no pointerdown came before delete setOpen(true) in the click handler
opens the menu on a bare click after an earlier mouse click delete the pointer flag reset in the click handler
opens a controlled menu once on a mouse click Root's onOpenChange calls onOpenChange directly as well as setOpen
opens the menu once on Enter onChange: onOpenChange removed from useControllableState
closes an open non-modal menu on a mouse click on the trigger without reopening it drop afterPointerDown ||
reports closing once when a non-modal menu is closed from its trigger the same double call as above
asks a controlled Dropdown to open on a click without opening it itself prop: openProp changed to prop: undefined
opens the menu on first render with defaultOpen defaultProp: false
keeps the menu closed on a Ctrl+click, which Radix ignores drop afterPointerDown ||; and setting the pointer flag to !event.ctrlKey
does not ask to open a disabled trigger on a bare click drop disabled || from the click condition
does not ask to open on a bare click that the trigger's onClick prevents drop event.defaultPrevented ||
calls the onClick and onPointerDown given to the trigger delete the call to either consumer handler

Commit 4, the Button child:

Test Mutation that turned it red
keeps a Button child from submitting its form delete the htmlType: 'button' spread
keeps a Button child's own htmlType force htmlType: 'button' onto the child with cloneElement
does not pass htmlType to a native button child make the htmlType spread unconditional

Not covered: Radix's type="button" reaching a click-ui Button child. It is visible only through the Button's variant class, and no visual spec renders a Button child. Existing tests: none deleted; "keeps a native button child from submitting its form" now clicks with a full pointer press, because a bare click opens the menu only after commit 3.

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 - Tab reaches Button, text, SplitButton and date-range triggers; Enter/Space open (unit tests). Escape / focus return unchanged (Radix).
  • Names — the trigger is the consumer's element, so its name is its own label.
  • Vision — focus ring on the text trigger uses the existing --click-global-color-outline-default token; contrast not re-measured.

Checked: 2.1.1 Keyboard, 2.4.7 Focus Visible, 4.1.2 Name, Role, Value (jsdom + Playwright). Not tested: real VoiceOver/NVDA, the Storybook axe panel, a manual browser keyboard pass.

Screenshots

Focus-visible text trigger before: no outline; after: outline.

🤖 Generated with Claude Code

@changeset-bot

changeset-bot Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8650651

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 Minor

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 ariser changed the title fix(Dropdown)!: put the menu-button semantics on the trigger's child and open it on assistive-tech clicks (CUI-315) fix(Dropdown)!: put the menu-button semantics on the trigger's child and open it on assistive-tech clicks Oct 7, 2026
@ariser
ariser force-pushed the nick/cui-315-dropdown-trigger-a11y branch from deea2fc to b484bc0 Compare October 7, 2026 18:03
@ariser
ariser force-pushed the nick/cui-315-dropdown-trigger-a11y branch 2 times, most recently from c68072d to a5e1210 Compare October 7, 2026 20:16
ariser and others added 3 commits October 7, 2026 22:26
Dropdown.Trigger rendered its child inside a <div> that carried
aria-haspopup, aria-expanded and the Radix handlers, so a child <button>
took focus without menu-button semantics, and a text trigger could not
take focus at all (CUI-315, CUI-195). A single element child is now the
trigger through asChild; text children, or asChild={false}, render
inside a reset button. A disabled trigger also sets aria-disabled.

Radix's type="button" is passed only to a native <button> child,
because click-ui Button reads type as its variant. The date range
pickers pass asChild={false}, since their input display forwards
neither ref nor props. The SplitButton chevron becomes a BaseButton,
like the primary half, so it keeps its cursor and gains a focus ring.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The text trigger is now the focusable menu button, so its focus outline shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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>
@ariser
ariser force-pushed the nick/cui-315-dropdown-trigger-a11y branch from a5e1210 to fc25958 Compare October 7, 2026 20:27
@ariser
ariser marked this pull request as ready for review October 7, 2026 21:32

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit fc25958. Configure here.

Comment thread src/components/Dropdown/Dropdown.tsx

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

Pointer cancellation, form submission, and the SplitButton control’s accessible name remain unresolved.

3 open findings
What changed in this PR

Reworks Dropdown.Trigger semantics and adds assistive-technology click support.

Changes:

  • Makes the focusable child—or a fallback button—the menu trigger.
  • Updates SplitButton and date-range picker consumers.
  • Adds state handling, tests, stories, dependency metadata, and changesets.
File Description
src/​components/​Dropdown/​Dropdown.tsx Implements trigger semantics and bare-click handling.
src/​components/​Dropdown/​Dropdown.test.tsx Tests trigger behavior and controlled state.
src/​components/​Dropdown/​Dropdown.stories.tsx Adds a keyboard interaction story.
src/​components/​Dropdown/​Dropdown.module.css Resets and styles fallback buttons.
src/​components/​SplitButton/​SplitButton.tsx Makes the chevron trigger a button.
src/​components/​SplitButton/​SplitButton.test.tsx Tests keyboard and form behavior.
src/​components/​DatePicker/​DateRangePicker.tsx Uses the fallback trigger button.
src/​components/​DatePicker/​DateTimeRangePicker.tsx Uses the fallback trigger button.
package.json Declares the controllable-state dependency.
yarn.lock Records the dependency.
.changeset/​nick-dropdown-trigger-bare-click.md Documents the bare-click fix.
.changeset/​nick-cui-315-dropdown-trigger-a11y.md Documents the breaking trigger migration.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

if (onPointerDown) {
onPointerDown(event);
}
pointerDownRef.current = true;
<DropdownMenu.Trigger
asChild
// Radix sets type="button" on its trigger, which a click-ui Button reads as its variant.
type={type ?? (children.type === 'button' ? 'button' : undefined)}
Comment on lines +116 to 120
<BaseButton type="button">
<Icon
name="chevron-down"
size="sm"
/>
A click-ui Button takes its native type from htmlType, which has no
default, so a Button child of Dropdown.Trigger was a submit button and
a click on it submitted an enclosing form. The trigger already passes
type="button" to a native <button> child; it now passes
htmlType="button" to a Button child. A Button's own htmlType still
wins, because the child's props override the slot's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@workflow-authentication-public

Copy link
Copy Markdown
Contributor

Storybook Preview Deployed

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

Built from commit: 1bb7c9808033760cccae2874132511ca01cdba36

@workflow-authentication-public

Copy link
Copy Markdown
Contributor

Chromatic Storybook

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

Built from commit: 8650651ce8c125105775956c79811e79b5311db9 · 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants