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
78 lines
2.3 KiB
TypeScript
78 lines
2.3 KiB
TypeScript
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);
|
|
});
|
|
});
|