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
-
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.
-
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.
-
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.
-
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.
-
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-labelledbypointing 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-test | Where | Purpose |
|---|---|---|
ui-stepper | Outer wrapper | data-current-step attr |
ui-stepper-header | Step list (ol) | Header region |
ui-stepper-step | Each step header item | data-step-id + data-status (upcoming/current/completed/error) |
ui-stepper-content | Current step content region | Always rendered, contains parent-supplied children |
ui-stepper-back | Back button | Disabled on first step |
ui-stepper-next | Next button | Label changes on last step |
ui-stepper-skip | Skip button | Visible only on optional+allowSkip steps |
ui-stepper-cancel | Cancel button | Visible only when cancelHref provided |
ui-stepper-validation-error | Inline validation error | role=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