افزودن سیستم Motion به تمام بخشهای رابط کاربری
واژگان مشترک حرکت در globals.css: شش @keyframes و شش کلاس .anim-* بههمراه .anim-stagger برای ورود آبشاری. همهٔ مدتزمانها پشت custom property هستند تا یک override کل اپ را تنظیم کند. - packages/ui: Button، Card، Stat، Loading/Empty/ErrorState، UsageBar، Timeline، Modal، Drawer، Table، ToastStack و ArtifactViewer - ریشهٔ هر صفحهٔ (app) با .anim-stagger وارد میشود - AppShell: محتوا fade، منوی کنار stagger، منوهای کشویی slide-up - prefers-reduced-motion مدتزمانها را به ۰.۰۱ms میرساند تا وضعیت پایانی حفظ شود - قرارداد motion در src/test/motion.test.tsx تست میشود تا بازنویسی بعدی آن را بیصدا حذف نکند - تستهای فرانتاند: ۳۵ ← ۴۵
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import {
|
||||
Drawer,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Modal,
|
||||
Stat,
|
||||
Table,
|
||||
Timeline,
|
||||
ToastStack,
|
||||
UsageBar,
|
||||
type TableColumn,
|
||||
} from "@agy/ui";
|
||||
|
||||
/**
|
||||
* The motion contract.
|
||||
*
|
||||
* These assert that animated surfaces actually carry an animation class, and
|
||||
* that every class they carry has keyframes behind it. A `className` with no
|
||||
* matching `@keyframes` is a silent no-op — nobody notices until the app feels
|
||||
* dead — so the vocabulary and the surfaces are checked together.
|
||||
*
|
||||
* Timing and feel are deliberately not asserted here: they are a design
|
||||
* decision, not behaviour, and a test that pins them down only fights the next
|
||||
* tuning pass.
|
||||
*/
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<Row>> = [
|
||||
{ key: "name", header: "name", render: (row) => row.name },
|
||||
];
|
||||
|
||||
const ROWS: Row[] = [
|
||||
{ id: "1", name: "one" },
|
||||
{ id: "2", name: "two" },
|
||||
];
|
||||
|
||||
describe("motion vocabulary", () => {
|
||||
const globals = readFileSync(join(process.cwd(), "src/app/globals.css"), "utf-8");
|
||||
|
||||
it("declares every keyframe the utilities reference", () => {
|
||||
for (const name of [
|
||||
"anim-fade-in",
|
||||
"anim-rise",
|
||||
"anim-scale-in",
|
||||
"anim-slide-up",
|
||||
"anim-drawer-in-start",
|
||||
"anim-drawer-in-start-rtl",
|
||||
]) {
|
||||
expect(globals).toContain(`@keyframes ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps each duration behind a custom property so one override retunes the app", () => {
|
||||
for (const token of ["--anim-fast", "--anim-base", "--anim-slow", "--anim-ease"]) {
|
||||
expect(globals).toContain(token);
|
||||
}
|
||||
// Every utility must read those tokens, not a hard-coded value.
|
||||
expect(globals).not.toMatch(/animation:[^;]*\b\d+ms\b/);
|
||||
});
|
||||
|
||||
it("honours prefers-reduced-motion", () => {
|
||||
expect(globals).toContain("@media (prefers-reduced-motion: reduce)");
|
||||
// Durations collapse, but the end state must still be reachable — so the
|
||||
// animation is kept at a near-zero duration rather than being removed.
|
||||
expect(globals).toMatch(/animation-duration:\s*0\.01ms !important/);
|
||||
expect(globals).toMatch(/transition-duration:\s*0\.01ms !important/);
|
||||
});
|
||||
|
||||
it("never animates a property that is not also stated by the end state", () => {
|
||||
// `both` fill mode is what makes an animation land on its final frame even
|
||||
// when the element is removed mid-flight or never re-rendered.
|
||||
for (const utility of ["anim-fade-in", "anim-rise", "anim-scale-in", "anim-slide-up"]) {
|
||||
expect(globals).toMatch(
|
||||
new RegExp(`\\.${utility}\\s*\\{[^}]*both\\s*;`),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("animated surfaces", () => {
|
||||
it("scales the modal panel in and fades its backdrop", () => {
|
||||
const { container } = render(<Modal open title="title" onClose={() => undefined} />);
|
||||
const panel = container.querySelector(".anim-scale-in");
|
||||
expect(panel).not.toBeNull();
|
||||
// The backdrop is the panel's parent: it owns the dialog role and fades.
|
||||
const backdrop = panel?.parentElement;
|
||||
expect(backdrop).toHaveAttribute("role", "dialog");
|
||||
expect(backdrop?.className).toContain("anim-fade-in");
|
||||
});
|
||||
|
||||
it("slides the drawer in from its edge", () => {
|
||||
const { container } = render(
|
||||
<Drawer open title="details" onClose={() => undefined}>
|
||||
<p>body</p>
|
||||
</Drawer>,
|
||||
);
|
||||
expect(container.querySelector("aside.anim-drawer-start")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("slides each toast up", () => {
|
||||
const { container } = render(
|
||||
<ToastStack toasts={[{ id: "t1", title: "saved" }]} onDismiss={() => undefined} />,
|
||||
);
|
||||
expect(container.querySelector(".anim-slide-up")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("staggers table rows", () => {
|
||||
const { container } = render(
|
||||
<Table columns={columns} rows={ROWS} rowKey={(row) => row.id} />,
|
||||
);
|
||||
expect(container.querySelector("tbody.anim-stagger")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rises the summary and async states", () => {
|
||||
const states = [
|
||||
render(<EmptyState title="nothing here" />),
|
||||
render(<ErrorState title="failed" />),
|
||||
render(<Stat label="credit" value="0" />),
|
||||
];
|
||||
for (const { container } of states) {
|
||||
expect(container.querySelector(".anim-rise")).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("staggers the timeline and transitions the usage fill", () => {
|
||||
const timeline = render(<Timeline items={[{ id: "a", title: "event" }]} />);
|
||||
expect(timeline.container.querySelector("ol.anim-stagger")).not.toBeNull();
|
||||
|
||||
const bar = render(<UsageBar used={40} total={100} />);
|
||||
expect(bar.container.querySelector(".transition-\\[width\\]")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user