Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dropdownmenu-disabled-trigger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@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 anchored menu; the anchored open path and the bottom-sheet trigger's click now check the trigger's availability first, for the default Button and for a natively or `aria-disabled` custom trigger.

@cixzhang
25 changes: 25 additions & 0 deletions packages/core/src/DropdownMenu/DropdownMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2861,6 +2861,31 @@ describe('DropdownMenu custom trigger', () => {
);
});

it('a disabled custom trigger never opens the menu', async () => {
const user = userEvent.setup();
render(
<DropdownMenu
items={[{label: 'Edit'}, {label: 'Delete'}]}
renderTrigger={({ref, ...props}) => (
<button type="button" ref={ref} aria-disabled="true" {...props}>
Row actions
</button>
)}
/>,
);
const trigger = screen.getByRole('button', {name: 'Row actions'});
trigger.focus();
await user.keyboard('{ArrowDown}');
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await user.pointer([
{keys: '[MouseLeft>]', target: trigger},
{keys: '[/MouseLeft]'},
]);
expect(trigger).toHaveAttribute('aria-expanded', 'false');
await user.click(trigger);
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});

it('a custom trigger opens from the keyboard and lands on the first row', async () => {
const user = userEvent.setup();
render(
Expand Down
28 changes: 25 additions & 3 deletions packages/core/src/DropdownMenu/DropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,15 @@ function DropdownMenuBottomSheet({
openModalityRef.current = 'keyboard';
}
};
const handleSheetTriggerClick = () => {
const handleSheetTriggerClick = (event: React.MouseEvent<HTMLElement>) => {
// A caller-rendered trigger that is natively or aria-disabled must not
// open the sheet; the default Button already refuses its own click.
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".

onClick?.();
setOpen(!isOpen);
};
Expand Down Expand Up @@ -1190,6 +1198,16 @@ function DropdownMenuPopover({
modality: 'keyboard' | 'pointer' = 'keyboard',
notifyClick = false,
): boolean => {
// An unavailable trigger never opens the menu. Button keeps an
// aria-disabled trigger focusable for its tooltip and swallows only
// 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.

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>
)}

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

}
acceptedOpenRef.current = false;
notifyClickOnShowRef.current = notifyClick;
pendingControlledOpenRef.current = isControlled;
Expand All @@ -1205,7 +1223,7 @@ function DropdownMenuPopover({
}
return true;
},
[isControlled, popover],
[button.isDisabled, isControlled, popover],
);

const isMenuOpenNow = isControlled
Expand Down Expand Up @@ -1328,7 +1346,11 @@ function DropdownMenuPopover({
return;
}
focusLastOnOpenRef.current = e.key === 'ArrowUp';
openAndFocus();
if (!openAndFocus()) {
// A refused open (disabled trigger) must not leave ArrowUp's
// "land on the last row" armed for a later open.
focusLastOnOpenRef.current = false;
}
}
return;
}
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/MoreMenu/MoreMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,44 @@ describe('MoreMenu', () => {
expect(button).toHaveAttribute('aria-disabled', 'true');
});

it('keeps the menu closed from every opening key and press while disabled', async () => {
const user = userEvent.setup();
render(<MoreMenu items={defaultItems} isDisabled />);
const button = screen.getByRole('button', {name: 'More options'});
button.focus();
for (const key of ['{Enter}', ' ', '{ArrowDown}', '{ArrowUp}']) {
await user.keyboard(key);
expect(button).toHaveAttribute('aria-expanded', 'false');
}
await user.pointer([
{keys: '[MouseLeft>]', target: button},
{keys: '[/MouseLeft]'},
]);
expect(button).toHaveAttribute('aria-expanded', 'false');
});

it('does not carry a refused ArrowUp into a later open', async () => {
const user = userEvent.setup();
const {rerender} = render(<MoreMenu items={defaultItems} isDisabled />);
const button = screen.getByRole('button', {name: 'More options'});
button.focus();
await user.keyboard('{ArrowUp}');
expect(button).toHaveAttribute('aria-expanded', 'false');

// Enabled again, a synthesized click (detail 0: screen-reader activation)
// opens on the FIRST row; the refused ArrowUp must not have armed "last".
rerender(<MoreMenu items={defaultItems} />);
fireEvent.click(button, {detail: 0});
await waitFor(() => {
expect(button).toHaveAttribute('aria-expanded', 'true');
});
await waitFor(() => {
expect(
screen.getByRole('menuitem', {name: 'Edit', hidden: true}),
).toHaveFocus();
});
});

it('supports custom label', () => {
render(<MoreMenu items={defaultItems} label="Row actions" />);
expect(
Expand Down
Loading