import { expect, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { BellDuotoneIcon, BellRegularIcon, } from "alouette-icons/phosphor-icons/Bell"; import { CalendarDuotoneIcon, CalendarRegularIcon, } from "alouette-icons/phosphor-icons/Calendar"; import { ChartBarDuotoneIcon, ChartBarRegularIcon, } from "alouette-icons/phosphor-icons/ChartBar"; import { type ReactNode, useState } from "react"; import type { Accent } from "../../core/AlouetteConfig"; import { Box } from "../containers/Box"; import { Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { Story } from "../story-components/Story"; import { Tab, type TabProps } from "./Tab"; import { Tabs } from "./Tabs"; type ThisStory = StoryObj; export default { title: "alouette/Navigation/Tabs", component: Tabs, parameters: { componentSubtitle: "Segmented switch between views on the same screen. Tabs owns the value; Tab children read it via context and mark themselves aria-selected.", }, argTypes: { disabled: { control: "boolean" }, variant: { control: "inline-radio", options: ["segmented", "icon"] }, accent: { control: "select", options: [undefined, "brand", "danger", "info", "success", "warning"], }, }, } satisfies Meta; export const PreviewTabsStory: ThisStory = { name: "Tabs Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, render: (args) => ( ), }; function IconTabs({ accent }: { accent?: Accent }): ReactNode { return ( } activeIcon={} /> } activeIcon={} /> } activeIcon={} /> ); } interface RangeTabsProps { label: string; accent?: Accent; activeAccent?: TabProps["activeAccent"]; /** Renders `icon` alone, so the glyph keeps one weight throughout. */ withoutActiveIcon?: boolean; } function RangeTabs({ label, accent, activeAccent, withoutActiveIcon, }: RangeTabsProps): ReactNode { return ( } activeIcon={withoutActiveIcon ? undefined : } activeAccent={activeAccent} /> } activeIcon={withoutActiveIcon ? undefined : } activeAccent={activeAccent} /> ); } function TabsVariant({ accent }: { accent?: Accent }): ReactNode { return ( ); } export const VariantsTabsStory: ThisStory = { name: "Tabs 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 tabs. The duotone twin is optional. Drop `activeIcon` and the glyph keeps one weight throughout, the chip carrying the whole affordance. ), }; function TabPanel({ value }: { value: string }): ReactNode { if (value === "week") { return Seven days of activity.; } return Twelve weeks of activity.; } function TabsWithPanels(): ReactNode { const [value, setValue] = useState("week"); return ( ); } export const TestsTabsStory: ThisStory = { name: "Tabs Tests", render: () => ( } activeIcon={} /> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const tablist = canvas.getByRole("tablist", { name: "Period" }); const tablistCanvas = within(tablist); const day = tablistCanvas.getByRole("tab", { name: "Day" }); const week = tablistCanvas.getByRole("tab", { name: "Week" }); const month = tablistCanvas.getByRole("tab", { name: "Month" }); await expect(day).toHaveAttribute("aria-selected", "true"); await expect(week).toHaveAttribute("aria-selected", "false"); await expect(month).toHaveAttribute("aria-disabled", "true"); await expect(tablist.getBoundingClientRect().height).toBe(44); for (const tab of [day, week, month]) { await expect(tab.getBoundingClientRect().height).toBeGreaterThanOrEqual( 44, ); } week.click(); await waitFor(() => expect(week).toHaveAttribute("aria-selected", "true")); await expect(day).toHaveAttribute("aria-selected", "false"); month.click(); await expect(month).toHaveAttribute("aria-selected", "false"); await expect(week).toHaveAttribute("aria-selected", "true"); const ranges = within(canvas.getByRole("tablist", { name: "Ranges" })); const quarter = ranges.getByRole("tab", { name: "Quarter" }); await expect(quarter).toHaveAttribute("aria-controls", "panel-quarter"); quarter.click(); await waitFor(() => expect(canvas.getByRole("tabpanel")).toHaveAttribute( "id", "panel-quarter", ), ); await expect(canvas.getByText("Twelve weeks of activity.")).toBeTruthy(); // Icon tabs: the label names the tab without being rendered, and the square // chip still sits in a 44x44 tap target. const iconTablist = canvas.getByRole("tablist", { name: "Icons" }); const iconCanvas = within(iconTablist); const iconWeek = iconCanvas.getByRole("tab", { name: "Week" }); const iconStats = iconCanvas.getByRole("tab", { name: "Stats" }); const iconAlerts = iconCanvas.getByRole("tab", { name: "Alerts" }); await expect(iconCanvas.queryByText("Stats")).toBeNull(); await expect(iconTablist.getBoundingClientRect().height).toBe(44); for (const tab of [iconWeek, iconStats, iconAlerts]) { const rect = tab.getBoundingClientRect(); await expect(rect.height).toBeGreaterThanOrEqual(44); await expect(rect.width).toBeGreaterThanOrEqual(44); } await expect(iconWeek).toHaveAttribute("aria-selected", "true"); await expect(iconAlerts).toHaveAttribute("aria-disabled", "true"); iconStats.click(); await waitFor(() => expect(iconStats).toHaveAttribute("aria-selected", "true"), ); await expect(iconWeek).toHaveAttribute("aria-selected", "false"); }, };