import { FLIP_KEY_ATTRIBUTE } from "@charcuterie/logic" import type { ReactNode } from "react" import { Button } from "../Button/Button.tsx" import { toClassName } from "../toClassName.ts" import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx" /** * Where a step sits in the sequence. * * `blocked` is not a worse `upcoming`. Upcoming means "its turn has * not come"; blocked means "its turn has come and something is in * the way", which is a thing to go and fix rather than a thing to * wait for. Docket's phases need exactly this split — a phase gated * behind an unfinished one, versus a phase gated behind an EMPTY * one, where the second is a mistake somebody has to correct. */ export type StepStatus = | "blocked" | "current" | "done" | "upcoming" export type Step = { /** What the marker shows instead of its ordinal position. */ readonly marker?: ReactNode readonly key: string readonly label: ReactNode /** One line under the label — why it is blocked, what it is * waiting on, when it finished. */ readonly description?: ReactNode /** * What BELONGS to this step: the tasks in a phase, the fields in * a form stage, the checks in a pipeline. * * The reason this component is not a `ProgressBar` with a legend. * A bar says a sequence is half done; only the contents say which * things go together and which one is next. */ readonly content?: ReactNode readonly status?: StepStatus } export type StepperOrientation = "horizontal" | "vertical" export type StepperProps = { /** `compact` is the dot-and-line ladder for short status labels in cards. */ readonly appearance?: "default" | "compact" readonly className?: string /** * What level the step labels are. The caller's, because only the * caller knows what the stepper sits under — one below a page * `

` wants `2`, one inside an already-deep section wants `4`. * Getting it wrong is an axe `heading-order` failure and a broken * screen-reader outline. */ readonly headingLevel?: 2 | 3 | 4 | 5 /** The list's accessible name. Required — an unnamed sequence of * numbers is a sequence of numbers. */ readonly label: string /** * Whether a requested horizontal sequence collapses below `cq-md`. * Keep this on for prose-sized steps. Turn it off only when the * caller supplies short labels that are designed to remain a compact * ladder inside a card. */ readonly isResponsive?: boolean /** * `horizontal` is a REQUEST, not a promise: it collapses to * vertical below `cq-md`, because four steps side by side in a * narrow container squash into unreadable slivers (`2. / De- / * dupe`, measured). A step carrying `content` should stay * vertical — a column cannot hold a list. */ readonly orientation?: StepperOrientation /** Completed stages become buttons; the caller resets dependent answers. */ readonly onStepSelect?: (key: string) => void readonly steps: readonly Step[] } /** * An ordered sequence of steps, with what belongs to each one. * * ### Why the status is never colour alone * * Four states painted only as four marker colours is a WCAG 1.4.1 * failure however clear it looks, so each status reaches a screen * reader as a word — `VisuallyHidden`, next to the label — and gets * a second visual channel besides hue: a **filled** marker for done * and current, an **outlined** one for upcoming and blocked, and a * connector that is solid behind finished work and dashed ahead of * it. * * ### Why the marker is a number and never a tick * * This library * [ships no icons and no symbol glyphs](../../../../docs/decisions/2026-07-29-ship-no-icons-and-no-symbol-glyphs.md). * A `✓` renders as an empty box wherever the font lacks it — which * includes the sandbox chromium, the kiosk image and the ePaper * build — and an empty box in the "done" position reads as an error. * The ordinal always renders. An app that owns a glyph set can pass * one as `marker`. * * ### Why the connector is an element * * Not a `::before`. `styles.css` is reserved for the handful of * visuals a Tailwind utility genuinely cannot express — the looping * animations and the scrollbar — and a line between two markers is * not one of them. A real `` also stops the last * step drawing a connector into empty space, which is a * `:not(:last-child)` selector nobody remembers to write. */ export const Stepper = ({ appearance = "default", className, headingLevel = 3, isResponsive = true, label, orientation = "vertical", onStepSelect, steps, }: StepperProps) => { const Heading = `h${headingLevel}` as "h3" const isHorizontal = orientation === "horizontal" const isCompact = appearance === "compact" return ( // The container is a WRAPPER, never the `
    ` itself. // // An element cannot answer its own container query: declaring // `container-type` on the list makes it the query container for // its DESCENDANTS, and `cq-md:` on the list resolves against // whatever container is further up — usually none. The first // version of this component put both on the `
      `, and the // result was a horizontal stepper whose CONNECTORS went // horizontal (they are children, so they matched) while the // list stayed a column. Valid CSS, no error, nothing for axe or // a DOM test to say, and obvious the moment it was rendered. // `Board` carries the same note for the same reason.
        {steps.map((step, index) => { const status = step.status ?? "upcoming" const isLast = index === steps.length - 1 return ( /* * `data-flip-key` carries `step.key` into the DOM so a * re-ordered stepper can animate with `useFlipList` * from `@charcuterie/logic`, which matches items on * exactly this attribute. * * Emitted always, not behind a prop. React's `key` is * invisible to the DOM, so without this a caller who * re-orders `steps` has no way to say which `
      1. ` is * which — and FLIP matched by POSITION pairs every item * with whatever now sits where it used to be, measures * a delta of zero, and animates nothing. Docket's phase * list is the case: the owner reported *"when I move * them up and down, there's no animation"*, and the * step's identity never reached the markup. * * A data attribute on a list item costs nothing when * nobody reads it, which is why it is not opt-in. */
      2. {isCompact ? ( {step.marker ?? index + 1} ) : ( (step.marker ?? index + 1) )} {/* Absent on the last step, so nothing draws a line into empty space. */} {isLast ? null : ( )}
        {onStepSelect && status === "done" ? ( ) : ( step.label )} {/* The status as a WORD. Four marker colours is four colours as far as assistive technology is concerned, and this is the only channel that survives a screen reader, a greyscale print and the ePaper build at once. */} {STATUS_LABEL[status]} {step.description == null ? null : (

        {step.description}

        )} {step.content == null ? null : (
        {step.content}
        )}
      3. ) })}
      ) } /** * Written out in full, never interpolated. Tailwind v4 scans source * text for COMPLETE class strings, so `` `bg-intent-${x}-solid` `` * generates nothing at all and the element paints transparent with * no error and no failing test — * `tailwindCandidates.test.ts` is what makes this near-duplication * safe to keep. */ const STATUS_MARKER_CLASS: Record = { blocked: "border-intent-warning-border bg-intent-warning-surface text-intent-warning-content", current: "border-intent-accent-border bg-intent-accent-surface text-intent-accent-content", done: "border-intent-success-border bg-intent-success-solid text-intent-success-on-solid", upcoming: "border-border-subtle bg-surface-raised text-content-secondary", } const STATUS_LABEL: Record = { blocked: "Blocked", current: "In progress", done: "Done", upcoming: "Not started", }