Announce unavailable controls instead of hiding them behind disabled
Native `disabled` removes an element from the tab order and from the accessibility tree, so any explanation of why a control cannot be used is delivered only to a sighted user with a mouse. `useUnavailable` is the way out: `aria-disabled` keeps the control focusable and announced, `aria-describedby` carries the reason, and — because `aria-disabled` is advisory and blocks nothing — the hook hands back the click and Enter/Space guards along with the attributes, so a call site cannot take the announcement without the guard. `Button` gets it as an opt-in `unavailable` / `unavailableReason` pair. Opt-in matters: 37 files render this button and none of them change. The `aria-disabled:` class mirrors exist because Tailwind's `disabled:` variant only matches the native attribute, which this pattern deliberately omits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import Button from "./Button";
|
||||
|
||||
const onClick = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
|
||||
describe("Button", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("still supports the native disabled attribute", () => {
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("stays in the accessibility tree when unavailable, and says why", () => {
|
||||
render(
|
||||
<Button unavailable unavailableReason="Stop the container first.">
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
expect(button).toHaveAccessibleDescription("Stop the container first.");
|
||||
// The reason is a description, not part of the name.
|
||||
expect(button).toHaveAccessibleName("Save");
|
||||
});
|
||||
|
||||
it("guards clicks and Enter/Space while unavailable", () => {
|
||||
render(
|
||||
<Button unavailable unavailableReason="Stop the container first." onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
fireEvent.click(button);
|
||||
fireEvent.keyDown(button, { key: "Enter" });
|
||||
fireEvent.keyDown(button, { key: " " });
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still forwards keys that are not activation keys", () => {
|
||||
render(
|
||||
<Button
|
||||
unavailable
|
||||
unavailableReason="Stop the container first."
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), {
|
||||
key: "Escape",
|
||||
});
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("behaves like an ordinary button when available", () => {
|
||||
render(
|
||||
<Button unavailable={false} unavailableReason="Stop the container first." onClick={onClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: "Save" });
|
||||
expect(button).not.toHaveAttribute("aria-disabled");
|
||||
expect(button).toHaveAccessibleDescription("");
|
||||
fireEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user