183 lines
8.3 KiB
TypeScript
183 lines
8.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import {
|
|
DepthButton,
|
|
FlipButton,
|
|
GrowButton,
|
|
IconButton,
|
|
SendIcon,
|
|
SparkleButton,
|
|
SweepButton,
|
|
flipLabels,
|
|
} from "@/components/Buttons";
|
|
import { ButtonGallery } from "@/components/ButtonGallery";
|
|
|
|
const html = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el);
|
|
const css = readFileSync(path.resolve(__dirname, "../src/styles/buttons.css"), "utf8");
|
|
const globals = readFileSync(path.resolve(__dirname, "../src/app/globals.css"), "utf8");
|
|
|
|
/** Body of the first rule whose selector list matches exactly. */
|
|
function rule(selector: string): string {
|
|
const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
const m = css.match(new RegExp(`(?:^|\\n|,)\\s*${esc}\\s*\\{([^}]*)\\}`));
|
|
if (!m) throw new Error(`no rule for ${selector}`);
|
|
return m[1];
|
|
}
|
|
|
|
describe("SparkleButton disabled", () => {
|
|
it("keeps full opacity and a solid readable face", () => {
|
|
const body = rule(".btn-spark:disabled");
|
|
expect(body).toMatch(/opacity:\s*1\b/);
|
|
expect(body).toContain("var(--tile-3)");
|
|
expect(body).toContain("var(--ink-2)");
|
|
expect(body).toContain("not-allowed");
|
|
for (const f of readdirSync(path.resolve(__dirname, "../src/styles"))) {
|
|
expect(readFileSync(path.resolve(__dirname, "../src/styles", f), "utf8")).not.toMatch(/(^|[\s,}])button:disabled\s*\{[^}]*opacity/);
|
|
}
|
|
expect(html(createElement(SparkleButton, { label: "Sign in", disabled: true }))).toContain("disabled");
|
|
});
|
|
});
|
|
|
|
describe("legacy.css", () => {
|
|
it("is deleted and no longer imported, and no global button margin rule is left", () => {
|
|
expect(existsSync(path.resolve(__dirname, "../src/styles/legacy.css"))).toBe(false);
|
|
expect(readFileSync(path.resolve(__dirname, "../src/app/layout.tsx"), "utf8")).not.toContain("legacy.css");
|
|
expect(globals).not.toMatch(/(^|[\s,}])button\s*\{[^}]*margin/);
|
|
});
|
|
});
|
|
|
|
describe("FlipButton states", () => {
|
|
it("ready: POST plus send icon, flips to PUBLISH with the icon", () => {
|
|
expect(flipLabels("ready")).toEqual({ front: "Post", back: "Publish", icon: true });
|
|
const out = html(createElement(FlipButton, { state: "ready" }));
|
|
expect(out).toContain('class="btn-flip"');
|
|
expect(out).not.toContain("disabled");
|
|
expect(out.match(/<svg/g)).toHaveLength(2);
|
|
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Publish/);
|
|
});
|
|
|
|
it("disabled: native disabled, flips to WRITE FIRST, no icon", () => {
|
|
const out = html(createElement(FlipButton, { state: "disabled" }));
|
|
expect(out).toContain('disabled=""');
|
|
expect(out).toMatch(/btn-flip-front">Post</);
|
|
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Write first</);
|
|
expect(out).not.toContain("<svg");
|
|
});
|
|
|
|
it("posting: POSTING, busy, no flip panel", () => {
|
|
const out = html(createElement(FlipButton, { state: "posting" }));
|
|
expect(out).toContain("btn-flip btn-flip-busy");
|
|
expect(out).toContain('aria-busy="true"');
|
|
expect(out).toMatch(/btn-flip-front">Posting</);
|
|
expect(out).not.toContain("btn-flip-back");
|
|
});
|
|
|
|
it("the disabled face is solid graphite and never faded", () => {
|
|
const disabled = rule(".btn-flip:disabled");
|
|
expect(disabled).toMatch(/opacity:\s*1/);
|
|
expect(disabled).toMatch(/background:\s*var\(--tile-3\)/);
|
|
expect(disabled).toMatch(/color:\s*var\(--ink-2\)/);
|
|
expect(css).not.toMatch(/\.btn-flip[^{]*\{[^}]*opacity:\s*0?\.\d/);
|
|
});
|
|
|
|
it("sets text color explicitly for every state, never by inheritance", () => {
|
|
expect(rule(".btn-flip")).toMatch(/color:\s*var\(--bg\)/);
|
|
expect(rule(".btn-flip .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
|
|
expect(rule(".btn-flip .btn-flip-back")).toMatch(/color:\s*var\(--on-accent\)/);
|
|
expect(rule(".btn-flip:disabled .btn-flip-front")).toMatch(/color:\s*var\(--ink-2\)/);
|
|
expect(rule(".btn-flip:disabled .btn-flip-back")).toMatch(/color:\s*var\(--ink\)/);
|
|
expect(rule(".btn-flip.btn-flip-busy:disabled .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
|
|
// Face and text never share a token in any state.
|
|
expect(rule(".btn-flip")).toMatch(/background:\s*var\(--ink\)/);
|
|
expect(rule(".btn-flip.btn-flip-busy:disabled")).toMatch(/background:\s*var\(--ink\)/);
|
|
});
|
|
});
|
|
|
|
describe("the other styles", () => {
|
|
it("GrowButton renders the crimson circle and the label", () => {
|
|
const out = html(createElement(GrowButton, { label: "3 new posts" }));
|
|
expect(out).toContain('class="btn-grow"');
|
|
expect(out).toContain("btn-grow-circle");
|
|
expect(out).toMatch(/btn-grow-text">3 new posts</);
|
|
});
|
|
|
|
it("SweepButton stays filled and pressed when selected", () => {
|
|
expect(html(createElement(SweepButton, { label: "Business", selected: true }))).toContain('class="btn-sweep btn-sweep-on" aria-pressed="true"');
|
|
expect(html(createElement(SweepButton, { label: "Media", selected: false }))).toContain('aria-pressed="false"');
|
|
expect(html(createElement(SweepButton, { label: "Cancel" }))).not.toContain("aria-pressed");
|
|
});
|
|
|
|
it("DepthButton renders label, sublabel and icon", () => {
|
|
const out = html(createElement(DepthButton, { label: "Trending", sublabel: "Hot now", icon: createElement(SendIcon) }));
|
|
expect(out).toContain("btn-depth-icon");
|
|
expect(out).toMatch(/btn-depth-label">Trending</);
|
|
expect(out).toMatch(/btn-depth-sub">Hot now</);
|
|
});
|
|
|
|
it("SparkleButton: sign in has the fold, hexagon and ten sparks; fab has a plus and a name", () => {
|
|
const signin = html(createElement(SparkleButton, { label: "Sign in", type: "submit" }));
|
|
expect(signin).toContain("btn-spark-fold");
|
|
expect(signin).toContain("btn-spark-hex");
|
|
expect(signin.match(/<i><\/i>/g)).toHaveLength(10);
|
|
expect(signin).toContain('type="submit"');
|
|
const fab = html(createElement(SparkleButton, { variant: "fab", label: "New post" }));
|
|
expect(fab).toContain('aria-label="New post"');
|
|
expect(fab).toContain("btn-spark btn-spark-fab");
|
|
expect(fab).not.toContain("btn-spark-fold");
|
|
expect(fab).toContain("btn-spark-plus");
|
|
});
|
|
|
|
it("IconButton is the glossy crimson button with an accessible name", () => {
|
|
const out = html(createElement(IconButton, { label: "Send comment", icon: createElement(SendIcon), shape: "round", size: "sm" }));
|
|
expect(out).toContain('class="btn-gloss btn-gloss-round btn-gloss-sm"');
|
|
expect(out).toContain('aria-label="Send comment"');
|
|
expect(rule(".btn-gloss:disabled")).toMatch(/opacity:\s*1/);
|
|
});
|
|
});
|
|
|
|
describe("button CSS rules", () => {
|
|
it("uses only .btn-* class selectors", () => {
|
|
const selectors = css.replace(/\/\*[\s\S]*?\*\//g, "").match(/\.[a-zA-Z][\w-]*/g) ?? [];
|
|
expect(selectors.filter((s) => !s.startsWith(".btn-"))).toEqual([]);
|
|
});
|
|
|
|
it("does not include the rejected liquid waves or 3D drop buttons", () => {
|
|
expect(css).not.toMatch(/wave|wv/i);
|
|
});
|
|
|
|
it("stops hover and press motion under prefers-reduced-motion", () => {
|
|
const block = css.slice(css.indexOf("@media (prefers-reduced-motion: reduce)"));
|
|
expect(block.length).toBeGreaterThan(0);
|
|
for (const cls of ["btn-flip", "btn-grow", "btn-sweep", "btn-depth", "btn-spark", "btn-gloss"]) {
|
|
expect(block).toContain(`.${cls}`);
|
|
}
|
|
expect(block).toMatch(/transition:\s*none !important/);
|
|
expect(block).toMatch(/animation:\s*none !important/);
|
|
expect(block).toMatch(/transform:\s*none !important/);
|
|
});
|
|
|
|
it("every theme defines the button tokens", () => {
|
|
for (const token of ["--shadow-btn", "--shadow-float", "--neu-d", "--neu-l"]) {
|
|
expect(globals.match(new RegExp(`${token}:`, "g"))).toHaveLength(2);
|
|
}
|
|
expect(globals).toContain("--on-accent:");
|
|
});
|
|
});
|
|
|
|
describe("ButtonGallery story", () => {
|
|
it("shows every style in both themes", () => {
|
|
for (const theme of ["dark", "light"] as const) {
|
|
const out = html(createElement(ButtonGallery, { theme }));
|
|
expect(out).toContain(`data-theme="${theme}"`);
|
|
for (const cls of ["btn-flip", "btn-flip-busy", "btn-grow", "btn-sweep-on", "btn-depth", "btn-spark-fold", "btn-spark-fab", "btn-gloss"]) {
|
|
expect(out).toContain(cls);
|
|
}
|
|
expect(out).toMatch(/Write first/);
|
|
expect(out).toMatch(/Posting/);
|
|
}
|
|
});
|
|
});
|