diff --git a/app/src/components/ui/Button.test.tsx b/app/src/components/ui/Button.test.tsx new file mode 100644 index 0000000..0a85c43 --- /dev/null +++ b/app/src/components/ui/Button.test.tsx @@ -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( + , + ); + expect(screen.getByRole("button", { name: "Save" })).toBeDisabled(); + }); + + it("stays in the accessibility tree when unavailable, and says why", () => { + render( + , + ); + 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( + , + ); + 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( + , + ); + fireEvent.keyDown(screen.getByRole("button", { name: "Save" }), { + key: "Escape", + }); + expect(onKeyDown).toHaveBeenCalled(); + }); + + it("behaves like an ordinary button when available", () => { + render( + , + ); + const button = screen.getByRole("button", { name: "Save" }); + expect(button).not.toHaveAttribute("aria-disabled"); + expect(button).toHaveAccessibleDescription(""); + fireEvent.click(button); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/ui/Button.tsx b/app/src/components/ui/Button.tsx index 79b8655..be41f79 100644 --- a/app/src/components/ui/Button.tsx +++ b/app/src/components/ui/Button.tsx @@ -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 { 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 = { 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 = { @@ -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 ( - + <> + + {/* Outside the button: inside, the reason would join its accessible name. */} + {reasonNode} + ); } diff --git a/app/src/components/ui/unavailable.tsx b/app/src/components/ui/unavailable.tsx new file mode 100644 index 0000000..b25e7bc --- /dev/null +++ b/app/src/components/ui/unavailable.tsx @@ -0,0 +1,87 @@ +import { + useId, + type KeyboardEventHandler, + type MouseEventHandler, + type ReactNode, +} from "react"; + +/** Keys a native `