import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { Meta, StoryObj } from "@storybook/react" import { toStoryChoice } from "../argTypes.storyHelpers.ts" import { Badge } from "../Badge/Badge.tsx" import { Button } from "../Button/Button.tsx" import { ContainerBoard, StoryCell, StoryGrid, StorySection, } from "../board.storyHelpers.tsx" import { IconButton } from "../IconButton/IconButton.tsx" import { SettingsIcon } from "../icons.storyHelpers.tsx" import { ProgressBar } from "../ProgressBar/ProgressBar.tsx" import { Card, ELEVATION_CLASS } from "./Card.tsx" import { ACCENT_EDGE_CATEGORICAL_CLASS } from "./cardAccentEdge.ts" const meta = { title: "Components/Layout/Card", component: Card, parameters: { layout: "padded" }, argTypes: { // An object union, so an object control is the right one — but // it is stated rather than inferred, because docgen's fallback // for a type it cannot read is also an object control and the // two are indistinguishable in the panel. accentEdge: { control: "object" }, elevation: toStoryChoice( Object.keys( ELEVATION_CLASS, ) as (keyof typeof ELEVATION_CLASS)[], ), }, args: { elevation: "low", headingLevel: 2, padding: "md", surface: "raised", }, } satisfies Meta export default meta type Story = StoryObj export const Playground: Story = { parameters: playgroundParameters, args: { children: (

Ripping Blade Runner (1982) — title 4 of 9.

), heading: "Bay 3", }, } export const AllVariants: Story = { args: { children: "Content" }, render: () => ( {(["base", "raised", "sunken"] as const).flatMap( (surface) => ( ["none", "low", "medium", "high"] as const ).map((elevation) => ( `s sharing an // accessible name is an axe `landmark-unique` // violation — a real one: a screen-reader user // navigating by landmark gets two "raised" // regions and no way to tell them apart. heading={`${surface} · ${elevation}`} surface={surface} >

shadow-{elevation}

)), )}
), } export const AllStates: Story = { args: { children: "Content" }, render: () => (

A card with no name cannot be a region, so it is not pretending to be one.

ripping } footer="Started 14:02 · MakeMKV 1.17.8" heading="Bay 2" >
), } /** * The header stacks below `--cq-sm` and sits on one row above it — * and the container is the *card*, not the window, which is the case * a media query genuinely cannot express: the same card in a sidebar * and full-bleed on a dashboard. */ export const Responsive: Story = { args: { children: "Content" }, render: () => ( {(width) => ( Start rip } heading={`Bay 3 at ${width} — Blade Runner (1982)`} > )} ), } /** * Two identical "Start rip" buttons on one page. * * Unscoped, `getByRole("button", { name: "Start rip" })` is * ambiguous — which is exactly what `expectAgentDrivable` refuses. * Scoping by region is the fix, and it only exists because the cards * are named. That is the whole a11y argument for this component: an * agent driving a 16-bay tower can say *which* "Start" it means. */ export const Interactive: Story = { args: { children: "Content" }, render: () => (
{[3, 4].map((bay) => ( Start rip} heading={`Bay ${bay}`} key={bay} > ))}
), } /** * The leading edge, and the two answers the fleet has for what * colour it is. * * A **categorical index** is a colour a user picked — Docket's * project colours come from this ten-wide family, and every pair in * it is contrast-audited. An explicit **colour** is one the app * computed: Folio hashes a repo's name so a repo added tomorrow * already has a hue and nobody maintains a palette, which is 360 * answers rather than ten. * * Both bars follow the card's own corner, because the treatment * inherits its radius instead of naming one. Every app that grew * this shape by hand drew it as a straight border beside a rounded * box, and a border cannot follow a curve. */ export const AccentEdge: Story = { args: { children: "Content" }, render: () => ( {( Object.keys( ACCENT_EDGE_CATEGORICAL_CLASS, ) as unknown as (keyof typeof ACCENT_EDGE_CATEGORICAL_CLASS)[] ).map((index) => (

A colour the user picked.

))}

A hue hashed from the name, not an index.

The bar reads the radius off the card rather than naming one, so an override still lines up.

), } /** * Ids for the two cells that point at their own text. Written out * rather than generated, because a story is a static demo and the * docs page mounts every cell at once — two cells sharing a * generated id would be a duplicate-id violation nobody could see. */ const CALLER_HEADING_ID = "card-caller-heading" const SHADOWED_HEADING_ID = "card-shadowed-heading" /** * The card is named by a heading the **app** draws, not by * `heading`. * * mail-sifter's tiles put the name and its count on one row, so they * render the title themselves and pass `aria-labelledby` at the call * site. `Card` writes that attribute after the caller's spread, so * it has to fall back to the caller's pointer: without the fallback * the attribute is overwritten with `undefined`, React drops it, and * the tile's name becomes its whole subtree — "Recent Mail 6 40 * arrived in the last 24 hours, 34 done" instead of "Recent Mail". * Nothing reports that: it renders, it typechecks, and axe is happy * with a name made of everything. * * `heading` still wins where it is given, and `aria-label` needs no * fallback at all — the component never writes one, so the spread * carries it through. */ export const CallerNamed: Story = { args: { children: "Content" }, render: () => (

Recent Mail

6

40 arrived in the last 24 hours, 34 done.

Pointed at, and deliberately not the name — the heading is the text the reader can see.

No heading and no pointer, so the card is named by the label the call site set.

), }