import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { ArrowLeftDuotoneIcon, ArrowLeftRegularIcon, } from "alouette-icons/phosphor-icons/ArrowLeft"; import type { ReactNode } from "react"; import { Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { Story, neutralAndAccents } from "../story-components/Story"; import { StoryGrid, stateTitle } from "../story-components/StoryGrid"; import { IconButton, type IconButtonProps } from "./IconButton"; type ThisStory = StoryObj; export default { title: "alouette/Actions/IconButton", component: IconButton, parameters: { componentSubtitle: "A standardized circular icon button with consistent touch targets, visual feedback, and accessibility", docs: { description: { component: `### Variants - \`size\`: \`sm\` | \`md\` | any number (custom diameter px) - \`iconSize\`: \`"fill"\` makes the icon fill 80% of the button (default 50%) - \`variant\`: tonal | filled | outlined | soft (the glyph takes the accent on hover/focus) - \`activeIcon\`: replaces \`icon\` while the button is hovered, focused or pressed — usually the duotone twin - Wrap in \`\` (or any accent: brand|info|success|warning|danger) to switch the interactive token set; it composes with current light/dark mode`, }, }, }, } satisfies Meta; export const PreviewIconButtonStory: ThisStory = { parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, args: { size: "md", "aria-label": "Go back", }, render: (args) => ( } activeIcon={} /> ), }; interface BackIconButtonProps { forceStyle?: IconButtonProps["forceStyle"]; /** Renders `icon` alone, so the glyph keeps one weight in every state. */ withoutActiveIcon?: boolean; } function BackIconButton({ forceStyle, withoutActiveIcon, }: BackIconButtonProps): ReactNode { return ( } activeIcon={withoutActiveIcon ? undefined : } aria-label="Go back" /> ); } function IconWeightRow({ withoutActiveIcon, }: Pick): ReactNode { return ( ); } export const Variants: ThisStory = { render: () => ( {([24, 40] as const).map((size) => ( } activeIcon={} aria-label="Go back" /> ))} } aria-label="Go back" disabledReason="Nothing to go back to" /> {neutralAndAccents.map((accent) => ( {( [undefined, "hover", "focus", "press", "disabled"] as const ).map((state) => ( {(["tonal", "filled", "soft"] as const).map((variant) => ( } activeIcon={} aria-label="Go back" /> {variant} ))} ))} ))} ), }; /** * A soft icon button has no ground to carry its accent at rest, so the glyph * takes `text-accent` on hover and focus. */ export const SoftAccentOnInteraction: ThisStory = { render: () => ( } aria-label="Go back" /> } aria-label="Go back (hover)" /> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole("button", { name: "Go back" }); const glyph = button.querySelector("svg")!; const accentColor = getComputedStyle( canvas .getByRole("button", { name: "Go back (hover)" }) .querySelector("svg")!, ).color; const restColor = getComputedStyle(glyph).color; await expect(restColor).not.toBe(accentColor); // user-event's hover never sets CSS `:hover`, so only focus is asserted; // the forced `hover` button covers the hover appearance. await userEvent.tab(); await expect(button).toHaveFocus(); await waitFor(() => expect(getComputedStyle(glyph).color).toBe(accentColor), ); }, }; export const DisabledReason: ThisStory = { render: () => ( } aria-label="Go back" disabledReason="Nothing to go back to" /> ), play: async ({ canvasElement }) => { const button = within(canvasElement).getByRole("button", { name: "Go back", }); await expect(button).toHaveAttribute("aria-disabled", "true"); await expect(button).toHaveAccessibleDescription("Nothing to go back to"); await userEvent.hover(button); await expect( await screen.findByRole("tooltip", { hidden: true }), ).toHaveTextContent("Nothing to go back to"); await userEvent.unhover(button); await waitFor(() => expect( screen.queryByRole("tooltip", { hidden: true }), ).not.toBeInTheDocument(), ); }, };