{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "step-indicator", "title": "Step Indicator", "description": "Ordered multi-step progress list with status markers (completed, saved, in progress, error, cannot start) and an aria-current step, plus a sectioned StepNav wrapper.", "dependencies": [ "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/link.json", "https://ui.digital.nsw.gov.au/registry/r/icons.json" ], "files": [ { "path": "src/components/step-indicator.tsx", "content": "'use client'\n\nimport React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Link } from '@/components/link'\nimport { IconCheck } from '@/icons/check'\nimport { IconError } from '@/icons/error'\nimport { IconMoreHoriz } from '@/icons/more-horiz'\nimport { IconRemove } from '@/icons/remove'\n\n/**\n * Progress state of a single step in a multi-step journey.\n *\n * `'default'` is the unannotated baseline (a step with no `status` set) and\n * renders identically to `'not-started'` — both exist because the nswds-app\n * source distinguished them, and consumers may key behaviour off the\n * difference even though the visual is shared.\n */\ntype StepStatus =\n 'default' | 'not-started' | 'in-progress' | 'completed' | 'saved' | 'error' | 'cannot-start'\n\n/** One entry in a {@link StepIndicator} list. */\ntype Step = {\n /** Visible step name, e.g. \"Your details\". */\n title: string\n /** Optional second line under the title. */\n description?: string\n /** Navigation target; also the identity compared against `currentHref`. */\n href: string\n /** Progress state. Omitted = `'default'` (renders as not started). */\n status?: StepStatus\n}\n\n/**\n * Per-status visual treatment, collapsed from the nswds-app source's seven\n * copy-pasted JSX branches into one map. Every colour funnels through a single\n * `--step-ink` custom property declared on the `
  • `, so the marker,\n * connector, hover fill and focus ring all follow from one declaration per\n * status (the same ink-token pattern as footer.tsx / link.tsx).\n *\n * Ink values use RAW @nswds/tokens custom properties (`--success-600`, not the\n * `--color-success-600` Tailwind bridge alias): Tailwind v4 tree-shakes\n * unreferenced `@theme` keys and an arbitrary-property reference is not a\n * usage signal, but the raw tokens are plain `:root` declarations that always\n * resolve — the same precedent as button.tsx's semantic colours.\n *\n * Token remapping from the nswds-app source (documented per §3 of AGENTS.md):\n * - completed/saved: `success-450`/`-500`/`-550` → `--success-600` (dark:\n * `--success-500`). 450 (L≈0.63) gives the white check only ≈2.4:1; 600\n * (L≈0.55) clears the 3:1 non-text minimum (WCAG 2.2 1.4.11) and matches\n * Button's solid success.\n * - in-progress: `primary-500` → `--primary-600` (dark: `--primary-450`).\n * Masterbrand primary-500 (L≈0.72) fails 3:1 against white for the marker\n * border and icon; 600 matches Button's tertiary. The dark override\n * lightens the ink so it clears 3:1 against dark surfaces.\n * - error: `danger-450`/`-500` → `--danger-600` (dark: `--danger-500`),\n * matching Button's solid danger.\n * - not-started/default: border `grey-300` → `--grey-500` (dark:\n * `--grey-450`). grey-300 (L≈0.90) is ≈1.2:1 against white — invisible to\n * low-vision users; grey-500 clears 3:1 (1.4.11).\n * - cannot-start: `grey-600` kept (white glyph on it is ≈7:1 in both modes).\n * - marker fill `bg-white` → `bg-background` so outlined markers sit on the\n * page surface in dark mode instead of punching white holes in it.\n */\nconst stepStatusStyles: Record<\n StepStatus,\n {\n /** `--step-ink` declaration, applied to the `
  • ` so descendants inherit. */\n ink: string\n /** Connector line colour (the `aria-hidden` rule between markers). */\n connector: string\n /** Structural marker treatment. */\n marker: 'solid' | 'outline' | 'dot'\n /** Glyph rendered inside the marker (always `aria-hidden`). */\n icon: React.ElementType | null\n /** Default visually-hidden status announcement (see `statusLabels`). */\n label?: string\n /** Steps that must not be reachable yet. */\n disabled?: boolean\n }\n> = {\n completed: {\n ink: '[--step-ink:var(--success-600)] dark:[--step-ink:var(--success-500)]',\n connector: 'bg-(--step-ink)',\n marker: 'solid',\n icon: IconCheck,\n label: 'Completed',\n },\n saved: {\n ink: '[--step-ink:var(--success-600)] dark:[--step-ink:var(--success-500)]',\n connector: 'bg-grey-300 dark:bg-grey-700',\n marker: 'outline',\n icon: IconCheck,\n label: 'Saved',\n },\n 'in-progress': {\n ink: '[--step-ink:var(--primary-600)] dark:[--step-ink:var(--primary-450)]',\n connector: 'bg-grey-300 dark:bg-grey-700',\n marker: 'outline',\n icon: IconMoreHoriz,\n label: 'In progress',\n },\n error: {\n ink: '[--step-ink:var(--danger-600)] dark:[--step-ink:var(--danger-500)]',\n connector: 'bg-(--step-ink)',\n marker: 'solid',\n icon: IconError,\n label: 'Error',\n },\n 'cannot-start': {\n ink: '[--step-ink:var(--grey-600)]',\n connector: 'bg-grey-300 dark:bg-grey-700',\n marker: 'solid',\n icon: IconRemove,\n label: 'Cannot start yet',\n disabled: true,\n },\n 'not-started': {\n ink: '[--step-ink:var(--grey-500)] dark:[--step-ink:var(--grey-450)]',\n connector: 'bg-grey-300 dark:bg-grey-700',\n marker: 'dot',\n icon: null,\n label: 'Not started',\n },\n default: {\n ink: '[--step-ink:var(--grey-500)] dark:[--step-ink:var(--grey-450)]',\n connector: 'bg-grey-300 dark:bg-grey-700',\n marker: 'dot',\n icon: null,\n },\n}\n\n// Shared marker shell. The hover shade derives from the ink via color-mix and\n// is declared here — NOT on the
      — because a custom property that\n// substitutes var(--step-ink) is resolved on the element it is declared on;\n// on the list root the ink is still undefined and the whole property would\n// compute to invalid. On the marker the inherited per-status ink has already\n// resolved. `--black` is the raw @nswds/tokens value (always on :root), not\n// the tree-shakeable --color-black bridge alias.\nconst markerBase = [\n 'relative z-10 flex size-6 items-center justify-center rounded-full',\n 'motion-safe:transition-colors',\n '[--step-ink-hover:color-mix(in_oklch,var(--step-ink)_85%,var(--black))]',\n]\n\nconst markerStyles = {\n // Solid disc, white glyph; hover deepens the fill (source: -450 → -500).\n solid: cn(markerBase, 'bg-(--step-ink) text-white group-hover:bg-(--step-ink-hover)'),\n // Outlined disc, ink glyph; hover fills with the ink and inverts the glyph\n // (source: saved/in-progress fill-on-hover). In dark mode the inks are\n // light steps, so the hovered glyph flips to a dark grey instead of white.\n outline: cn(\n markerBase,\n 'border-2 border-(--step-ink) bg-background text-(--step-ink)',\n 'group-hover:bg-(--step-ink) group-hover:text-white dark:group-hover:text-grey-900',\n ),\n // Outlined disc with a centre dot that appears on hover (or is always\n // filled for the current step).\n dot: cn(markerBase, 'border-2 border-(--step-ink) bg-background'),\n}\n\n// One text treatment for title and description. The source coloured\n// `dark:text-slate-400` — a raw-palette leak — remapped here onto the grey\n// ramp. Hover moved from the text spans to `group-hover` so the whole link\n// (marker included) gives feedback, not just the glyph under the pointer.\nconst stepTextClassName =\n 'text-sm text-grey-700 motion-safe:transition-colors group-hover:text-grey-800 dark:text-grey-300 dark:group-hover:text-grey-100'\n\n// Emphasised text for the current step. Source used primary-500 /\n// dark:slate-400; remapped to primary-800 / primary-200 — the ramp's text\n// steps (WCAG 2.2 1.4.3 AA needs 4.5:1 for 14px text; primary-500 on white\n// is ≈2.9:1).\nconst currentTextClassName = 'text-sm font-semibold text-primary-800 dark:text-primary-200'\n\n/** Built-in announcements; override or suppress per status via `statusLabels`. */\nconst DEFAULT_STATUS_LABELS: Partial> = Object.fromEntries(\n Object.entries(stepStatusStyles).flatMap(([status, style]) =>\n style.label ? [[status, style.label]] : [],\n ),\n)\n\ntype StepIndicatorProps = Omit, 'children'> & {\n /** Steps to render, in journey order. `href` must be unique per step. */\n steps: Step[]\n /**\n * The href of the page being viewed. The matching step gets\n * `aria-current=\"step\"` and — when its status carries no stronger visual\n * (default / not-started / in-progress) — the emphasised current treatment.\n * Replaces the source's internal next/navigation `usePathname()` coupling\n * so the component stays framework-free.\n */\n currentHref?: string\n /** Click handler applied to every enabled step link. */\n onNavigate?: React.MouseEventHandler\n /**\n * Visually-hidden status announcements appended to each title, merged over\n * the English defaults (\"Completed\", \"Saved\", …). Set a status to\n * `undefined` to suppress its announcement; supply strings to localise.\n */\n statusLabels?: Partial>\n ref?: React.Ref\n}\n\n/**\n * Vertical list of the steps in a multi-step journey — one link per step with\n * a status marker and connector line, as used beside long NSW Government\n * application forms.\n *\n * Accessibility contract:\n * - Renders an `
        ` — steps are a sequence, so an ordered list\n * conveys the ordering to assistive tech (the nswds-app source used `
          `);\n * the explicit `role` restores list semantics in Safari/VoiceOver where\n * `list-style: none` removes them.\n * - The step matching `currentHref` gets `aria-current=\"step\"` whatever its\n * status (the source only set it on one of its seven branches).\n * - Markers and connectors are purely decorative (`aria-hidden`); status is\n * announced through a visually-hidden suffix after each title instead,\n * because colour and iconography alone would leave screen-reader users with\n * no status at all (WCAG 2.2 1.4.1 Use of Colour). See `statusLabels`.\n * - `cannot-start` steps are `aria-disabled`, removed from the tab order and\n * click-inert — the source left them fully clickable, which invited\n * navigation into pages that reject you.\n * - Links get a `focus-visible` outline drawn in the status ink (the source\n * relied on browser defaults), satisfying 2.4.7 Focus Visible with a 3:1\n * indicator (2.4.13).\n *\n * All anchors render through `Link` (`variant=\"unstyled\"`), so apps can inject\n * a framework link (e.g. next/link) via `LinkProvider`.\n */\nfunction StepIndicator({\n steps,\n currentHref,\n onNavigate,\n statusLabels,\n className,\n ref,\n ...props\n}: StepIndicatorProps) {\n if (process.env.NODE_ENV !== 'production') {\n const seen = new Set()\n for (const step of steps) {\n if (seen.has(step.href)) {\n console.warn(\n `[nswds/ui] StepIndicator: duplicate step href \"${step.href}\" — hrefs are used as React keys and for currentHref matching, so duplicates will misrender.`,\n )\n }\n seen.add(step.href)\n }\n }\n\n const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels }\n\n return (\n \n {steps.map((step, stepIdx) => {\n const status: StepStatus = step.status ?? 'default'\n const style = stepStatusStyles[status]\n const isCurrent = currentHref !== undefined && step.href === currentHref\n // Statuses with a strong treatment of their own (completed, saved,\n // error, cannot-start) keep it even when current — matching the\n // source's branch order.\n const currentVariant = !isCurrent\n ? null\n : status === 'in-progress'\n ? ('ring' as const)\n : status === 'default' || status === 'not-started'\n ? ('dot' as const)\n : null\n const isLast = stepIdx === steps.length - 1\n const StatusIcon = style.icon\n const label = labels[status]\n\n return (\n \n {!isLast && (\n
      \n )\n}\n\n/** A titled group of steps inside {@link StepNav}. */\ntype StepNavSection = {\n /** Section heading, e.g. \"Before you start\". */\n title: string\n steps: Step[]\n}\n\ntype StepNavProps = Omit, 'children'> & {\n /** Titled groups of steps, rendered as heading + StepIndicator pairs. */\n sections: StepNavSection[]\n /**\n * Heading element for section titles. Defaults to 2; set it so the headings\n * slot into the surrounding page outline (WCAG 2.2 1.3.1).\n */\n headingLevel?: 2 | 3 | 4 | 5 | 6\n /** Forwarded to every section's {@link StepIndicator}. */\n currentHref?: string\n /** Forwarded to every section's {@link StepIndicator}. */\n onNavigate?: React.MouseEventHandler\n /** Forwarded to every section's {@link StepIndicator}. */\n statusLabels?: Partial>\n ref?: React.Ref\n}\n\n/**\n * Sectioned journey navigation: a `\n )\n}\n\nexport { StepIndicator, StepNav, stepStatusStyles }\nexport type { Step, StepIndicatorProps, StepNavProps, StepNavSection, StepStatus }\n", "type": "registry:ui", "target": "components/step-indicator.tsx" }, { "path": "src/lib/utils.ts", "content": "import { clsx, type ClassValue } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n", "type": "registry:lib", "target": "lib/utils.ts" } ], "type": "registry:ui", "meta": { "nswdsVersion": "5.1.0" } }