fix(ui): a disabled menu row says why it is off, under its label

The reason for a disabled menu row lived in a `title` on a natively disabled
button: no pointer events, so no tooltip, and roving focus skips the row —
four translated sentences reached nobody (the Watch later rename/delete
lock, "no other list to move to", the reorder gate). An aria-disabled row was
tried and reverted in E10's review. ActionMenu items now take a `note`,
rendered as a muted line under a disabled row's label: seen, and readable in
a screen reader's browse mode. Only the icon and label dim — row-level
opacity would dim the explanation too. The reorder group states its reason
once, under its first row.
This commit is contained in:
2026-09-19 07:57:20 +02:00
parent df7c3db085
commit bc138e3b37
4 changed files with 55 additions and 26 deletions
@@ -42,6 +42,22 @@ describe("ActionMenu", () => {
// floating-ui never computes a position in jsdom, so the surface stays visibility:hidden; the items
// are in the DOM but the a11y tree treats them as hidden — hence `{ hidden: true }` on role queries.
it("says why a disabled row is off, under its label — and only on a disabled row", () => {
// A `title` on a disabled row reached nobody: a disabled button fires no pointer events and
// roving focus skips it. The reason is visible text now, undimmed so it can be read.
renderMenu([
{ id: "move", label: "Move to", onSelect: vi.fn(), disabled: true, note: "No other list" },
{ id: "sync", label: "Sync", onSelect: vi.fn(), note: "never shown" },
]);
fireEvent.click(screen.getByRole("button", { name: "Catalog actions" }));
const [move, sync] = screen.getAllByRole("menuitem", { hidden: true });
expect(move!.textContent).toBe("Move toNo other list");
expect(sync!.textContent).toBe("Sync");
const note = screen.getByText("No other list");
expect(note.closest(".opacity-50")).toBeNull();
expect(screen.getByText("Move to").className).toContain("opacity-50");
});
it("opens on click and shows one menuitem per item", () => {
renderMenu([
{ id: "sync", label: "Sync", onSelect: vi.fn() },