184 lines
8.4 KiB
TypeScript
184 lines
8.4 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import { SIDEBAR_COLLAPSE_DURATION_MS } from "./components/sidebarItemNavigation";
|
|
import { SIDEBAR_RAIL_WIDTH, SIDEBAR_SURFACE_WIDTH } from "./components/AppSidebar.logic";
|
|
|
|
/**
|
|
* The sidebar-collapse behaviour lives mostly in CSS, so these are contract
|
|
* tests over app.css rather than over rendered markup.
|
|
*
|
|
* The rules that matter: the sidebar surface stays 260px wide in every state,
|
|
* entering /item/* snaps the real layout from a 260px to a 64px offset in one
|
|
* pass, and the only animated property is `transform`. Transform is the one
|
|
* choice Chrome runs on the compositor — a clipping-path version of this was
|
|
* swallowed whole by the detail page's render (267ms and 209ms frozen frames).
|
|
*/
|
|
const css = readFileSync(fileURLToPath(new URL("./app.css", import.meta.url)), "utf8");
|
|
|
|
/**
|
|
* Body of the rule whose selector list starts at `selector`, searched from
|
|
* after the reduced-motion block so the `transition: none` overrides in there
|
|
* are never mistaken for the real declarations.
|
|
*/
|
|
function ruleBody(selector: string): string {
|
|
// This anchor is intentionally after reduced motion, whose selector list
|
|
// also contains `.sidebar-main-stage` but only declares transition: none.
|
|
const from = css.indexOf("@media (forced-colors: active)");
|
|
expect(from, "forced-colors anchor must remain after reduced motion").toBeGreaterThan(-1);
|
|
const start = css.indexOf(selector, from);
|
|
expect(start, `missing rule: ${selector}`).toBeGreaterThan(-1);
|
|
return css.slice(css.indexOf("{", start), css.indexOf("}", start));
|
|
}
|
|
|
|
/** Body of the first `@media (prefers-reduced-motion: reduce)` block. */
|
|
function reducedMotionBlock(): string {
|
|
const start = css.indexOf("@media (prefers-reduced-motion: reduce)");
|
|
expect(start).toBeGreaterThan(-1);
|
|
let depth = 0;
|
|
for (let i = css.indexOf("{", start); i < css.length; i++) {
|
|
if (css[i] === "{") depth++;
|
|
else if (css[i] === "}" && --depth === 0) return css.slice(start, i + 1);
|
|
}
|
|
throw new Error("unterminated prefers-reduced-motion block");
|
|
}
|
|
|
|
describe("sidebar collapse CSS", () => {
|
|
it("animates transform and nothing else on the surface", () => {
|
|
const surface = ruleBody(".sidebar-surface {");
|
|
expect(surface).toContain("overflow: hidden");
|
|
expect(surface).toContain("transform: translateX(0)");
|
|
expect(surface).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
|
|
// Any layout property here would put the document back on the reflow path,
|
|
// and any non-composited property would be starved by the route render.
|
|
expect(surface).not.toMatch(/\b(width|margin-left|left|clip-path|grid-template-columns)\s*:/);
|
|
});
|
|
|
|
it("slides the frame left by exactly the 196px it has to give up", () => {
|
|
const travel = SIDEBAR_SURFACE_WIDTH - SIDEBAR_RAIL_WIDTH;
|
|
expect(ruleBody('.sidebar-surface[data-collapsed="true"] {')).toContain(
|
|
`transform: translateX(-${travel}px)`,
|
|
);
|
|
});
|
|
|
|
it("counter-translates the contents by the same distance so nothing moves on screen", () => {
|
|
const inner = ruleBody(".sidebar-inner {");
|
|
expect(inner).toContain("transform: translateX(0)");
|
|
expect(inner).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
|
|
expect(ruleBody('.sidebar-surface[data-collapsed="true"] .sidebar-inner {')).toContain(
|
|
`transform: translateX(${SIDEBAR_SURFACE_WIDTH - SIDEBAR_RAIL_WIDTH}px)`,
|
|
);
|
|
});
|
|
|
|
it("moves the main stage on the same compositor timeline as the sidebar", () => {
|
|
const main = ruleBody(".sidebar-main-stage {");
|
|
expect(main).toContain("transform: none");
|
|
expect(main).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
|
|
const entering = ruleBody('.sidebar-main-stage[data-sidebar-target-collapsed="true"]:not(');
|
|
expect(entering).toContain("transform: translateX(196px)");
|
|
expect(entering).toContain("transition: none");
|
|
expect(css).toMatch(
|
|
/\.sidebar-main-stage:not\([\s\S]*?data-sidebar-target-collapsed="true"[\s\S]*?\)\[data-sidebar-visual-collapsed="true"\]\s*\{\s*transform:\s*translateX\(-196px\);\s*transition:\s*none/,
|
|
);
|
|
});
|
|
|
|
it("uses the shared collapse duration on a compositor-friendly ease", () => {
|
|
const duration = css.match(/--duration-sidebar-collapse:\s*(\d+)ms/);
|
|
expect(duration).not.toBeNull();
|
|
expect(Number(duration![1])).toBe(SIDEBAR_COLLAPSE_DURATION_MS);
|
|
expect(css).toMatch(/--ease-sidebar-collapse:\s*cubic-bezier\(/);
|
|
});
|
|
|
|
it("cross-fades and translates sidebar contents rather than resizing them", () => {
|
|
expect(ruleBody(".sidebar-fade {")).toMatch(
|
|
/transition:\s*opacity var\(--duration-sidebar-collapse\)/,
|
|
);
|
|
expect(ruleBody(".sidebar-row-shift {")).toMatch(
|
|
/transition:\s*transform var\(--duration-sidebar-collapse\)/,
|
|
);
|
|
expect(ruleBody('.sidebar-surface[data-collapsed="true"] .sidebar-row-shift {')).toContain(
|
|
"transform: translateX(var(--sidebar-row-shift, 0px))",
|
|
);
|
|
});
|
|
|
|
it("no longer animates any layout property, or any main-thread-only one", () => {
|
|
// The old `.sidebar-transition` / `.main-transition` pair is gone; nothing
|
|
// in the sidebar or the main column interpolates geometry any more.
|
|
expect(css).not.toContain(".sidebar-transition");
|
|
expect(css).not.toContain(".main-transition");
|
|
expect(css).not.toMatch(/transition:\s*margin-left/);
|
|
expect(css).not.toMatch(/transition:\s*clip-path/);
|
|
// The bespoke sidebar View Transition snapshot rules are gone.
|
|
expect(css).not.toContain("app-sidebar)");
|
|
expect(css).not.toContain("data-sidebar-collapse-transition");
|
|
expect(css).not.toContain(":active-view-transition");
|
|
});
|
|
|
|
it("makes reduced motion an instant state change", () => {
|
|
const block = reducedMotionBlock();
|
|
expect(block).toContain("--duration-sidebar-collapse: 0ms");
|
|
for (const selector of [
|
|
".sidebar-surface,",
|
|
".sidebar-inner,",
|
|
".sidebar-fade,",
|
|
".sidebar-row-shift,",
|
|
".sidebar-main-stage,",
|
|
".impersonation-banner",
|
|
]) {
|
|
expect(block).toContain(selector);
|
|
}
|
|
});
|
|
|
|
it("snaps the impersonation banner's margin instead of animating it", () => {
|
|
const banner = ruleBody(".impersonation-banner {");
|
|
expect(banner).toContain("margin-left: var(--app-sidebar-offset)");
|
|
// Animating margin would put the document back on the per-frame layout path.
|
|
expect(banner).not.toContain("transition: margin-left");
|
|
expect(banner).not.toMatch(/transition:[^;]*\b(margin|width|left)\b/);
|
|
});
|
|
|
|
it("carries the impersonation banner's edge with a compensating transform", () => {
|
|
// The banner renders outside Layout, so it mirrors `.sidebar-main-stage`
|
|
// off root attributes: hold the previous visual offset with no transition,
|
|
// then release to zero over the shared sidebar duration/easing.
|
|
expect(ruleBody(".impersonation-banner {")).not.toContain("transform:");
|
|
|
|
const entering = ruleBody(
|
|
':root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])',
|
|
);
|
|
expect(entering).toContain("transform: translateX(196px)");
|
|
expect(entering).toContain("transition: none");
|
|
|
|
const leaving = ruleBody(
|
|
':root:not([data-sidebar-collapsed="true"])[data-sidebar-visual-collapsed="true"]',
|
|
);
|
|
expect(leaving).toContain("transform: translateX(-196px)");
|
|
expect(leaving).toContain("transition: none");
|
|
|
|
// The settled rule animates transform on the same tokens as the sidebar.
|
|
expect(css).toMatch(
|
|
/\.impersonation-banner \{\s*transition: transform var\(--duration-sidebar-collapse\) var\(--ease-sidebar-collapse\);/,
|
|
);
|
|
});
|
|
|
|
it("compensates the banner on the same breakpoint that moves its margin", () => {
|
|
// `html[data-text-scale]` re-bases rem, so 64rem and 1024px diverge at large
|
|
// text. The compensation must follow `--app-sidebar-offset`'s own query.
|
|
const nearestQueryBefore = (needle: string) => {
|
|
const at = css.indexOf(needle);
|
|
expect(at, `missing: ${needle}`).toBeGreaterThan(-1);
|
|
const queryAt = css.lastIndexOf("@media", at);
|
|
return css.slice(queryAt, css.indexOf("{", queryAt)).trim();
|
|
};
|
|
|
|
expect(nearestQueryBefore("--app-sidebar-offset: 64px")).toBe("@media (min-width: 1024px)");
|
|
expect(
|
|
nearestQueryBefore(
|
|
':root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])',
|
|
),
|
|
).toBe("@media (min-width: 1024px)");
|
|
});
|
|
});
|