-
Notifications
You must be signed in to change notification settings - Fork 0
LayrzStepper
A full-width step navigator for multi-step flows — wizards, onboarding, guided forms.
Specification Status:
LayrzStepperhas shipped and is implemented atlib/src/steppers/src/stepper.dart(+stepper_wide.dart,stepper_compact.dart,step_indicator.dart,step.dart,stepper_controller.dart,stepper_state.dart). This page describes the shipped, redesigned widget only — there is no legacyThemedStepperpredecessor inlayrz_themeand no pre-implementation design sketch to reconcile.
LayrzStepper is a thin StatefulWidget coordinator. It owns the LayrzStepperController
lifecycle, resolves each step's LayrzStepperState, and renders one of two layouts based on the
required direction: LayrzStepperDirection parameter — it renders no circle, connector, or label
geometry itself, and it never reads context.isCompact on its own.
-
LayrzStepperDirection.horizontal(LayrzStepperWideHeader) — one equal-width flex cell per step, spanning the entire available width, connected by a line. -
LayrzStepperDirection.vertical(LayrzStepperCompactLayout) — a vertical accordion: the active step's body renders inline under its own header, all other steps are collapsed to a header row.
There is no default and no viewport-derived inference: direction is required, so a caller always
states the axis explicitly. A caller that wants the previous width-derived behaviour reproduces it
at the call site:
direction: context.isCompact
? LayrzStepperDirection.vertical
: LayrzStepperDirection.horizontal,A single Back/Next button row, driven by LayrzStepperController.canMoveNext /
canMovePrevious, sits below either layout — navigation actions are stepper-level in both
layouts, never per-step, even in the compact accordion.
LayrzStepper needs roughly 150 logical pixels of height before it can render a step body at
all, regardless of direction. That floor is made up of fixed-height chrome that does not shrink:
the wide layout's indicator band (kLayrzStepperWideBandHeight, matching the 40px step indicator),
its label line below that band, a spacing gap, and the Back/Next button row (a LayrzButton row
plus its own vertical padding). None of these are optional or collapsible — they are the minimum
UI LayrzStepper promises to render on every call, and there is no compact fallback below this
floor.
A caller placing LayrzStepper in a bounded box shorter than this (an Expanded inside a small
LayrzCard, a fixed-height container, a narrow bottom sheet) will see a RenderFlex overflow —
this is a real constraint to design around, not a bug to report. In practice this only matters for
genuinely small boxes: the showroom's own permanent regression test
(example/test/steppers_section_overflow_test.dart) exercises sizes as small as 400×400 cleanly,
and deliberately excludes 320×300 — which leaves the stepper well under 150px after the page's own
chrome — with this same floor recorded as the reason. If the numbers behind
kLayrzStepperWideBandHeight or the button row's padding change, re-check this figure rather than
assuming it still holds.
Every step's cell stacks two independent bands in a Column:
-
Band 1 — a fixed-height
SizedBox(kLayrzStepperWideBandHeight, matchingkLayrzStepIndicatorSize) holding theLayrzStepIndicatorcircle and the connector line segments on either side of it, all vertically centred on the same midline. - Band 2 — the step's label, hanging below band 1.
This two-band split is a structural fix for a real, previously-shipped bug: modelling a step as a
single Column[circle, label], with those columns sitting as centre-aligned siblings in a Row,
let a two-line label grow its own column taller than its neighbours — which re-centred that column
and visibly shifted its circle out of line with the others and with the connector. Because band 1's
height here does not depend on band 2's content in any way, that feedback path cannot exist: no
label, however long, can reach back into the indicator row's layout.
Labels are capped at maxLines: 2 with TextOverflow.ellipsis and have no recovery
affordance — no tooltip, no long-press to reveal the full text. A truncated label stays
truncated; this is a deliberate ruling, not an oversight.
The header spans the full available width and never scrolls — "using the entire space available" and horizontal scrolling are opposites. There is no maximum step count: an unusually long step list squeezes into narrower cells instead of being capped, scrolled, or rejected. This is a deliberate ownership boundary — legibility at extreme step counts is the caller's responsibility — recorded in decision D57's 2026-08-27 update, where a proposed ~5–6 step threshold was considered and declined.
An upcoming (locked) step additionally carries a small lock badge (MdiIcons.lockOutline)
overlaid on its indicator, so a locked step is distinguishable by more than colour and a thin
border alone.
LayrzStepperCompactLayout flips the axis from left-to-right to top-to-bottom. Every step renders
as a header row; only the active step additionally hosts its LayrzStep.body inline beneath
its own header, expanded via AnimatedSize (tokens.motion.dTransition / tokens.motion.easing,
alignment: Alignment.topCenter) — the first use of AnimatedSize anywhere in this library, and
now the house pattern for expand/collapse.
This is deliberately not a general-purpose accordion:
- Exactly one step is ever open, and it is always the active one — driven entirely by
currentIndex. There is no independent per-row expanded/collapsed flag anywhere in the widget. Tapping a completed step's header navigates (movescurrentIndex); it does not open a second panel alongside the first. - Upcoming (locked) steps can never be opened by the user, no matter how many times they are tapped — the accordion feel describes presentation, not a change to navigation rules.
A persistent, always-visible "Step X of Y" counter renders above the stack, additive to the per-step headers rather than replacing them: the stack answers "what am I getting into," the counter answers "how much is left" at a glance, without requiring a scan of every collapsed row.
Each header row shows a trailing glyph: a disclosure chevron (MdiIcons.chevronDown) on tappable
rows, or a distinct lock glyph (MdiIcons.lockOutline) on locked ones — never both, and never
neither, so a locked row's non-response to a tap is explained rather than reading as a frozen app.
LayrzStepIndicator (exported on its own — see below) owns the entire rule for what renders inside
a step's circle, and is the only place that rule may live:
| State | Circle content | Background |
|---|---|---|
completed |
MdiIcons.check — always, overriding any LayrzStep.icon
|
success |
error |
MdiIcons.alertCircle — always, overriding any LayrzStep.icon
|
danger |
active |
LayrzStep.icon if supplied, else the 1-based step number |
primary |
upcoming |
LayrzStep.icon if supplied, else the 1-based step number |
surface, with a divider-colored border |
LayrzStep.icon is the step's identity icon (e.g. a credit-card glyph for a billing step); the
state glyph communicates the step's status. The override on completed/error is fixed and not
caller-configurable — there is no parameter to disable it, because WCAG 1.4.1 requires status to be
legible without colour, so the status glyph must always win. This is decision D57's accessibility
clause, unamended by the 2026-08-27 redesign and now also governing the caller-icon feature.
completed, active, and error steps are all tappable in both layouts; only upcoming steps
are locked. Error steps being tappable is a fix, not new scope: LayrzStepperState.error's own
documentation already promised a step "can be jumped to for correction" before this redesign, but
the previously-shipped code locked error steps out of both layouts regardless. Both layouts now
honour that promise. The one visible consequence: a compact-layout error row that is not the
currently open step carries expanded: false (a real, closed, re-openable row) rather than
expanded: null (reserved for a step, like a locked one, that can never be expanded at all).
- Each step's
Semanticsnode carriesbutton,enabled, and a label combining the localized position ("Step 2 of 3"), the step's ownlabelText, and a localized state fragment — built fromLayrzUiL10nSteppersMixin.steppersStepCounterLabelandsteppersStateLabelrespectively, so both layouts announce the exact same state the exact same way. Theupcomingstate's fragment includes the word "locked" for exactly this reason: the same state must not announce differently by viewport width, and "locked" is also what tells a screen-reader user why a step does not respond to activation. - In the compact layout,
expandedisisOpenfor tappable rows andnull(notfalse) for locked ones —falsewould misrepresent a locked row as a closed-but-openable disclosure, which contradicts its ownenabled: false. -
ExcludeFocus(excluding: !isOpen)keeps a collapsed compact-layout step's body out of the tab order. This matters on desktop regardless of window width, since a caller can chooseLayrzStepperDirection.verticalon a wide desktop window as freely as on a narrow one, and a mouse-and-keyboard user would otherwise still be able to Tab into a body that is visually hidden. This is a new precedent in the library — nothing else usesExcludeFocus— and is now the house answer for hiding collapsed content from keyboard navigation. - Connector lines and the step number/icon glyph's decorative wrapper are excluded from semantics.
| Property | Value |
|---|---|
| Predecessor | None — no ThemedStepper exists in layrz_theme
|
| Phase | M3 Inputs |
| Domain | Navigation |
| SDK Primitive | Hand-rolled (Row/Column/AnimatedSize/CustomPaint-free; no Material Stepper) |
| Parameter | Type | Notes |
|---|---|---|
steps |
List<LayrzStep> |
Required. assert(steps.length > 0). No maximum. |
controller |
LayrzStepperController? |
Optional. If null, the stepper creates, owns, and disposes its own. If non-null, the caller owns disposal, and the instance must never be swapped — an assertion fails (debug builds only) if a different controller is passed on rebuild. |
onStepChanged |
void Function(int stepIndex)? |
Fired with the new active step's zero-based index whenever it changes. |
backButtonLabel |
String? |
Overrides the "Back" button label. Defaults to LayrzUiL10n.steppersPreviousButtonLabel. |
nextButtonLabel |
String? |
Overrides the "Next" button label. Defaults to LayrzUiL10n.steppersNextButtonLabel. |
direction |
LayrzStepperDirection |
Required. Selects which layout renders: horizontal for LayrzStepperWideHeader, vertical for LayrzStepperCompactLayout. No default and no viewport-derived inference — a caller reproducing the old width-derived behaviour writes direction: context.isCompact ? LayrzStepperDirection.vertical : LayrzStepperDirection.horizontal. |
An immutable data class, @immutable, with copyWith / == / hashCode.
| Field | Type | Notes |
|---|---|---|
labelText |
String |
Required. The step's display label, shown in both layouts. |
body |
Widget |
Required. Rendered only while the step is active — inline under the header in the wide layout, inline in the open accordion row in the compact layout. |
state |
LayrzStepperState? |
Optional override. If null, the stepper derives it from progression (steps before the current index default to completed, after default to upcoming). The stepper always forces the active index to active, overriding this field if set. |
icon |
IconData? |
Optional identity icon shown in the indicator while upcoming or active. Always overridden by the state glyph once completed or error — see the icon-vs-state-glyph rule above. |
Equality caveat: LayrzStep's == and hashCode depend on body, a Widget, compared by
identity. Two structurally identical steps built from separate const Text('x') instances are not
equal to each other.
upcoming · active · completed · error — trimmed from Material's full step-state
vocabulary per decisions D27/D28.
horizontal · vertical — the required value of LayrzStepper.direction. horizontal renders
LayrzStepperWideHeader; vertical renders LayrzStepperCompactLayout. See decision D57's
2026-08-27 addendum for why this replaced a viewport-derived isCompact override.
| Member | Notes |
|---|---|
currentStepIndex |
Zero-based index of the active step. |
stepCount |
Total step count, set internally by the stepper. |
canMoveNext / canMovePrevious
|
Whether the current step is not the last / not the first. |
Future<void> next() |
Advances one step. Gated by a canAdvance validation callback if one is set via setCanAdvance; a no-op on the last step. |
void previous() |
Moves back one step, without validation. A no-op on the first step. |
void goTo(int index) |
Jumps directly to index, no validation — used to jump back to a completed or error step for review/correction. Out-of-bounds or same-index calls are no-ops. |
setCanAdvance(Future<bool> Function()? callback) |
Sets or clears the async validation gate checked by next(). |
reset() |
Returns to the first step. |
dispose() |
Caller-owned if the controller was caller-supplied; stepper-owned otherwise. See LayrzStepper.controller's doc comment for the full lifecycle contract. |
The circular per-step indicator both layouts render, exported on its own so it can be reasoned about — and, in principle, reused — independently of the two internal layouts that consume it.
| Parameter | Type | Notes |
|---|---|---|
index |
int |
Required. Zero-based step position; renders as the 1-based number when no icon is supplied. |
state |
LayrzStepperState |
Required. Drives background colour and the glyph-override rule (see above). |
icon |
IconData? |
Optional identity icon; overridden by the state glyph on completed/error. |
lib/src/steppers/steppers.dart exports LayrzStep, LayrzStepIndicator, LayrzStepper,
LayrzStepperController, LayrzStepperDirection, and LayrzStepperState — it does not export
LayrzStepperWideHeader or LayrzStepperCompactLayout. This is a deliberate maintainer choice,
not an oversight: keeping the two layouts private preserves the freedom to change either one's
internal structure without that being a breaking API change. The trade-off is real — a consumer
who wants only the wide header as a standalone progress indicator, without a body or navigation
actions, cannot compose it directly and must use the full LayrzStepper. See decision D57's
2026-08-27 update for the full reasoning.
-
Spacing:
sp1–sp4for the compact layout's row padding and the wide layout's inter-band gaps. -
Colors:
primary(active indicator, progressed connector),success(completed),danger(error),sf1–sf3(upcoming indicator background, compact row hover),fg1/fg2(label text),divider(unprogressed connector, upcoming indicator border). -
Typography:
label(indicator numbers, counter text),body(step labels in the compact layout). -
Motion:
dTransition+easingfor the compact layout'sAnimatedSize.
Component Catalog,
Design Tokens,
Milestone 3,
Decision D57
Last updated: 2026-08-27 (documents the 2026-08-27 full-page redesign — the wide/compact layout
split, the alignment fix, the accordion behaviour, the caller-icon feature and its state-glyph
override rule, and the accessibility affordances added alongside it — and, same day, the follow-up
replacement of the viewport-derived isCompact override with the required direction: LayrzStepperDirection parameter)
Made with ❤️ by Golden M, Inc.
- LayrzTextInput
- LayrzSelectableAction
- LayrzSelectionToolbar
- LayrzTextSelectionControls
- LayrzSelectionMagnifier
- LayrzSelectionHandlePainter
- LayrzTextAreaInput
- LayrzComboBoxInput
- LayrzNumberInput
- LayrzPasswordInput
- LayrzCheckboxInput
- LayrzRadioInput
- LayrzSelectInput
- LayrzMultiSelectInput
- LayrzSearchInput
- LayrzDualListInput
- LayrzDurationInput
- LayrzSlider
- LayrzStepper