import { readFileSync } from "node:fs"; import postcss, { type AtRule, type Declaration, type Rule } from "postcss"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import SwatchGroup, { type SwatchData } from "./SwatchGroup"; import PillScroller from "./PillScroller"; /** * Storefront mobile contracts shared by both tiers: * * - Touch targets: on a coarse primary pointer every swatch type is at least * 44px in each direction, while a wider configured inline min-width wins. * jsdom cannot evaluate media queries, so the stylesheet is checked as a * contract and the renderers are checked for the variable it reads. * - Pill rows: the connected row sits in a bounded wrapper that scrolls * horizontally only when the row cannot fit. */ const css = postcss.parse(readFileSync("src/frontend/index.css", "utf8")); const declarations = (rule: Rule, prop: string): Declaration[] => (rule.nodes ?? []).filter( (node): node is Declaration => node.type === "decl" && node.prop === prop, ); const compact = (value: string) => value.replace(/\s+/g, " ").trim(); const swatches: Record = { red: { type: "color", color: "#ff0000", label: "Red" }, duo: { type: "color", color: "#ff0000", color2: "#0000ff", label: "Duo" }, linen: { type: "image", image: "https://shop.example/linen.png", color: "#000000", label: "Linen", }, "8": { type: "number", number: "8", color: "", label: "8" }, s: { type: "button", color: "", label: "S" }, }; function createSelect(values: string[]): HTMLSelectElement { const select = document.createElement("select"); select.name = "attribute_pa_mixed"; for (const value of ["", ...values]) { const option = document.createElement("option"); option.value = value; option.textContent = value || "Choose"; select.append(option); } document.body.append(select); return select; } async function renderGroup( layout: string, data: Record = swatches, ) { const values = Object.keys(data); const result = render( , ); await waitFor(() => expect(result.container.querySelectorAll('[role="radio"]')).toHaveLength( values.length, ), ); return result; } afterEach(() => { cleanup(); document.body.innerHTML = ""; vi.unstubAllGlobals(); }); describe("coarse-pointer touch targets", () => { it("sets a 44px floor for every swatch only on coarse primary pointers", () => { const media: AtRule[] = []; css.walkAtRules("media", (rule) => { if (compact(rule.params) === "(pointer: coarse)") media.push(rule); }); expect(media).toHaveLength(1); const rules: Rule[] = []; media[0].walkRules((rule) => { rules.push(rule); }); const containerRule = rules.find( (rule) => rule.selector === ".b3-wvs-swatches-container", ); expect( declarations(containerRule!, "--b3-wvs-touch-target")[0]?.value, ).toBe("44px"); const swatchRule = rules.find((rule) => rule.selectors.includes( ".b3-wvs-swatches-container [data-b3-wvs-swatch]", ), ); const minWidth = declarations(swatchRule!, "min-width")[0]; expect(compact(minWidth.value)).toBe( "max( var(--b3-wvs-swatch-min-width, 0px), var(--b3-wvs-touch-target) )", ); expect(minWidth.important).toBe(true); }); it("lets every swatch min-height rule honor the touch floor", () => { const minHeights: Declaration[] = []; css.walkRules((rule) => { if (!rule.selector.includes("[data-b3-wvs-swatch]")) return; minHeights.push(...declarations(rule, "min-height")); }); expect(minHeights.length).toBeGreaterThanOrEqual(2); for (const declaration of minHeights) { expect(compact(declaration.value)).toBe( "max( var(--b3-wvs-swatch-height, 0px), var(--b3-wvs-touch-target, 0px) )", ); expect(declaration.important).toBe(true); } }); for (const layout of ["wrap", "grid", "flow", "slider", "pill"]) { it(`mirrors every inline min-width into the touch-floor variable (${layout})`, async () => { const { container } = await renderGroup(layout); const swatchElements = Array.from( container.querySelectorAll("[data-b3-wvs-swatch]"), ); expect(swatchElements.length).toBe(Object.keys(swatches).length); for (const swatch of swatchElements) { const declared = swatch.style.minWidth; expect(swatch.style.getPropertyValue("--b3-wvs-swatch-min-width")).toBe( declared, ); } }); } }); describe("pill row overflow", () => { const sizes = Object.fromEntries( Array.from({ length: 16 }, (_, index) => [ String(index + 1), { type: "number", number: String(index + 1), label: String(index + 1) }, ]), ) as Record; it("wraps the connected pill row in the bounded scroller", async () => { const { container } = await renderGroup("pill", sizes); const radios = container.querySelectorAll('[role="radio"]'); const scroller = container.querySelector(".b3-wvs-pill-scroller"); expect(scroller).not.toBeNull(); expect(scroller!.children).toHaveLength(1); radios.forEach((radio) => expect(scroller!.contains(radio)).toBe(true)); }); it("styles the scroller to shrink below the row and scroll only when overflowing", () => { const rules = new Map(); css.walkRules((rule) => { rules.set(rule.selector, rule); }); const base = rules.get(".b3-wvs-swatches-container .b3-wvs-pill-scroller")!; expect(declarations(base, "display")[0]?.value).toBe("grid"); expect( compact(declarations(base, "grid-template-columns")[0]?.value ?? ""), ).toBe("minmax(0, max-content)"); expect(declarations(base, "overflow-x")).toHaveLength(0); const row = rules.get( ".b3-wvs-swatches-container .b3-wvs-pill-scroller > *", )!; expect(declarations(row, "width")[0]?.value).toBe("max-content"); const overflowing = rules.get( ".b3-wvs-swatches-container .b3-wvs-pill-scroller[data-b3-wvs-overflowing]", )!; expect(declarations(overflowing, "overflow-x")[0]?.value).toBe("auto"); expect(declarations(overflowing, "overflow-y")[0]?.value).toBe("hidden"); expect(declarations(overflowing, "padding")[0]?.value).toBe("6px"); }); it("marks the scroller overflowing only while the row is wider than its box", () => { const callbacks: Array<() => void> = []; vi.stubGlobal( "ResizeObserver", class { constructor(callback: () => void) { callbacks.push(callback); } observe() {} disconnect() {} }, ); const { container } = render(
row
, ); const scroller = container.querySelector( ".b3-wvs-pill-scroller", )!; const setWidths = (scrollWidth: number, clientWidth: number) => { Object.defineProperty(scroller, "scrollWidth", { configurable: true, value: scrollWidth, }); Object.defineProperty(scroller, "clientWidth", { configurable: true, value: clientWidth, }); act(() => callbacks.forEach((callback) => callback())); }; expect(scroller.hasAttribute("data-b3-wvs-overflowing")).toBe(false); setWidths(640, 343); expect(scroller.getAttribute("data-b3-wvs-overflowing")).toBe("true"); setWidths(300, 343); expect(scroller.hasAttribute("data-b3-wvs-overflowing")).toBe(false); }); });