Measured in Chrome before each change: - A dialog opened from a menu lost its focus: the menu row ran its action and closed in one batch, and floating-ui's focus return (a microtask) moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after it opened, where Enter activated the row. MenuItemList now closes first and runs the action a task later; the dialog also captures the row, not a dying menu item, as its restore target. - A side panel opened as a flyout collapsed the moment a dialog opened from inside it, unmounting the row the dialog hands the focus back to. useHoverFocusWithin holds while the focus is inside an aria-modal dialog. - ui/Button's loading state set native disabled; Chrome moved the focus of the pressed button to <body> and never gave it back. Loading is now aria-disabled with a click guard that also cancels implicit submission. - Escape/Enter in the playlist rail's rename field dropped the focus on <body>. The row refocuses from its ref callback when it re-attaches (a post-commit focus sticks; the earlier attempt ran before the button existed). - PlayerModal was the one aria-modal shell without the dialog contract: it is now named by its title, traps Tab, restores the focus to its opener and shares one counted body scroll lock with every dialog.
175 lines
7.9 KiB
TypeScript
175 lines
7.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// S5: the shared <Button> primitive. These lock the load-bearing contract the 120+ migrated call
|
|
// sites rely on: variant/size paint, icon-only accessibility (name must survive when the text is
|
|
// dropped), the loading state (disabled + spinner + aria-busy), and the type="button" default that
|
|
// stops an action button from submitting a surrounding form.
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
|
import { Button } from "./Button";
|
|
|
|
afterEach(cleanup);
|
|
|
|
describe("Button primitive (S5)", () => {
|
|
it("defaults to type=button so it never submits a form", () => {
|
|
render(<Button>Go</Button>);
|
|
expect(screen.getByRole("button", { name: "Go" }).getAttribute("type")).toBe("button");
|
|
});
|
|
|
|
it("applies the variant paint recipe", () => {
|
|
render(
|
|
<Button variant="danger" size="sm">
|
|
Delete
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Delete" });
|
|
expect(btn.className).toContain("btn-danger");
|
|
expect(btn.className).toContain("py-1.5");
|
|
});
|
|
|
|
it("exposes an accessible name for an icon-only button while dropping the text", () => {
|
|
render(
|
|
<Button iconOnly icon={<svg data-testid="ic" />} label="Block channel">
|
|
this text is dropped
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Block channel" });
|
|
expect(btn.getAttribute("title")).toBe("Block channel");
|
|
expect(btn.textContent).not.toContain("this text is dropped");
|
|
expect(btn.querySelector("[data-testid=ic]")).not.toBeNull();
|
|
});
|
|
|
|
it("loading marks aria-busy + aria-disabled, blocks clicks, and swaps the icon", () => {
|
|
const onClick = vi.fn();
|
|
render(
|
|
<Button loading icon={<svg data-testid="ic" />} onClick={onClick}>
|
|
Save
|
|
</Button>,
|
|
);
|
|
const btn = screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
|
|
expect(btn.getAttribute("aria-busy")).toBe("true");
|
|
expect(btn.getAttribute("aria-disabled")).toBe("true");
|
|
// the leading icon is swapped for a spinner
|
|
expect(btn.querySelector("[data-testid=ic]")).toBeNull();
|
|
fireEvent.click(btn);
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// E11 S2, measured in Chrome: a focused button that becomes natively disabled drops the focus to
|
|
// <body> and never gets it back, so every "Something else" press threw a keyboard user out of Home.
|
|
// jsdom has no focus fixup, so what is pinned here is the cause — native `disabled` stays off.
|
|
it("loading keeps the button natively enabled, so it keeps the focus", () => {
|
|
render(<Button loading>Reroll</Button>);
|
|
const btn = screen.getByRole("button", { name: "Reroll" }) as HTMLButtonElement;
|
|
expect(btn.disabled).toBe(false);
|
|
});
|
|
|
|
it("loading blocks clicks even when an explicit disabled={false} is passed", () => {
|
|
// Regression: `disabled ?? loading` let an explicit boolean disabled shadow loading, so a
|
|
// call-site with `disabled={!valid} loading={pending}` (valid → disabled=false) stayed
|
|
// clickable mid-mutation and double-submitted. The contract is: loading always blocks.
|
|
const onClick = vi.fn();
|
|
render(
|
|
<Button disabled={false} loading onClick={onClick}>
|
|
Create
|
|
</Button>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
|
expect(onClick).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a busy submit button cancels the form's submission", () => {
|
|
// Enter in a field submits through a click on the default button; the guard cancels that click.
|
|
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
|
|
render(
|
|
<form onSubmit={onSubmit}>
|
|
<Button type="submit" loading>
|
|
Send
|
|
</Button>
|
|
</form>,
|
|
);
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a non-busy button still calls onClick", () => {
|
|
const onClick = vi.fn();
|
|
render(<Button onClick={onClick}>Go</Button>);
|
|
fireEvent.click(screen.getByRole("button", { name: "Go" }));
|
|
expect(onClick).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("a link gets no button padding, and an explicit size still wins", () => {
|
|
// The size default is variant-dependent (link → "none"). Locking it because the failure is
|
|
// silent: a link that quietly inherits px-3 py-2 reads as a button, and nothing errors.
|
|
const { rerender } = render(<Button variant="link">Show all</Button>);
|
|
let btn = screen.getByRole("button", { name: "Show all" });
|
|
expect(btn.className).toContain("text-accent");
|
|
expect(btn.className).not.toContain("px-3");
|
|
rerender(
|
|
<Button variant="link" size="sm">
|
|
Show all
|
|
</Button>,
|
|
);
|
|
btn = screen.getByRole("button", { name: "Show all" });
|
|
expect(btn.className).toContain("px-3");
|
|
});
|
|
|
|
it("keeps one font-weight utility whatever the variant", () => {
|
|
// Two font-* utilities of equal specificity are decided by stylesheet order, not class order,
|
|
// so a variant must never add a second one (there is no tailwind-merge here).
|
|
for (const variant of ["primary", "link", "glass", "outline"] as const) {
|
|
const { unmount } = render(<Button variant={variant}>X</Button>);
|
|
const cls = screen.getByRole("button", { name: "X" }).className;
|
|
expect(cls.split(/\s+/).filter((c) => c.startsWith("font-")).length).toBe(1);
|
|
unmount();
|
|
}
|
|
});
|
|
|
|
it("carries the coarse-pointer minimum target, except on a link", () => {
|
|
// The 1024x768 comfort contract: the minimum lives in the primitive, so no call site can opt
|
|
// out of it by accident. A link is text in a sentence and is excluded by design.
|
|
const { unmount } = render(<Button>Tap</Button>);
|
|
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
|
|
unmount();
|
|
render(<Button variant="link">Read more</Button>);
|
|
expect(screen.getByRole("button", { name: "Read more" }).className).not.toContain(
|
|
"touch-target",
|
|
);
|
|
});
|
|
|
|
it("fullWidth adds the w-full utility", () => {
|
|
render(<Button fullWidth>Wide</Button>);
|
|
expect(screen.getByRole("button", { name: "Wide" }).className).toContain("w-full");
|
|
});
|
|
});
|
|
|
|
// --- the accessible-name contract, pinned at the TYPE level ------------------------------------
|
|
// A button that renders no text must carry a name, and that rule is a union in Button.tsx rather
|
|
// than a doc comment because the comment version let two nameless icon buttons ship (E10 review
|
|
// round 1). But a type with no test is a type someone widens to silence a stubborn call site:
|
|
// replacing `ButtonProps` with the permissive shape left tsc, eslint, knip and vitest all green
|
|
// (round 2). Each `@ts-expect-error` below FAILS THE BUILD if the error it expects stops happening,
|
|
// so weakening the union turns the whole typecheck lane red.
|
|
//
|
|
// Rendered, not just declared, so eslint sees them used and the file stays one test suite.
|
|
describe("Button accessible-name contract (type level)", () => {
|
|
it("rejects a text-less button with no name, and accepts every named spelling", () => {
|
|
render(
|
|
<>
|
|
{/* @ts-expect-error size="icon" with no label/aria-label/title has no accessible name */}
|
|
<Button size="icon" icon={<span />} data-testid="bad-a" />
|
|
{/* @ts-expect-error iconOnly with no label/aria-label/title has no accessible name */}
|
|
<Button iconOnly icon={<span />} data-testid="bad-b" />
|
|
<Button size="icon" icon={<span />} label="by label" />
|
|
<Button size="icon-sm" icon={<span />} aria-label="by aria-label" />
|
|
<Button iconOnly icon={<span />} title="by title" />
|
|
<Button>plain text needs none</Button>
|
|
</>,
|
|
);
|
|
expect(screen.getByRole("button", { name: "by label" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "by aria-label" })).toBeTruthy();
|
|
expect(screen.getByRole("button", { name: "by title" })).toBeTruthy();
|
|
});
|
|
});
|