Repository navigation
Conversation
🦋 Changeset detectedLatest commit: 8650651 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
deea2fc to
b484bc0
Compare
c68072d to
a5e1210
Compare
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>
a5e1210 to
fc25958
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ 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.
There was a problem hiding this comment.
🟡 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)} |
| <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>
Storybook Preview Deployed✅ Preview URL: https://click-k5ufgwb8s-clickhouse.vercel.app Built from commit: |
Chromatic Storybook
Built from commit: Statuses as of the end of the run. The build link shows the live review state. |


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.Triggerno longer wraps its child in a<div>: a single element child becomes the menu button through RadixasChild, soaria-haspopup,aria-expanded, focus and the handlers land on the element that takes focus. Text children (orasChild={false}) render inside a reset<button>, which also makes text triggers keyboard-focusable (CUI-195).type="button"is passed only to a native<button>child, because click-uiButtonreadstypeas its variant.aria-disabled="true"next todisabled, on the text-trigger button and on a slotted child. A click-uiButtonchild already did this itself.DateRangePicker/DateTimeRangePickerpassasChild={false}(their input display forwards neither ref nor props) and are now focusable buttons. TheSplitButtonchevron is now aBaseButtonwithtype="button", like the primary half, so it keeps its cursor and gets the same focus ring.3. A bare click opens the menu (VoiceOver)
DropdownMenu.Triggeropens only onpointerdownand Enter/Space, so a bareclick, which is what VoiceOver's activation sends, did nothing. The trigger now opens the menu on aclickthat nopointerdowncame before. A click after apointerdownis 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.Dropdownowns its open state (controlledopen/onOpenChangeand uncontrolleddefaultOpen) through Radix's ownuseControllableState, 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.@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.4. A
Buttonchild no longer submits its formButtontakes its native type fromhtmlType, which has no default, so aButtonchild ofDropdown.Triggerwas a submit button: a click on it opened the menu and submitted an enclosing form. This was already the case onmain, where theButtonsat inside the wrapper<div>. The trigger now passeshtmlType="button"to aButtonchild, as it passestype="button"to a native<button>. AButton's ownhtmlTypestill 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
asChild={false}(only for non-interactive content). See the changeset.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.Tests
Dropdown.test.tsx(describe('trigger')) covers the trigger's semantics and the bare-click path;SplitButton.test.tsxcovers the chevron.Commit 1, the trigger:
asChildin the slotted branchasChild+<div>in the fallback'aria-disabled': disabled || undefinedfrom the shared trigger propsaria-disabledonto the text-trigger button only'button'changed toundefinedin the type ternarySplitButton: focuses the chevron menu button with Tab and opens the menu with Enter<BaseButton>changed to<span>SplitButton: does not submit an enclosing form from the chevrontype="button"removed from the chevronBaseButtonCommit 3, the bare click:
setOpen(true)in the click handleronOpenChangecallsonOpenChangedirectly as well assetOpenonChange: onOpenChangeremoved fromuseControllableStateafterPointerDown ||prop: openPropchanged toprop: undefineddefaultProp: falseafterPointerDown ||; and setting the pointer flag to!event.ctrlKeydisabled ||from the click conditionevent.defaultPrevented ||Commit 4, the
Buttonchild:htmlType: 'button'spreadhtmlType: 'button'onto the child withcloneElementhtmlTypespread unconditionalNot covered: Radix's
type="button"reaching a click-uiButtonchild. It is visible only through the Button's variant class, and no visual spec renders aButtonchild. 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
Contribution
buildandbuild-storybookwork locallyAccessibility
--click-global-color-outline-defaulttoken; 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