@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
import axe from "axe-core";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AppShell,
|
||||
Button,
|
||||
FormField,
|
||||
Input,
|
||||
ThemeProvider,
|
||||
useTheme,
|
||||
} from "../src";
|
||||
|
||||
function ThemeControl() {
|
||||
const { resolvedTheme, toggleTheme } = useTheme();
|
||||
return <Button onClick={toggleTheme}>Current: {resolvedTheme}</Button>;
|
||||
}
|
||||
|
||||
describe("core UI contracts", () => {
|
||||
it("persists and applies theme changes", () => {
|
||||
render(<ThemeProvider defaultTheme="dark"><ThemeControl /></ThemeProvider>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Current: dark" }));
|
||||
expect(document.documentElement).toHaveClass("light");
|
||||
expect(window.localStorage.getItem("jason-ui-theme")).toBe("light");
|
||||
});
|
||||
|
||||
it("associates form help and errors with the control", () => {
|
||||
render(
|
||||
<FormField label="Workspace name" description="Used in navigation" error="Name is required">
|
||||
<Input />
|
||||
</FormField>
|
||||
);
|
||||
const input = screen.getByLabelText("Workspace name");
|
||||
expect(input).toHaveAttribute("aria-invalid", "true");
|
||||
expect(input.getAttribute("aria-describedby")).toContain("-error");
|
||||
});
|
||||
|
||||
it("provides mobile navigation and current-page semantics", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<AppShell
|
||||
brand="Acme"
|
||||
nav={[{ label: "Dashboard", active: true, onClick }]}
|
||||
>
|
||||
Content
|
||||
</AppShell>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open navigation" }));
|
||||
const current = screen.getAllByRole("button", { name: "Dashboard" }).at(-1)!;
|
||||
expect(current).toHaveAttribute("aria-current", "page");
|
||||
fireEvent.click(current);
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("has no serious accessibility violations in representative primitives", async () => {
|
||||
const { container } = render(
|
||||
<main>
|
||||
<FormField label="Email" description="We never share it."><Input type="email" /></FormField>
|
||||
<Button>Save</Button>
|
||||
</main>
|
||||
);
|
||||
const results = await axe.run(container, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] } });
|
||||
expect(results.violations.filter((violation) => violation.impact === "serious" || violation.impact === "critical")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
globalThis.requestAnimationFrame = (callback) => window.setTimeout(callback, 0);
|
||||
globalThis.cancelAnimationFrame = (handle) => window.clearTimeout(handle);
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn();
|
||||
|
||||
class ResizeObserverMock {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserverMock;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import source from "../src/lib/tokens.json";
|
||||
|
||||
function luminance(hex: string) {
|
||||
const channels = hex.match(/[a-f\d]{2}/gi)!.map((value) => parseInt(value, 16) / 255);
|
||||
const linear = channels.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
|
||||
}
|
||||
|
||||
function contrast(foreground: string, background: string) {
|
||||
const values = [luminance(foreground), luminance(background)].sort((a, b) => b - a);
|
||||
return (values[0] + 0.05) / (values[1] + 0.05);
|
||||
}
|
||||
|
||||
describe("brand tokens", () => {
|
||||
it.each(["dark", "light"] as const)("%s theme meets normal-text contrast targets", (mode) => {
|
||||
const colors = source.color[mode];
|
||||
expect(contrast(colors.text, colors.bg)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(colors.muted, colors.bg)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(colors.brandForeground, colors.brand)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(colors.successForeground, colors.success)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(colors.warningForeground, colors.warning)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(colors.dangerForeground, colors.danger)).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrast(colors.infoForeground, colors.info)).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it("keeps generated CSS synchronized with the canonical source", () => {
|
||||
const generated = readFileSync(path.resolve("src/styles/theme.generated.css"), "utf8");
|
||||
expect(generated).toContain("--brand-foreground:");
|
||||
expect(generated).toContain(":root, .dark");
|
||||
expect(generated).toContain(".light");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user