import { expect, fn, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import type { ReactNode } from "react"; import type { Accent } from "../../core/AlouetteConfig"; import { Story } from "../story-components/Story"; import { Checkbox } from "./Checkbox"; import { CheckboxGroup } from "./CheckboxGroup"; type ThisStory = StoryObj; export default { title: "alouette/Inputs/CheckboxGroup", component: CheckboxGroup, parameters: { componentSubtitle: "Multi-choice checkbox list. CheckboxGroup owns the checked values; Checkbox children read them via context. A Checkbox outside any group is a standalone boolean.", }, argTypes: { disabled: { control: "boolean" }, accent: { control: "select", options: [undefined, "brand", "danger", "info", "success", "warning"], }, }, } satisfies Meta; export const PreviewCheckboxGroupStory: ThisStory = { name: "CheckboxGroup Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, render: (args) => ( ), }; function CheckboxGroupVariant({ accent }: { accent?: Accent }): ReactNode { return ( ); } function StandaloneCheckboxVariant({ accent }: { accent?: Accent }): ReactNode { return ( ); } export const VariantsCheckboxGroupStory: ThisStory = { name: "CheckboxGroup Variants", render: () => ( ), }; /** The content-box width of the first ancestor that lays `element` out. */ function getContainerWidth(element: HTMLElement): number { let container = element.parentElement; while (container && getComputedStyle(container).display === "contents") { container = container.parentElement; } if (!container) throw new Error("No laid-out container"); const style = getComputedStyle(container); return ( container.getBoundingClientRect().width - Number.parseFloat(style.borderLeftWidth) - Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight) - Number.parseFloat(style.borderRightWidth) ); } export const TestsCheckboxGroupStory: ThisStory = { name: "CheckboxGroup Tests", args: { onValuesChange: fn() }, render: (args) => ( ), play: async ({ args, canvasElement }) => { const canvas = within(canvasElement); const group = canvas.getByRole("group", { name: "Periods" }); const day = within(group).getByRole("checkbox", { name: "Day" }); const week = within(group).getByRole("checkbox", { name: "Week" }); const month = within(group).getByRole("checkbox", { name: "Month" }); await expect(day).toHaveAttribute("aria-checked", "true"); await expect(week).toHaveAttribute("aria-checked", "false"); await expect(month).toHaveAttribute("aria-disabled", "true"); await expect(day.getBoundingClientRect().height).toBeGreaterThanOrEqual(44); // Every row stretches to the group, which shrinks to its widest row. const groupWidth = group.getBoundingClientRect().width; for (const row of [day, week, month]) { await expect(row.getBoundingClientRect().width).toBe(groupWidth); } await expect(groupWidth).toBeLessThan(getContainerWidth(group)); // `self-stretch` on the group makes it, and every row, fill its container. const stretchedGroup = canvas.getByRole("group", { name: "Notifications" }); const stretchedWidth = stretchedGroup.getBoundingClientRect().width; await expect(stretchedWidth).toBe(getContainerWidth(stretchedGroup)); for (const row of within(stretchedGroup).getAllByRole("checkbox")) { await expect(row.getBoundingClientRect().width).toBe(stretchedWidth); } // Keyboard focus (focus-visible) leaves the row's ground alone — a focus // ground would stay lit after a mouse click — and moves the box to the // interactive hover foreground. A programmatic focus() counts as keyboard // input for `:focus-visible`. const dayBox = [...day.querySelectorAll("div")].find( (element) => getComputedStyle(element).borderTopWidth === "2px", ); if (!dayBox) throw new Error("No checkbox box in the row"); const restBoxColor = getComputedStyle(dayBox).backgroundColor; await expect(getComputedStyle(day).backgroundColor).toBe( "rgba(0, 0, 0, 0)", ); day.focus(); await expect(getComputedStyle(day).backgroundColor).toBe( "rgba(0, 0, 0, 0)", ); await waitFor(() => expect(getComputedStyle(dayBox).backgroundColor).not.toBe(restBoxColor), ); day.blur(); week.click(); await waitFor(() => expect(week).toHaveAttribute("aria-checked", "true")); await expect(day).toHaveAttribute("aria-checked", "true"); await expect(args.onValuesChange).toHaveBeenLastCalledWith(["day", "week"]); day.click(); await waitFor(() => expect(day).toHaveAttribute("aria-checked", "false")); await expect(args.onValuesChange).toHaveBeenLastCalledWith(["week"]); month.click(); await expect(month).toHaveAttribute("aria-checked", "false"); }, }; export const TestsStandaloneCheckboxStory: ThisStory = { name: "Checkbox Standalone Tests", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const terms = canvas.getByRole("checkbox", { name: "Accept terms" }); const locked = canvas.getByRole("checkbox", { name: "Locked" }); const danger = canvas.getByRole("checkbox", { name: "Delete my data" }); await expect(terms).toHaveAttribute("aria-checked", "false"); // A standalone row keeps its own width. await expect(terms.getBoundingClientRect().width).toBeLessThan( getContainerWidth(terms), ); // An unchecked box already carries its accent. const getBoxBorderColor = (row: HTMLElement): string | undefined => { const box = [...row.querySelectorAll("div")].find( (element) => getComputedStyle(element).borderTopWidth === "2px", ); return box && getComputedStyle(box).borderTopColor; }; await expect(getBoxBorderColor(danger)).not.toBe(getBoxBorderColor(terms)); terms.click(); await waitFor(() => expect(terms).toHaveAttribute("aria-checked", "true")); terms.click(); await waitFor(() => expect(terms).toHaveAttribute("aria-checked", "false")); await expect(locked).toHaveAttribute("aria-checked", "true"); locked.click(); await expect(locked).toHaveAttribute("aria-checked", "true"); }, };