import { expect, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { DesktopDuotoneIcon, DesktopRegularIcon, } from "alouette-icons/phosphor-icons/Desktop"; import { MoonDuotoneIcon, MoonRegularIcon, } from "alouette-icons/phosphor-icons/Moon"; import { SunDuotoneIcon, SunRegularIcon, } from "alouette-icons/phosphor-icons/Sun"; import type { ReactNode } from "react"; import type { Accent } from "../../core/AlouetteConfig"; import { Text } from "../primitives/Text"; import type { SegmentedVariant } from "../selection/SelectionContext"; import { Story } from "../story-components/Story"; import { RadioButton, type RadioButtonProps } from "./RadioButton"; import { RadioButtonGroup } from "./RadioButtonGroup"; type ThisStory = StoryObj; export default { title: "alouette/Inputs/RadioButtonGroup", component: RadioButtonGroup, parameters: { componentSubtitle: "Segmented button-style single-select. RadioButtonGroup owns the value; RadioButton children read it via context.", }, argTypes: { disabled: { control: "boolean" }, compact: { control: "boolean" }, variant: { control: "inline-radio", options: ["segmented", "icon"] }, accent: { control: "select", options: [undefined, "brand", "danger", "info", "success", "warning"], }, }, } satisfies Meta; export const PreviewRadioButtonGroupStory: ThisStory = { name: "RadioButtonGroup Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, render: (args) => ( ), }; interface ColorModeGroupProps { label: string; variant?: SegmentedVariant; activeAccent?: RadioButtonProps["activeAccent"]; /** Renders `icon` alone, so the glyph keeps one weight throughout. */ withoutActiveIcon?: boolean; /** Badges the light chip, the state its own label would spell out. */ withIndicator?: boolean; } /** * A light/dark/system picker: the canonical icon group, and — as `variant` * `icon` — square chips whose `label` is their accessible name only. The * ready-made control is `ColorModePicker`, built on this. */ function ColorModeGroup({ label, variant, activeAccent, withoutActiveIcon, withIndicator, }: ColorModeGroupProps): ReactNode { return ( } activeIcon={withoutActiveIcon ? undefined : } activeAccent={activeAccent} indicator={withIndicator ? : undefined} /> } activeIcon={withoutActiveIcon ? undefined : } activeAccent={activeAccent} /> } activeIcon={withoutActiveIcon ? undefined : } activeAccent={activeAccent} /> ); } function RadioButtonGroupVariant({ accent }: { accent?: Accent }): ReactNode { return ( ); } function WeekdaysGroup({ compact }: { compact?: boolean }): ReactNode { return ( ); } export const VariantsRadioButtonGroupStory: ThisStory = { name: "RadioButtonGroup Variants", render: () => ( Each accent is shown on the bar itself, then — in the last row — on the glyph alone: no accent on the group, `activeAccent` on the buttons. } activeIcon={} /> } activeIcon={} /> } activeIcon={} /> } activeIcon={} /> } activeIcon={} /> The duotone twin is optional. Drop `activeIcon` and the glyph keeps one weight throughout, the chip carrying the whole affordance. `indicator` badges a chip for a secondary state its label spells out — here the light chip. It sits over the glyph's top-right in a halo of the chip's own fill, adding to the icon and never replacing it, and only the icon variant renders it. ), }; export const TestsRadioButtonGroupStory: StoryObj = { name: "RadioButtonGroup Tests", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const [group, compactGroup, defaultGroup] = canvas.getAllByRole("radiogroup"); if (!group || !compactGroup || !defaultGroup) { throw new Error("expected three radiogroups"); } const uncontrolled = within(group); const day = uncontrolled.getByRole("radio", { name: "Day" }); const week = uncontrolled.getByRole("radio", { name: "Week" }); const month = uncontrolled.getByRole("radio", { name: "Month" }); await expect(day).toHaveAttribute("aria-checked", "true"); await expect(week).toHaveAttribute("aria-checked", "false"); await expect(month).toHaveAttribute("aria-disabled", "true"); // The lowered `surface` bar is exactly 44px (zero vertical padding), and each // option's tap target fills it to the 44px accessibility minimum even though // the visible chip inside is shorter. await expect(group.getBoundingClientRect().height).toBe(44); for (const option of [day, week, month]) { await expect( option.getBoundingClientRect().height, ).toBeGreaterThanOrEqual(44); } // `compact` only removes horizontal room: same options, narrower bar, and // the tap targets keep the 44px minimum. await expect(compactGroup.getBoundingClientRect().height).toBe(44); await expect(compactGroup.getBoundingClientRect().width).toBeLessThan( defaultGroup.getBoundingClientRect().width, ); for (const option of within(compactGroup).getAllByRole("radio")) { await expect( option.getBoundingClientRect().height, ).toBeGreaterThanOrEqual(44); } week.click(); await waitFor(() => expect(week).toHaveAttribute("aria-checked", "true")); await expect(day).toHaveAttribute("aria-checked", "false"); month.click(); await expect(month).toHaveAttribute("aria-checked", "false"); await expect(week).toHaveAttribute("aria-checked", "true"); // Icon group: the label is the accessible name only — it is not rendered — // and the square chip still sits in a 44x44 tap target. const iconGroup = canvas.getByRole("radiogroup", { name: "Color mode" }); const iconCanvas = within(iconGroup); const light = iconCanvas.getByRole("radio", { name: "Light" }); const dark = iconCanvas.getByRole("radio", { name: "Dark" }); const system = iconCanvas.getByRole("radio", { name: "System" }); await expect(iconCanvas.queryByText("Light")).toBeNull(); await expect(iconGroup.getBoundingClientRect().height).toBe(44); for (const option of [light, dark, system]) { const rect = option.getBoundingClientRect(); await expect(rect.height).toBeGreaterThanOrEqual(44); await expect(rect.width).toBeGreaterThanOrEqual(44); } await expect(system).toHaveAttribute("aria-checked", "true"); dark.click(); await waitFor(() => expect(dark).toHaveAttribute("aria-checked", "true")); await expect(system).toHaveAttribute("aria-checked", "false"); }, };