Stepper
Show progress through a meaningful task while the application owns validation, draft state and completed outcomes.
Progress is not workflow logic
Use Stepper when a task has a small ordered set of meaningful stages. The component displays an ordered progression; it does not validate fields, navigate, save, authorize or certify completion. Use Tabs for independent views and Progress for quantitative work whose stages are not useful destinations.
Keep the current stage and what remains understandable. A checked preceding stage means the caller supplied a later active index; it is not evidence that a backend saved data or a qualified reviewer approved content. The example separates passing through stages from applying a real local result.
Anatomy and visual specifications
| Part | Emitted contract |
|---|---|
| Root | Native div/ref with list semantics. Each direct React child becomes a stage; orientation defaults horizontal. No tablist, roving focus or automatic navigation is added. |
| Stage | mt-stepper-step listitem with completed/active/pending classes. Exactly the current item has aria-current=step; all-complete has no current item. |
| Indicator | Actual mt-stepper-indicator: 32px circle, 2px boundary, 14px numeral or decorative 16px check. It is not a 32px interactive target. |
| Status | A localized visually hidden status supplements each label. Numeral/check and connectors are decorative; completion is also available as text. |
| Label / description | Step emits a 14px label and optional 12px description. Text wraps in shrinkable content; labels are not forced onto one line. |
| Connectors | Actual mt-stepper-connector: 2px line, 16px length, separate completion styling and aria-hidden. Logical margins follow direction. |
| States / direction | Current outline is independent from completed fill/check. Vertical stacks stages; horizontal follows DOM direction. Forced colors retain boundaries/current state; there is no animated transition. |
A retained draft with actual completion
The checked English-only form has Details, optional Context and Review. Continue validates the title before moving. Back retains both values and focuses a surviving stage heading. Review displays the actual complete draft, and Request snapshots it independently from the loaded result.
Failure/cancellation keep review values and the loaded result; retry is explicit. Only Complete applies real visible content and marks all stages completed. Editing after completion retains the draft and old loaded result. No remote publication, durable save or language authority is claimed.
The measured container switches to vertical below 560px; a control can also request vertical layout. RTL changes DOM direction without creating a keyboard navigation model. Hidden editors are not submitted as a partial native form: the complete reviewed parent-owned draft is explicitly captured.
Local draft details
No local draft applied.
Loaded local result
No local draft loaded.
Draft values belong to this example’s parent state. Hidden step editors do not register a partial native form; the reviewed complete draft is snapshotted explicitly.
'use client';
import {useEffect,useRef,useState,type CSSProperties} from 'react';
import {Stepper,Step,Input,Textarea,Button} from '@mobtranslate/ui';
const action:CSSProperties={minWidth:0,maxWidth:'100%',minHeight:44,height:'auto',whiteSpace:'normal',overflowWrap:'anywhere'};
type Draft={title:string;note:string};
export function StepperDraftExample(){
const [step,setStep]=useState(0);
const [draft,setDraft]=useState<Draft>({title:'',note:''});
const [requested,setRequested]=useState<Draft|null>(null);
const [applied,setApplied]=useState<Draft|null>(null);
const [error,setError]=useState('');
const [vertical,setVertical]=useState(false);
const [rtl,setRtl]=useState(false);
const [compact,setCompact]=useState(false);
const [status,setStatus]=useState('No local draft applied.');
const heading=useRef<HTMLHeadingElement>(null);
const input=useRef<HTMLInputElement>(null);
const shell=useRef<HTMLDivElement>(null);
useEffect(()=>{const node=shell.current;if(!node)return;const observer=new ResizeObserver(([entry])=>setCompact(entry.contentRect.width<560));observer.observe(node);return()=>observer.disconnect();},[]);
function move(next:number){setStep(next);setError('');requestAnimationFrame(()=>heading.current?.focus());}
function resolve(result:'complete'|'fail'|'cancel'){
if(!requested)return;
if(result==='complete'){setApplied(requested);setStatus('Actual local draft applied. No remote publication or durable save.');move(3);}
else {setStatus(`Local draft ${result==='fail'?'failed':'cancelled'}. Values and loaded result retained; retry is available.`);heading.current?.focus();}
setRequested(null);
}
return <div ref={shell} data-stepper-lab className="space-y-4" dir={rtl?'rtl':'ltr'}>
<div className="flex flex-wrap gap-3"><Button style={action} type="button" variant="outline" aria-pressed={vertical} onClick={()=>setVertical(v=>!v)}>Use vertical draft progress</Button><Button style={action} type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(v=>!v)}>Use RTL draft layout</Button></div>
<Stepper activeStep={step} orientation={vertical||compact?'vertical':'horizontal'} aria-label="Local draft progress"><Step label="Details" description="Name the local draft" /><Step label="Context" description="Optional English-only note" /><Step label="Review" description="Confirm the actual values" /></Stepper>
<h3 ref={heading} tabIndex={-1} data-stepper-heading className="font-semibold">{step===0?'Local draft details':step===1?'Optional local context':step===2?'Review local draft':'Local draft completed'}</h3>
<form noValidate className="space-y-4" onSubmit={event=>{event.preventDefault();if(requested||step===3)return;if(!draft.title.trim()){setStep(0);setError('Enter an English-only local draft title.');requestAnimationFrame(()=>input.current?.focus());return;}if(step<2){move(step+1);return;}setRequested({...draft,title:draft.title.trim()});setStatus('Draft requested; loaded result remains until explicit completion.');heading.current?.focus();}}>
{step===0&&<div><label htmlFor="stepper-title" className="block mb-2">English-only local title</label><Input ref={input} id="stepper-title" required value={draft.title} onChange={event=>{setDraft(current=>({...current,title:event.target.value}));setError('');}} aria-invalid={!!error} aria-describedby={error?'stepper-title-error':undefined} /></div>}
{step===1&&<div><label htmlFor="stepper-note" className="block mb-2">Optional English-only note</label><Textarea id="stepper-note" value={draft.note} onChange={event=>setDraft(current=>({...current,note:event.target.value}))} /></div>}
{step===2&&<div data-stepper-review style={{overflowWrap:'anywhere'}}><p>Title: {draft.title}</p><p>Note: {draft.note||'No optional context.'}</p></div>}
{error&&<p id="stepper-title-error" role="alert">{error}</p>}
<div className="flex flex-wrap gap-3">{step>0&&step<3&&<Button style={action} type="button" variant="outline" disabled={!!requested} onClick={()=>move(step-1)}>Back in local draft</Button>}{step<3&&<Button style={action} type="submit" disabled={!!requested}>{step===2?'Request local draft':'Continue local draft'}</Button>}{step===3&&<Button style={action} type="button" variant="outline" onClick={()=>move(0)}>Edit retained local draft</Button>}</div>
</form>
{requested&&<div className="flex flex-wrap gap-3"><Button style={action} type="button" onClick={()=>resolve('complete')}>Complete local draft</Button><Button style={action} type="button" variant="outline" onClick={()=>resolve('fail')}>Fail local draft</Button><Button style={action} type="button" variant="outline" onClick={()=>resolve('cancel')}>Cancel local draft request</Button></div>}
<p role="status" data-stepper-status>{status}</p><h3 className="font-semibold">Loaded local result</h3><div data-stepper-applied style={{overflowWrap:'anywhere'}}>{applied?<><p>{applied.title}</p><p>{applied.note||'No optional context.'}</p></>:<p>No local draft loaded.</p>}</div>
<p>Draft values belong to this example’s parent state. Hidden step editors do not register a partial native form; the reviewed complete draft is snapshotted explicitly.</p>
</div>;
}
Complete public API
| Export / prop | Ownership |
|---|---|
| Stepper.activeStep | Required zero-based integer. 0..children.length−1 identifies current stage; children.length is the explicit all-complete sentinel. Index is positional and controlled by caller. |
| Invalid activeStep | Negative, fractional, nonfinite or greater-than-count indices mark data-invalid-step/initial aria-invalid and render every stage pending. Reconcile dynamic stages through the task model rather than claiming success. |
| Stepper.orientation | horizontal or vertical, default horizontal. No automatic breakpoint or navigation is built into the component; the example’s cleaned-up ResizeObserver owns adaptation. |
| Stepper.getStepStatusLabel(index,status) | Localizable completed/current/pending status text. Defaults to English Completed, Current step and Not started. Give stage labels useful meaning independently. |
| Stepper.children | Every direct child is one stage; React.Children.toArray handles direct child identity. Nested fragments are not a domain workflow definition. Use stable stage ordering and reconcile active index when it changes. |
| Step.label / description | Required label string and optional supporting description. Step supplies presentational text, not a button, registered form field or step-change action. |
| Native props / refs | Stepper and Step accept native div attributes, className/style and HTMLDivElement refs. Preserve list/listitem/current semantics when customizing. |
| Application boundary | Validation, editable/completed status, Back/Continue actions, errors, route/history policy, persistence and asynchronous results remain application-owned. |
Validation, draft lifetime and submission
- Keep essential instructions and recovery visible. Never advance merely because an index can be incremented; validate the actual stage and focus a linked error target.
- Retain meaningful drafts above conditional step editors. Unmounting an editor must not erase values that Back/Review needs. Parent ownership is distinct from durable persistence.
- Review the entire submitted model. Named inputs from only the visible stage cannot prove the full form was submitted; register retained fields explicitly or capture the validated parent model, as this example does.
- During a request, prevent duplicate forward/back transitions that would invalidate the reviewed snapshot. Leave explicit cancellation and outcome controls reachable.
- Failure and cancellation must not display all stages completed or replace a loaded result. Validate/reconcile current data again before a retry if availability can change.
- Returning to an earlier stage may invalidate dependent stages. This component knows only positions; the product must reconcile branches, skipped/optional stages and completed work explicitly.
Keyboard, focus and adaptation
- Progress items are not focusable commands and have no arrow-key interaction. Back/Continue/Request are ordinary named buttons; fields keep normal native editing behavior.
- On stage arrival, focus its useful heading or first meaningful control. On validation failure, focus the actual labelled field and connect the visible correction.
- Keep focus/status outside disabled request controls. Completion must reach a surviving result or stage heading after editor removal. Do not rely on a disappearing Submit button.
- Prefer vertical layout when stage names cannot fit horizontally. Adapt to actual available inline width and content, not only a guessed device type. Check long labels, zoom and text-spacing overrides.
- The diagram has no fixed bottom placement or gestures. Link-based direct stage navigation needs actual URLs, admissibility rules, history/arrival focus and separate tests.
States, failures and verification
| State | Evidence required |
|---|---|
| First / current / preceding stages | Correct count, useful labels, a single aria-current item and actual 32px indicators/2px connectors. |
| Validation / Back | No invalid advance; error field is focused and earlier values survive remount. |
| Review / pending | Actual complete values are shown and snapshotted; duplicate transitions are unavailable. |
| Failed / cancelled / retried | Loaded content and draft are retained; no false completed diagram. |
| Completed / edit again | All-complete sentinel follows explicit outcome; new editing keeps loaded context and reconciles progress. |
| Dynamic / empty / invalid stages | Caller reconciles positional indices; invalid external input never presents automatic success. |
| Horizontal / vertical / RTL | Wrapping, status text and order remain readable; direction does not introduce fake keyboard commands. |
Migration and quality gates
The previous stylesheet targeted mt-step-* indicator/connector/state selectors while the renderer emitted mt-stepper-*. Styling now follows the actual emitted DOM, and hidden status text supplements decorative completion marks. The old counter-only demo is replaced by a validated retained draft with observable outcomes.
- Run test:stepper, checked-source/token freshness, lint, shared UI/styleguide types, production build and complete route/fragment crawl. Verify actual draft retention, validation, focus, requested/applied separation, state semantics, dimensions, contrast and narrow adaptation.
- Hardware/device and spoken assistive-technology behavior, branch-specific workflows and remote save races require separately scoped evidence. A styleguide simulation does not establish a persisted backend result.