Skip to content

fix(DropdownMenu): a disabled trigger never opens the menu - #7475

Merged
cixzhang merged 4 commits into
mainfrom
fix/dropdownmenu-disabled-trigger
Oct 13, 2026
Merged

cixzhang merged 4 commits into
mainfrom
fix/dropdownmenu-disabled-trigger

Conversation

@cixzhang

@cixzhang cixzhang commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A keyboard user on a disabled MoreMenu (or any DropdownMenu whose trigger is aria-disabled — Button keeps a disabled trigger with a tooltip focusable) heard the control was unavailable, pressed ArrowDown, and the menu opened anyway. Button swallows Enter and Space for an aria-disabled control (Button.tsx L645–654) but lets other keys through, and DropdownMenu's trigger handler opens on ArrowDown/ArrowUp (L1317–1332); the pointer press model and a click could do the same. The Menu Button accessibility contract (#7469) records this as the one known failure of menu-button.control.unavailable-stays-closed; component:MoreMenu AR1 / component:DropdownMenu AR4 (#7459) govern the trigger, and spec:AST-021 FR4 asks an unavailable control to stay unavailable.

Fix

openAndFocus — the single path every open goes through (keyboard, pointer press, click, mounted-open walk-in) — returns false when the trigger is unavailable: button.isDisabled, or a custom renderTrigger control matching :disabled, [aria-disabled="true"]. Nothing else changes; an enabled trigger behaves exactly as before.

Round-2 changes

  • A refused open clears focusLastOnOpenRef, so a later screen-reader activation (synthesized click, detail === 0) or programmatic open lands on the first row, not the last. Test: "does not carry a refused ArrowUp into a later open" (red on the previous head).
  • The renderTrigger branch is covered: "a disabled custom trigger never opens the menu" (aria-disabled custom button; ArrowDown, a mouse press, a click — red on main).
  • The bottom-sheet trigger's click handler refuses a natively or aria-disabled control too; the Changeset names both paths precisely.

Evidence

  • MoreMenu.test.tsx "keeps the menu closed from every opening key and press while disabled" — Enter, Space, ArrowDown, ArrowUp and a mouse press on the disabled trigger leave aria-expanded="false". Red arm: fails with DropdownMenu.tsx reverted to main (ArrowDown opened it); 260/260 across the DropdownMenu and MoreMenu suites with the fix (ContextMenu and NavMenu suites unchanged).
  • Once this lands, the contract binding's more-menu-disabled state passes and its known-failure record is removed in the contract PR's follow-up.
  • tsc, eslint, Prettier clean.

Changeset

@astryxdesign/core patch [fix].

Button keeps an aria-disabled trigger focusable for its tooltip and swallows
only Enter and Space, so ArrowDown, ArrowUp, a press-down or a click still
reached the open path and opened a menu the trigger said was unavailable
(component:MoreMenu AR1 / component:DropdownMenu AR4, APG menu button;
AST-021 FR4). Every open path now checks the trigger first, for the default
Button and for a natively or aria-disabled custom trigger. Red-armed test.
@vercel

vercel Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 13, 2026 8:20pm UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 13, 2026
@astracat-bot

astracat-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 13, 2026, 8:43 PM UTC

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR

🧪 Sandbox Preview

View Sandbox for this PR

Modified Components

