← All stories

COMPONENT · ui-stepper

ui-stepper

Component Tier 1 (primitive) Used by create-event wizard, Canvas template-start, custom question builder, email design wizard

A multi-step wizard primitive: numbered steps, current step indicator, forward/back navigation, validation gates, and the URL-state mandate (every step is its own URL). The trickiest piece is making the back navigation safe — going back must not lose the next step's draft.

Component contract

Renders a multi-step wizard with header indicator and navigation. URL sync is mandatory per the SPA-state mandate.

  • steps: StepDef[] — id, label, optional, validate?: () => ValidationResult.
  • currentStepId: string — controlled.
  • onStepChange: (nextStepId, direction) => void | Promise<void> — fires before transition; Promise return gates the change.
  • onComplete: () => void | Promise<void> — fires when the user advances past the last step.
  • linkBuilder?: (stepId) => string — when provided, step header items are also <a href>; allows direct URL navigation. Mandatory in production.
  • allowSkip?: boolean — defaults to false; when true, optional steps can be skipped without validation.
  • cancelHref?: string — Cancel returns user here; without it, no Cancel button.
  • nextLabel?: string — overrides "Next" label (e.g., "Save and continue" on the last step).

Interaction surface

  1. Step header renders all steps with status.

    Each step header item shows: number, label, status (upcoming, current, completed, error). Current step is visually highlighted. Completed steps are clickable to go back; upcoming steps are not clickable until reached.

  2. Back / Next buttons.

    Back is disabled on first step. Next runs the current step's validator before advancing; on validation failure, stays on the step and surfaces the error. Next on the last step calls onComplete.

  3. Step header item is also a navigation control.

    Clicking a completed step header item is a "back" navigation — does NOT validate the current step (going back is always free). Clicking a non-reached step does nothing (or shows a tooltip "Complete previous steps first"). Clicking the current step does nothing.

  4. URL sync.

    When linkBuilder is provided, each step has its own URL. Browser back/forward navigates between steps. Refresh on any step lands the user on that step. If the user lands on a step they haven't reached and no draft exists for the prerequisite steps, the component redirects to the first incomplete step.

  5. Cancel.

    Cancel button (when cancelHref provided) prompts via ui-destructive-confirmation if there's unsaved work, then navigates to cancelHref. Without unsaved work, navigates immediately.

Failure modes

Back navigation loses draft state of subsequent steps

Trigger: user fills steps 1–3, goes back to step 1, comes forward to step 2; step 2 form is empty.

The component does NOT own form state — that's the parent's job. But the contract is: navigating between steps does not unmount the per-step content (the children render conditionally; their state is preserved by the parent). Harness: fill step 2, go back to step 1, return to step 2, assert form values intact. (This is verified at the branch level since the component itself is just navigation.)

Next advances despite validation failure

Trigger: validate() returns invalid but Next still calls onStepChange.

Next runs validate synchronously; if invalid, surfaces the validation result inline and does NOT call onStepChange. Harness: validate returns {valid: false, message: "Required"}, click Next, assert onStepChange NOT called and message visible.

Direct URL navigation lands on a step out of order

Trigger: user visits /create-event?step=4 directly without completing steps 1–3.

Component checks "has the user reached this step before?" via the parent's draft state. If not, redirects to the first incomplete step. Harness: navigate to step 4 fresh, assert URL settled to step 1 (or wherever the first incomplete step is).

Cancel doesn't prompt for unsaved work

Trigger: user has filled half the wizard; Cancel navigates immediately, losing all draft state.

Cancel checks hasUnsavedWork via a parent-supplied predicate; when true, opens ui-destructive-confirmation. Harness: hasUnsavedWork=true, click Cancel, ui-destructive-confirmation visible.

Step header click triggers validation

Trigger: clicking a previous step's header item runs the current step's validator and blocks the back-navigation.

Going back is always free. Validation runs only on Next. Harness: current=step3, validate returns invalid, click step1 header, assert navigation to step1 succeeds.

Optional step can't be skipped

Trigger: step.optional=true and allowSkip=true, but Next still requires validation pass.

