import { expect, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { GlobeRegularIcon } from "alouette-icons/phosphor-icons/Globe"; import { LockRegularIcon } from "alouette-icons/phosphor-icons/Lock"; import { UsersRegularIcon } from "alouette-icons/phosphor-icons/Users"; import type { ReactNode } from "react"; import type { Accent } from "../../core/AlouetteConfig"; import { View } from "../primitives/View"; import { Story } from "../story-components/Story"; import { RadioCard } from "./RadioCard"; import { RadioCardGroup, type RadioCardGroupVariant } from "./RadioCardGroup"; type ThisStory = StoryObj; export default { title: "alouette/Inputs/RadioCardGroup", component: RadioCardGroup, parameters: { componentSubtitle: "Single-choice card list: each option is a large pressable card with an icon, a title, a description and a radio indicator.", }, argTypes: { disabled: { control: "boolean" }, variant: { control: "inline-radio", options: ["tonal", "outlined"], }, layout: { control: "inline-radio", options: ["list", "stack"] }, accent: { control: "select", options: [undefined, "brand", "danger", "info", "success", "warning"], }, }, } satisfies Meta; function VisibilityCards(): ReactNode { return ( <> } label="Public" description="Accessible à quiconque a ton lien" /> } label="Partagé" description="Accessible aux personnes invitées" /> } label="Privé" description="Accessible à toi seul" /> ); } export const PreviewRadioCardGroupStory: ThisStory = { name: "RadioCardGroup Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, render: (args) => ( ), }; export const StackRadioCardGroupStory: ThisStory = { name: "RadioCardGroup Stack", render: (args) => ( ), }; function RadioCardGroupVariant({ accent, variant, }: { accent?: Accent; variant: RadioCardGroupVariant; }): ReactNode { return ( } label="Without description" /> } label="Disabled option" description="Not selectable" /> } label="Public" description="Tout le monde" /> } label="Partagé" description="Sur invitation" /> } label="Privé" description="Toi seul" /> } label="Group disabled" description="Unselected card" /> } label="Group disabled" description="Selected card" /> ); } export const VariantsRadioCardGroupStory: ThisStory = { name: "RadioCardGroup Variants", render: () => ( ), }; function getRing(card: HTMLElement): HTMLElement { const ring = [...card.querySelectorAll("div")].find( (element) => getComputedStyle(element).borderTopWidth === "2px", ); if (!ring) throw new Error("No radio ring in the card"); return ring; } function getRingColor(card: HTMLElement): string { return getComputedStyle(getRing(card)).borderTopColor; } export const TestsRadioCardGroupStory: ThisStory = { name: "RadioCardGroup Tests", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const [group] = canvas.getAllByRole("radiogroup"); const publicCard = canvas.getByRole("radio", { name: "Public" }); const sharedCard = canvas.getByRole("radio", { name: "Partagé" }); await expect(group).toBeInTheDocument(); await expect(publicCard).toHaveAttribute("aria-checked", "true"); await expect(sharedCard).toHaveAttribute("aria-checked", "false"); await expect( publicCard.getBoundingClientRect().height, ).toBeGreaterThanOrEqual(44); // The ring is the accent's interactive foreground, selected or not: the // unselected card drops to the neutral theme, its ring keeps the accent. const restRingColor = getRingColor(sharedCard); await expect(getRingColor(publicCard)).toBe(restRingColor); const dangerSelected = canvas.getByRole("radio", { name: "Danger selected", }); const dangerUnselected = canvas.getByRole("radio", { name: "Danger unselected", }); await expect(getRingColor(dangerUnselected)).toBe( getRingColor(dangerSelected), ); await expect(getRingColor(dangerUnselected)).not.toBe(restRingColor); // It reacts to the card's state. sharedCard.focus(); await waitFor(() => expect(getRingColor(sharedCard)).not.toBe(restRingColor), ); sharedCard.blur(); sharedCard.click(); await waitFor(() => expect(sharedCard).toHaveAttribute("aria-checked", "true"), ); await expect(publicCard).toHaveAttribute("aria-checked", "false"); }, }; export const TestsStackRadioCardGroupStory: ThisStory = { name: "RadioCardGroup Stack Tests", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const publicCard = canvas.getByRole("radio", { name: "Public" }); const sharedCard = canvas.getByRole("radio", { name: "Partagé" }); const privateCard = canvas.getByRole("radio", { name: "Privé" }); const publicRect = publicCard.getBoundingClientRect(); const sharedRect = sharedCard.getBoundingClientRect(); const privateRect = privateCard.getBoundingClientRect(); await expect(sharedRect.top).toBe(publicRect.top); await expect(sharedRect.left).toBeGreaterThan(publicRect.left); await expect(privateRect.top).toBeGreaterThan(publicRect.top); await expect(privateRect.left).toBe(publicRect.left); await expect(publicRect.height).toBeGreaterThanOrEqual(44); }, }; export const TestsDisabledRadioCardGroupStory: ThisStory = { name: "RadioCardGroup Disabled Tests", render: () => ( } label="Public" description="Accessible à quiconque a ton lien" /> } label="Privé" description="Accessible à toi seul" /> } label="Partagé" description="Accessible aux personnes invitées" /> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const publicCard = canvas.getByRole("radio", { name: "Public" }); const privateCard = canvas.getByRole("radio", { name: "Privé" }); const sharedCard = canvas.getByRole("radio", { name: "Partagé" }); await expect(privateCard).toHaveAttribute("aria-disabled", "true"); privateCard.click(); await expect(privateCard).toHaveAttribute("aria-checked", "false"); await expect(publicCard).toHaveAttribute("aria-checked", "true"); // The disabled ring must not take the disabled card's own ground. const privateGround = getComputedStyle(privateCard).backgroundColor; await expect( getComputedStyle(getRing(privateCard)).borderTopColor, ).not.toBe(privateGround); // A selected disabled card keeps both its ring and its dot. await expect(sharedCard).toHaveAttribute("aria-checked", "true"); const sharedGround = getComputedStyle(sharedCard).backgroundColor; const sharedRing = getRing(sharedCard); const sharedRingColor = getComputedStyle(sharedRing).borderTopColor; await expect(sharedRingColor).not.toBe(sharedGround); await expect( getComputedStyle(sharedRing.firstElementChild as HTMLElement) .backgroundColor, ).toBe(sharedRingColor); }, };