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:
2026-09-02 09:08:50 -07:00
co-authored by Claude Opus 5
parent ed1dc8502c
commit aa0a574091
3 changed files with 205 additions and 11 deletions
+77
View File
@@ -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);
});
});
+41 -11
View File
@@ -1,4 +1,5 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import { useUnavailable } from "./unavailable";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "sm" | "md";
@@ -7,22 +8,37 @@ interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
children: ReactNode;
/**
* Unavailable, but still announced. Renders `aria-disabled` and wires
* `unavailableReason` to `aria-describedby` instead of using the native
* `disabled` attribute, which would take the button out of the tab order and
* out of the accessibility tree — reason and all. Clicks and Enter/Space are
* guarded for you. Prefer this over `disabled` whenever there is a reason
* worth telling the user.
*/
unavailable?: boolean;
/** Why the button cannot be used. Required for `unavailable` to say anything. */
unavailableReason?: string;
}
/**
* Real buttons with visible bounds and a ≥24px hit target.
* Filled variants use the *-emphasis tokens so white text clears WCAG AA;
* `--accent` stays reserved for foreground/link use.
*
* The `aria-disabled:` class mirrors below exist because Tailwind's
* `disabled:` variant only matches the native attribute, which `unavailable`
* deliberately does not set. Keep the two lists in step.
*/
const VARIANTS: Record<ButtonVariant, string> = {
primary:
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)]",
"bg-[var(--accent-emphasis)] text-white border border-transparent hover:bg-[var(--accent-emphasis-hover)] disabled:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] aria-disabled:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
secondary:
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)]",
"bg-[var(--bg-tertiary)] text-[var(--text-primary)] border border-[var(--border-color)] hover:bg-[var(--border-color)] disabled:text-[var(--text-disabled)] disabled:hover:bg-[var(--bg-tertiary)] aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:bg-[var(--bg-tertiary)]",
danger:
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent",
"bg-transparent text-[var(--error)] border border-[var(--error)]/40 hover:bg-[var(--error-muted)] disabled:text-[var(--text-disabled)] disabled:border-[var(--border-color)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:border-[var(--border-color)] aria-disabled:hover:bg-transparent",
ghost:
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent",
"bg-transparent text-[var(--text-secondary)] border border-transparent hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] disabled:text-[var(--text-disabled)] disabled:hover:bg-transparent aria-disabled:text-[var(--text-disabled)] aria-disabled:hover:text-[var(--text-disabled)] aria-disabled:hover:bg-transparent",
};
const SIZES: Record<ButtonSize, string> = {
@@ -35,16 +51,30 @@ export default function Button({
size = "sm",
className = "",
type = "button",
unavailable = false,
unavailableReason = "",
children,
...rest
}: Props) {
const { controlProps, reasonNode } = useUnavailable({
unavailable,
reason: unavailableReason,
onClick: rest.onClick,
onKeyDown: rest.onKeyDown,
});
return (
<button
type={type}
{...rest}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
>
{children}
</button>
<>
<button
type={type}
{...rest}
{...controlProps}
className={`inline-flex items-center justify-center whitespace-nowrap rounded-[var(--radius-control)] font-medium transition-colors disabled:cursor-not-allowed aria-disabled:cursor-not-allowed ${SIZES[size]} ${VARIANTS[variant]} ${className}`}
>
{children}
</button>
{/* Outside the button: inside, the reason would join its accessible name. */}
{reasonNode}
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import {
useId,
type KeyboardEventHandler,
type MouseEventHandler,
type ReactNode,
} from "react";
/** Keys a native `<button>` turns into a click. */
const ACTIVATION_KEYS = new Set([" ", "Spacebar", "Enter"]);
export interface UnavailableControlProps {
"aria-disabled"?: true;
"aria-describedby"?: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}
export interface UnavailableControl {
/** Spread onto the control. Carries the guarded handlers. */
controlProps: UnavailableControlProps;
/**
* Render as a *sibling* of the control — inside it the reason would be
* appended to the accessible name instead of the description.
*/
reasonNode: ReactNode;
}
/**
* Makes a control unavailable without hiding it from assistive technology.
*
* `disabled` takes an element out of the tab order *and* out of the
* accessibility tree, so the `title` explaining why it cannot be used is
* announced to nobody and shown only to a sighted user with a mouse. That is
* backwards: the people who most need the reason are the ones who never get
* it. `aria-disabled` keeps the control focusable and announced, and
* `aria-describedby` hands over the reason.
*
* The catch is that `aria-disabled` is advisory — it does not block clicks or
* Enter/Space the way `disabled` does. This hook therefore returns the guards
* along with the attributes, so a call site cannot take the announcement
* without the guard. Handlers that a form can reach without going through the
* control (Enter inside a text field submits the form) still have to guard
* themselves.
*/
export function useUnavailable({
unavailable,
reason,
onClick,
onKeyDown,
}: {
unavailable: boolean;
reason: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
onKeyDown?: KeyboardEventHandler<HTMLButtonElement>;
}): UnavailableControl {
const reasonId = `${useId()}unavailable`;
if (!unavailable) {
return { controlProps: { onClick, onKeyDown }, reasonNode: null };
}
return {
controlProps: {
"aria-disabled": true,
"aria-describedby": reasonId,
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
onKeyDown: (e) => {
if (!ACTIVATION_KEYS.has(e.key)) {
onKeyDown?.(e);
return;
}
// Suppress the default action before it can become a click, submit a
// form, or scroll the page.
e.preventDefault();
e.stopPropagation();
},
},
reasonNode: (
<span id={reasonId} className="sr-only">
{reason}
</span>
),
};
}