Optional steps with allowSkip=true add a "Skip this step" button next to Next. Skip advances without validation. Required steps never show Skip. Harness: optional step, click Skip, onStepChange called with next stepId.

Promise from onStepChange resolves but step doesn't advance

Trigger: onStepChange returns Promise; resolves; the parent didn't update currentStepId; component is stuck.

Component shows a busy state during the Promise. After resolution, if currentStepId hasn't changed, surface a console warning in dev mode. Harness: stub onStepChange to resolve without parent update, assert dev warning.

Step header items not keyboard-traversable

Trigger: step headers are <div> with onClick but no tabindex.

Each header item is a <button> (or <a> when linkBuilder is provided). Tab traverses through them in order. Harness: Tab from current content, assert focus reaches each completed step header in DOM order.

Last step's Next still says "Next"

Trigger: nextLabel not customized on the last step; user thinks there's another step.

When on the last step, the default Next label becomes "Complete" or whatever nextLabel resolves to. Harness: render at last step, assert button text != "Next".

Accessibility

  • Step header is <ol role="list" aria-label="Wizard steps">.
  • Each step item has aria-current="step" when current; aria-label="Step N: [label] (completed)" reflects status.
  • Step content has role="region" + aria-labelledby pointing at the current step's label.
  • Validation errors live in a region with role="alert".
  • Color contrast: step number/label ≥ 4.5:1 in every state, current step indicator ≥ 3:1 against background.
  • axe-clean at severity ≥ serious.

Stable test attributes

data-testWherePurpose
ui-stepperOuter wrapperdata-current-step attr
ui-stepper-headerStep list (ol)Header region
ui-stepper-stepEach step header itemdata-step-id + data-status (upcoming/current/completed/error)
ui-stepper-contentCurrent step content regionAlways rendered, contains parent-supplied children
ui-stepper-backBack buttonDisabled on first step
ui-stepper-nextNext buttonLabel changes on last step
ui-stepper-skipSkip buttonVisible only on optional+allowSkip steps
ui-stepper-cancelCancel buttonVisible only when cancelHref provided
ui-stepper-validation-errorInline validation errorrole=alert; visible after failed Next

Agent test plan

Standalone probes against /admin-test/ui-stepper-fixture with variants: 3-step linear, 5-step with optional, with-link-builder, with-cancel, async-onStepChange.

Probe list
- header-renders-all-steps: assert ui-stepper-step count === steps.length
- current-step-aria-current: data-status=current AND aria-current=step
- completed-step-clickable: completed step header click triggers back nav
- upcoming-step-not-clickable: click upcoming, no nav, tooltip visible
- back-disabled-on-first: ui-stepper-back disabled when current=first
- next-validates-before-advance: validate=invalid, click Next, onStepChange NOT called, error visible
- next-advances-on-valid: validate=valid, click Next, onStepChange called with next stepId
- next-on-last-step-completes: at last step, click Next, onComplete called
- last-step-button-label: at last step, ui-stepper-next text != "Next"
- header-click-back-no-validation: validate=invalid, click previous step header, nav succeeds
- skip-visible-on-optional-with-allowSkip: optional step + allowSkip → ui-stepper-skip visible
- skip-not-visible-on-required: required step → ui-stepper-skip not visible
- skip-bypasses-validation: optional + allowSkip + invalid validate, click Skip, onStepChange called
- cancel-prompts-when-unsaved: hasUnsavedWork=true, click Cancel, ui-destructive-confirmation visible
- cancel-immediate-when-clean: hasUnsavedWork=false, click Cancel, navigate to cancelHref
- direct-url-out-of-order-redirects: navigate to step 4 fresh, current settles at step 1
- direct-url-completed-step-allowed: completed step 1, navigate to step 1 → renders step 1
- async-onStepChange-shows-busy: returns pending Promise, ui-stepper-next shows busy state
- url-sync-with-linkBuilder: linkBuilder provided, each step header is  with href
- focus-on-step-change: advancing focuses the new step's content region
- header-keyboard-traversal: Tab through completed step headers in DOM order
- color-contrast-step-label: ≥ 4.5:1 in every status
- color-contrast-current-indicator: ≥ 3:1
- axe-clean-serious: no serious violations