MoreMenu (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 87 -
Complexity N/A Low (4) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.9KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.

Visual Regression

44 of 448 shot(s) changed.

A change here is a question, not a failure: check whether the after is the
picture you intended. Record that review on the PR. Baseline maintenance is an
explicit dispatch of CI; this report never rewrites the baseline or adds a release gate.

component story theme mode pixels
Selector Indicator Space Bottom Sheet Narrow End RTL neutral light 754,077
Selector Indicator Space Bottom Sheet Wide Start neutral light 740,188
Chat Full AI Chat probe light 238,081
Chat Full AI Chat probe dark 237,048
Markdown Default probe light 159,625
Markdown Default probe dark 158,986
Collapsible Dividers probe dark 66,445
Collapsible Dividers probe light 66,445
Chat Default neutral dark 33,205
Chat Default neutral light 32,876
Chat Empty State probe light 23,531
Chat Empty State probe dark 22,622
ChatAutoScroll Scroll Behavior Comparison neutral dark 15,378
ChatAutoScroll Scroll Behavior Comparison neutral light 14,107
Chat With Attachments probe light 13,406
Chat With Attachments probe dark 11,232
ProgressBar With Target Mark probe dark 7,895
ProgressBar With Target Mark probe light 7,895
TableRowExpansion Detail Panel neutral dark 5,150
TableRowExpansion Detail Panel neutral light 4,068

and 24 more.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@astracat-bot astracat-bot 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.

The new guard in openAndFocus does keep a disabled trigger closed, and the new MoreMenu test reproduces the original bug, but two small changes are needed before this can be approved.

  • A refused ArrowUp leaves focusLastOnOpenRef set, so once the trigger is enabled a screen-reader activation or a programmatic isMenuOpen open lands on the last row instead of the first; please clear the flag whenever an open is refused and add a test for that sequence.
  • The :disabled, [aria-disabled="true"] branch for renderTrigger controls has no test, since removing it keeps the menu suites green, so please add a disabled custom-trigger case that covers ArrowDown, a click and a mouse press.
  • The changeset says every open path now checks availability, but the bottom-sheet presentation's click handler still opens from a caller-rendered aria-disabled control, so please narrow the wording or add the same check there.

[Automated review]

button.isDisabled === true ||
buttonRef.current?.matches(':disabled, [aria-disabled="true"]') === true
) {
return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This early return leaves focusLastOnOpenRef set when the refused key was ArrowUp, because handleButtonKeyDown sets it before calling here. Once the trigger is enabled, the next open that does not set the flag again lands focus on the last row instead of the first. That includes an AT activation (a detail 0 click, which this component treats as a keyboard open) and a controlled isMenuOpen open. In jsdom: ArrowUp while disabled, then enable, then either open → "Last". Without the earlier ArrowUp → "First". Clearing the flag on refusal fixes it:

focusLastOnOpenRef.current = false;
return false;

The existing rejected-show branch below has the same gap.

// arrive here; a custom trigger may be natively disabled instead.
if (
button.isDisabled === true ||
buttonRef.current?.matches(':disabled, [aria-disabled="true"]') === true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This clause is the only thing protecting caller-rendered renderTrigger controls (and loading or group-disabled built-in triggers), but no test covers it. With this line deleted, all 344 tests in the DropdownMenu, MoreMenu, ContextMenu and NavMenu suites still pass. Please add a custom-trigger case, for example with the trigger below, asserting that ArrowDown, a click and a mouse press each leave aria-expanded="false".

renderTrigger={props => (
  <button type="button" {...props} aria-disabled="true">
    More
  </button>
)}

'@astryxdesign/core': patch
---

[fix] `DropdownMenu` and `MoreMenu`: a disabled trigger no longer opens the menu. A disabled trigger with a tooltip stays focusable (`aria-disabled`), and Button swallowed only Enter and Space, so ArrowDown, ArrowUp, a press, or a click could still open the menu; every open path now checks the trigger's availability first.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"every open path now checks the trigger's availability first" covers only the anchored menu. In the bottom-sheet presentation the trigger opens through handleSheetTriggerClick, which has no availability check, so a caller-rendered aria-disabled control still opens the sheet. The built-in disabled trigger stays closed there only because Button's click guard runs first. Please narrow the wording, or add the same check to the sheet's click handler.

…e bottom-sheet trigger click also refuses a disabled control

Red-armed: a synthesized click after a refused ArrowUp now lands on the first
row, and a disabled custom trigger stays closed on ArrowDown, a press and a
click.

@astracat-bot astracat-bot 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.

The disabled-trigger fix now holds on every anchored open path, and the earlier feedback on the ArrowUp landing and the custom-trigger test is addressed, but one small change is needed before approval. The new availability check in the bottom-sheet trigger's click handler has no regression test, since removing it keeps every suite green, so please add a presentation="bottom-sheet" case with an aria-disabled renderTrigger control and assert that a click and Enter leave aria-expanded="false".

  • Optional: the button.isDisabled clause in both guards is already covered by the DOM check for the built-in trigger, and when button and renderTrigger are both given it disables the caller's enabled control, which should win, so consider dropping it.

[Automated review]

Comment on lines +722 to +727
if (
event.currentTarget.matches(':disabled, [aria-disabled="true"]') ||
button.isDisabled === true
) {
return;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This guard fixes the bottom sheet for a disabled caller control, but no test covers it: removing it keeps every suite green. Please add a bottom-sheet case to the custom-trigger suite, for example:

render(
  <DropdownMenu
    presentation="bottom-sheet"
    items={[{label: 'Edit'}, {label: 'Delete'}]}
    renderTrigger={({ref, ...props}) => (
      <button type="button" ref={ref} aria-disabled="true" {...props}>
        Row actions
      </button>
    )}
  />,
);

Then assert that a click and Enter on the trigger each leave aria-expanded="false".

// Enter and Space, so the arrow keys, a press and a click would still
// arrive here; a custom trigger may be natively disabled instead.
if (
button.isDisabled === true ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: the selector on the next line already matches the built-in Button's disabled or aria-disabled state, so every menu suite stays green without this clause. Keeping it also stops an enabled renderTrigger control from opening the menu when button is passed alongside it with isDisabled, although the caller's control is meant to win. Consider dropping it here and in the bottom-sheet guard.

…st; the DOM availability check alone guards both paths

The button.isDisabled clause is dropped: the DOM check already covers the
built-in Button, and when both button and renderTrigger are given the
caller's enabled control wins. Red-armed: the bottom-sheet test fails with
the sheet click guard removed.

@astracat-bot astracat-bot 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.

This fix is ready: a disabled DropdownMenu or MoreMenu trigger now stays closed on every key, press and click, in both the anchored menu and the bottom sheet, and the feedback from earlier revisions is addressed. The new tests fail against the previous implementation, each guard and the ArrowUp reset has its own covering test, and CI is green. One optional cleanup: in “a disabled custom trigger never opens the bottom sheet”, the queryByRole('dialog', {name: 'Project actions'}) check can never fail because the sheet is named “Menu”, so it can be dropped or pointed at that name.

[Automated review]

Comment on lines +2883 to +2885
expect(
screen.queryByRole('dialog', {name: 'Project actions'}),
).not.toBeInTheDocument();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: this assertion cannot fail. With only renderTrigger given, the sheet takes its accessible name from the default button label (“Menu”), so no dialog is ever named “Project actions”. The two aria-expanded checks above already prove the sheet stays closed; drop this or match the sheet's real name.

@cixzhang
cixzhang merged commit 250fd9a into main Oct 13, 2026
56 checks passed
@github-actions
github-actions Bot deleted the fix/dropdownmenu-disabled-trigger branch October 14, 2026 06:22

This branch was successfully deployed

1 active deployment
Preview — 935c63da Deployed Oct 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant