import { useMediaQuery } from "@charcuterie/logic" import { matchMediaMatcher } from "@charcuterie/logic/browser" import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import type { ReactNode } from "react" import { useState } from "react" import { controlSizeArgType } from "../argTypes.storyHelpers.ts" import { ContainerBoard, StoryCell, StoryGrid, } from "../board.storyHelpers.tsx" import { Header } from "../Header/Header.tsx" import { MoreIcon, PlayIcon, RedoIcon, SettingsIcon, UndoIcon, } from "../icons.storyHelpers.tsx" import { Main } from "../Main/Main.tsx" import { Shell } from "../Shell/Shell.tsx" import { Switch } from "../Switch/Switch.tsx" import { HeaderSchemeToggle, PageContent, } from "../shell.storyHelpers.tsx" import type { ToolbarAction, ToolbarItem, } from "./Toolbar.tsx" import { Toolbar } from "./Toolbar.tsx" const noop = () => undefined /** * Priority order, highest first — `items[0]` is the last thing to * collapse. rip-deck's bar, near enough: the two things you do to a * running job, then the two you do to the deck. */ const DECK_ACTIONS: ToolbarAction[] = [ { icon: , key: "start", label: "Start rip", onSelect: noop, }, { icon: , key: "retry", label: "Retry title", onSelect: noop, }, { icon: , key: "skip", label: "Skip title", onSelect: noop, }, { icon: , isDisabled: true, key: "eject", label: "Eject disc", onSelect: noop, }, ] /** * A dry-run switch with its own state, so the collapsed row is a * **real** toggle rather than mux-magic's `aria-hidden` span — the * defect the audit calls the worst in that file, because Dry Run * announces identically on and off. */ const DryRunSwitch = (): ReactNode => { const [isDryRun, setIsDryRun] = useState(true) return ( ) } /** * The header's real mix: actions, a switch, and the scheme control. * `role="menu"` would be invalid over this — hence `overflow="panel"`, * which the type system requires here rather than suggesting. */ const MIXED_ITEMS: ToolbarItem[] = [ ...DECK_ACTIONS.slice(0, 2), { element: , key: "dry-run", type: "control", }, { element: , key: "scheme", label: "Colour scheme", type: "control", }, ] /** * A fixed-width box, because the whole component is a measurement. * A `layout: "centered"` story would size itself to its contents * and never overflow at all. */ const Frame = ({ children, inlineSize, }: { children: ReactNode inlineSize: string }): ReactNode => (
{children}
) const meta = { title: "Components/Actions/Toolbar", component: Toolbar, parameters: { layout: "padded" }, argTypes: { size: controlSizeArgType }, args: { items: DECK_ACTIONS, label: "Deck actions", overflow: "menu", overflowIcon: , overflowLabel: "More actions", size: "sm", }, } satisfies Meta export default meta type Story = StoryObj /** * Wide enough for everything, so there is **no overflow trigger at * all**. mux-magic shows its ⋮ at every width, which is why its * collapse is not progressive: almost everything lives in the * popover permanently. */ export const Playground: Story = { parameters: playgroundParameters, render: (toolbarProps) => ( ), } /** * The two overflow kinds, and the distinction is a **type**. * * `overflow="menu"` narrows `items` to actions only and opens a real * `role="menu"`. `overflow="panel"` accepts controls too and opens a * `Popover` (`role="dialog"`, `aria-haspopup="dialog"`), because * `role="menu"` permits only `menuitem`-family children — putting a * scheme switcher in one, as plex-channels does today, is invalid * ARIA that no gate catches. */ export const AllVariants: Story = { render: ({ size }) => ( } size={size} /> } overflowLabel="More controls" size={size} /> ), } /** * Nothing collapsed, something collapsed, and the overflow open — * the three states the audit says nobody screenshots. The open ones * are the point: an overflow audited only while shut is an overflow * nobody audited. */ export const AllStates: Story = { render: ({ size }) => ( } size={size} /> } size={size} /> } overflowLabel="More controls" size={size} /> ), } /** * Progressive collapse, measured rather than breakpointed. Same * items, three container widths, and the count in the bar falls one * at a time from the **end** of the priority order — no `480px` * anywhere, so a longer label or a denser theme moves the collapse * point on its own. */ export const Responsive: Story = { render: ({ size }) => ( {(width) => ( } size={size} /> )} ), } /** * Tab **once** to enter, then the arrow keys move along the row and * Home/End jump to its ends — one tab stop for the whole toolbar, * which is what the APG asks for and what four repos' `role="toolbar"` * does not do. Open the overflow and focus moves into it; Escape * closes it and hands focus back to the trigger. */ export const Interactive: Story = { render: ({ size }) => ( } size={size} /> ), } /** * Where it actually goes: `Shell` → `Header`, in the header's own * row, with the scheme control among its items rather than beside * them. * * The header is also where the **other** half of the fleet's problem * lives — on a phone the bar wants to leave the header entirely, and * that is a relocation, not a fit. So this story composes the two: * `useMediaQuery` (from `@charcuterie/logic`, over the `matchMedia` * matcher) decides *where* the toolbar is mounted, and `Toolbar` * decides *what fits* once it is there. Exactly one instance either * way — the element moves, it is never rendered twice and hidden. */ const AppShellHarness = (): ReactNode => { const { isMatching: isNarrow } = useMediaQuery({ matcher: matchMediaMatcher("(width < 40rem)"), }) const toolbar = ( } overflowLabel="More controls" /> ) return (
{isNarrow ? null : toolbar}
{isNarrow ? toolbar : null}
) } export const InAppShell: Story = { parameters: { layout: "fullscreen" }, render: () => , }