import type { StoryObj, Meta } from "@storybook/react-vite"; import { within, userEvent, expect, fn } from "storybook/test"; import { IconButton } from "./icon-button"; import "./button.scss"; import "./icon-button.scss"; // Minimal inline SVG icons for stories — no external icon dependency required const CloseIcon = () => ( ); const SettingsIcon = () => ( ); const TrashIcon = () => ( ); const iconClicked = fn(); const meta = { title: "FP.React Components/Buttons/IconButton", component: IconButton, tags: ["beta"], args: { type: "button", icon: , "aria-label": "Close", onClick: iconClicked, }, } as Meta; export default meta; type Story = StoryObj; /** * Default icon-only button. Requires `aria-label` for screen reader accessibility. */ export const IconButtonDefault: Story = { args: { "aria-label": "Close", icon: , }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); const button = canvas.getByRole("button", { name: "Close" }); await step("IconButton is rendered with aria-label", async () => { expect(button).toBeInTheDocument(); expect(button).toHaveAttribute("aria-label", "Close"); }); await step("IconButton receives focus on tab", async () => { await userEvent.tab(); expect(button).toHaveFocus(); }); await step("IconButton click handler fires", async () => { await userEvent.click(button); expect(iconClicked).toHaveBeenCalled(); }); }, }; /** * Uses `aria-labelledby` instead of `aria-label` — references an existing element in the DOM. * The XOR type means passing both `aria-label` and `aria-labelledby` is a TypeScript error. */ export const IconButtonLabelledBy: Story = { render: () => (
Delete item } />
), }; /** * Icon + visible label. Label hides below 768px (overridable via `$icon-label-bp` SCSS variable). * Resize the viewport to see the responsive behavior. * NOTE: `variant="outline"` overrides the default `variant="icon"` to restore padding. */ export const IconButtonWithLabel: Story = { args: { "aria-label": "Settings", icon: , label: "Settings", variant: "outline", }, }; /** * All style variants — icon (default), outline, text, and pill. * `icon` is the default: transparent background, currentColor icon, square touch target. * Switch `variant` to restore background or border as needed. */ export const IconButtonVariants: Story = { render: () => (
} /> } variant="outline" /> } variant="text" /> } variant="pill" />
), }; /** * Size variants — xs through 2xl. Height and touch target scale with font size * via the `--btn-height: calc(var(--btn-fs) * 2.75)` formula. */ export const IconButtonSizes: Story = { render: () => (
} size="xs" /> } size="sm" /> } size="md" /> } size="lg" /> } size="xl" /> } size="2xl" />
), }; /** * All semantic color variants. Color sets `--btn-bg` and `--btn-color` via * `data-color` — icon buttons keep a transparent background by default so the * icon itself inherits the color token via `currentColor`. */ export const IconButtonColors: Story = { render: () => (
} color="primary" /> } color="secondary" /> } color="danger" /> } color="success" /> } color="warning" />
), }; /** * Outline variant across all color tokens. The `outline` variant restores a border * and uses `currentColor` for both border and icon — color sets the inherited value. */ export const IconButtonOutlineColors: Story = { render: () => (
} variant="outline" color="primary" /> } variant="outline" color="secondary" /> } variant="outline" color="danger" /> } variant="outline" color="success" /> } variant="outline" color="warning" />
), }; /** * Disabled state — uses the WCAG-compliant `aria-disabled` pattern. * The button remains focusable but all interactions are blocked. */ export const IconButtonDisabled: Story = { args: { "aria-label": "Close (disabled)", icon: , disabled: true, }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); const button = canvas.getByRole("button", { name: "Close (disabled)" }); await step("Disabled button has aria-disabled attribute", async () => { expect(button).toHaveAttribute("aria-disabled", "true"); }); await step("Disabled button remains focusable", async () => { await userEvent.tab(); expect(button).toHaveFocus(); }); }, };