Files
siftlode/frontend/src/components/ui/Button.dom.test.tsx
T
peter 84db570951 fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
2026-10-25 04:01:43 +01:00

286 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
//
// The shared <Button> primitive. These lock the load-bearing contract the migrated call sites rely
// on: variant/size paint (the DESIGN.md B1 ladder and B3 layers), the loading state (disabled +
// spinner + aria-busy), the pressed toggle, and the type="button" default that stops an action button from
// submitting a surrounding form. A glyph-only button is `IconButton` (its own test).
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, render, screen, cleanup, fireEvent } from "@testing-library/react";
import { Button } from "./Button";
afterEach(cleanup);
describe("Button primitive", () => {
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("state-danger");
expect(btn.className).toContain("h-7");
});
it("puts every rung on an explicit height, so a toolbar row lines up (B1)", () => {
for (const [size, h] of [
["xs", "h-6"],
["sm", "h-7"],
["md", "h-8"],
] as const) {
const { unmount } = render(<Button size={size}>X</Button>);
const cls = screen.getByRole("button", { name: "X" }).className.split(/\s+/);
expect(cls).toContain(h);
expect(cls).toContain("rounded-lg");
expect(cls.filter((c) => c.startsWith("py-"))).toEqual([]);
unmount();
}
});
it("hovers with a state layer, never an opacity dim or a card fill (B3)", () => {
for (const variant of ["outline", "ghost", "outline-danger", "danger-soft"] as const) {
const { unmount } = render(<Button variant={variant}>X</Button>);
const cls = screen.getByRole("button", { name: "X" }).className;
expect(cls).toContain("state-layer");
expect(cls).not.toMatch(/hover:(bg-card|bg-surface|opacity)/);
unmount();
}
});
it("a lit cycle paints the pressed look without announcing a toggle", () => {
render(
<Button variant="overlay" lit>
Loop: All
</Button>,
);
const btn = screen.getByRole("button", { name: "Loop: All" });
expect(btn.hasAttribute("aria-pressed")).toBe(false);
expect(btn.hasAttribute("data-lit")).toBe(true);
expect(btn.className).toContain("data-lit:bg-accent");
});
it("a toggle announces aria-pressed and paints the pressed state", () => {
const { rerender } = render(
<Button variant="outline" pressed={false}>
Default
</Button>,
);
const btn = screen.getByRole("button", { name: "Default" });
expect(btn.getAttribute("aria-pressed")).toBe("false");
expect(btn.className).toContain("aria-pressed:bg-accent");
rerender(
<Button variant="outline" pressed>
Default
</Button>,
);
expect(btn.getAttribute("aria-pressed")).toBe("true");
});
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();
});
// 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-2.5");
});
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 and the 24 floor, a link variant too", () => {
// The tablet comfort contract: the minimum lives in the primitive, so no call site can opt
// out of it by accident. A link variant was once excluded as "text in a sentence", but every
// one stands alone, and an audit measured them at 16–20px.
const { unmount } = render(<Button>Tap</Button>);
expect(screen.getByRole("button", { name: "Tap" }).className).toContain("touch-target");
unmount();
for (const variant of ["link", "link-muted"] as const) {
const { unmount: u } = render(<Button variant={variant}>Read more</Button>);
const cls = screen.getByRole("button", { name: "Read more" }).className;
expect(cls).toContain("touch-target");
expect(cls).toContain("min-h-6");
u();
}
});
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. 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.
// 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 · disabled while it holds the focus", () => {
// A focused button that turns natively disabled loses the focus to <body> in Chrome (measured)
// — and ~60 call sites disable the very button that was just pressed while its mutation runs.
// jsdom does not move the focus, so the CONTRACT is what is pinned: while focused, the button is
// unavailable by aria-disabled + a click guard, never by the native attribute.
it("keeps the native attribute off while focused, and blocks activation", () => {
const onClick = vi.fn();
const { rerender } = render(<Button onClick={onClick}>Save</Button>);
const btn = screen.getByRole("button", { name: "Save" });
act(() => btn.focus());
rerender(
<Button onClick={onClick} disabled>
Save
</Button>,
);
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("also holds when the window has no focus and no focus event ever fired", () => {
// Chrome, with another app in front: `.focus()` moves activeElement and fires nothing. The
// button asks the document at render time, so it does not rely on the event (measured).
const { rerender } = render(<Button onClick={() => {}}>Next</Button>);
const btn = screen.getByRole("button", { name: "Next" });
const active = vi.spyOn(document, "activeElement", "get").mockReturnValue(btn);
rerender(
<Button onClick={() => {}} disabled>
Next
</Button>,
);
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
active.mockRestore();
});
it("keeps a caller's aria-disabled focusable and inert to a click (unavailable with a reason)", () => {
const onClick = vi.fn();
render(
<Button onClick={onClick} aria-disabled>
Run all
</Button>,
);
const btn = screen.getByRole("button", { name: "Run all" });
expect(btn.hasAttribute("disabled")).toBe(false);
expect(btn.getAttribute("aria-disabled")).toBe("true");
fireEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
it("is natively disabled again once the focus is elsewhere", () => {
render(
<Button onClick={() => {}} disabled>
Save
</Button>,
);
const btn = screen.getByRole("button", { name: "Save" });
expect(btn.hasAttribute("disabled")).toBe(true);
});
});
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 an icon with no children renders no text and no name */}
<Button icon={<span />} data-testid="bad-c" />
{/* @ts-expect-error the icon-only sizes moved to IconButton */}
<Button size="icon" label="gone" />
<Button icon={<span />} label="by label" />
<Button icon={<span />} aria-label="by aria-label" />
<Button icon={<span />} title="by title" />
<Button>plain text needs none</Button>
{/* @ts-expect-error a toggle states itself through `pressed`, which also paints it (B3) */}
<Button aria-pressed>raw toggle</Button>
<Button pressed>toggle</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();
});
});