import { expect, mocked, spyOn, userEvent, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { Box } from "../containers/Box"; import { Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { Story, neutralAndAccents } from "../story-components/Story"; import { PressableBox } from "./PressableBox"; type ThisStory = StoryObj; const VARIANTS = ["tonal", "filled", "outlined", "soft"] as const; function inkOf(variant: (typeof VARIANTS)[number]): string { if (variant === "tonal") return "text-on-tonal"; if (variant === "filled") return "text-on-accent"; return "text-sharp"; } export default { title: "alouette/Actions/PressableBox", component: PressableBox, parameters: { componentSubtitle: "Foundation pressable container that drives all alouette button-like components.", }, } satisfies Meta; export const PreviewStory: ThisStory = { parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, render: () => ( Press me ), }; export const Variants: ThisStory = { render: () => ( {VARIANTS.map((variant) => ( {variant} ))} {neutralAndAccents.map((accent) => ( {VARIANTS.map((variant) => ( {variant} ))} ))} ), }; export const Tests: ThisStory = { render: () => ( Plain Linked Menu row ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect( canvas.getByRole("button", { name: "Plain" }), ).not.toHaveAttribute("href"); const link = canvas.getByRole("link", { name: "Linked" }); await expect(link.tagName).toBe("A"); await expect(link).toHaveAttribute("href", "/destination"); // A caller's own role still wins over the href-derived one, as MenuItem // needs on a row that keeps announcing itself as a menu item. await expect( canvas.getByRole("menuitem", { name: "Menu row" }), ).toHaveAttribute("href", "/destination"); }, }; // `outlineColor` is reported as `rgb(...)`; a token is hex (or oklch), so it // is resolved through a probe element. function toRgb(color: string, root: HTMLElement): string { const probe = document.createElement("span"); probe.style.color = color; root.append(probe); const resolved = getComputedStyle(probe).color; probe.remove(); return resolved; } export const FocusRing: ThisStory = { render: () => ( {VARIANTS.map((variant) => ( {variant} ))} inset ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const tonal = canvas.getByRole("button", { name: "tonal" }); const accent = toRgb( getComputedStyle(tonal).getPropertyValue("--color-accent"), canvasElement, ); const restGround = getComputedStyle(tonal).backgroundColor; // A mouse click leaves the focus behind, never a changed ground. (The ring // cannot be asserted here: the synthetic click counts as keyboard input // for `:focus-visible`.) await userEvent.click(tonal); await expect(tonal).toHaveFocus(); await userEvent.unhover(tonal); await expect(getComputedStyle(tonal).backgroundColor).toBe(restGround); // Tab brings the ring: inset on tonal and soft, 2px outside on filled and // outlined, in the accent ink everywhere. await userEvent.tab({ shift: true }); await userEvent.tab(); await expect(tonal).toHaveFocus(); await expect(getComputedStyle(tonal).outlineWidth).toBe("2px"); await expect(getComputedStyle(tonal).outlineOffset).toBe("-2px"); await expect(getComputedStyle(tonal).outlineColor).toBe(accent); await expect(getComputedStyle(tonal).backgroundColor).toBe(restGround); for (const [name, offset] of [ ["filled", "2px"], ["outlined", "2px"], ["soft", "-2px"], ]) { await userEvent.tab(); const button = canvas.getByRole("button", { name }); await expect(button).toHaveFocus(); await expect(getComputedStyle(button).outlineWidth).toBe("2px"); await expect(getComputedStyle(button).outlineOffset).toBe(offset); await expect(getComputedStyle(button).outlineColor).toBe(accent); } // An explicit `inset` for a pressable whose parent clips. await userEvent.tab(); const inset = canvas.getByRole("button", { name: "inset" }); await expect(inset).toHaveFocus(); await expect(getComputedStyle(inset).outlineOffset).toBe("-2px"); }, }; export const TonalGroundWarning: ThisStory = { // The warning fires as the pressable mounts, before `play` could spy on it. beforeEach: () => { const warn = spyOn(console, "warn").mockImplementation(() => {}); return () => { warn.mockRestore(); }; }, render: () => ( Dissolves Neutral On highlight Dissolves too Soft ), play: async () => { const warnings = mocked(console.warn).mock.calls.filter(([message]) => String(message).includes("tonal pressable rests on its own ground"), ); // "Dissolves" and "Dissolves too"; never the neutral pressable on an // accented surface, the accented one on a white panel, or a soft one. // A production build (`storybook build`, as Chromatic runs) drops the check. await expect(warnings).toHaveLength( process.env.NODE_ENV === "production" ? 0 : 2, ); }, };