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: () => ,
}