Skip to content

LayrzStepper

Kenny Mochizuki Escalona edited this page Aug 28, 2026 · 3 revisions

LayrzStepper

A full-width step navigator for multi-step flows — wizards, onboarding, guided forms.

Specification Status: LayrzStepper has shipped and is implemented at lib/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 legacy ThemedStepper predecessor in layrz_theme and no pre-implementation design sketch to reconcile.

Shipped behavior: two layouts, one coordinator

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.

Constraints: minimum height

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.

Shipped behavior: the wide layout's alignment fix

Every step's cell stacks two independent bands in a Column:

  • Band 1 — a fixed-height SizedBox (kLayrzStepperWideBandHeight, matching kLayrzStepIndicatorSize) holding the LayrzStepIndicator circle 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.

Shipped behavior: the compact layout — "acts like an accordion"

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 (moves currentIndex); 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.

Shipped behavior: the step indicator and the icon-vs-state-glyph rule

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.

Shipped behavior: tappability, and error steps specifically

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).

Shipped behavior: accessibility

  • Each step's Semantics node carries button, enabled, and a label combining the localized position ("Step 2 of 3"), the step's own labelText, and a localized state fragment — built from LayrzUiL10nSteppersMixin.steppersStepCounterLabel and steppersStateLabel respectively, so both layouts announce the exact same state the exact same way. The upcoming state'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, expanded is isOpen for tappable rows and null (not false) for locked ones — false would misrepresent a locked row as a closed-but-openable disclosure, which contradicts its own enabled: 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 choose LayrzStepperDirection.vertical on 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 uses ExcludeFocus — 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.

Metadata

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)

API

LayrzStepper

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.

LayrzStep

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.

LayrzStepperState (enum)

upcoming · active · completed · error — trimmed from Material's full step-state vocabulary per decisions D27/D28.

LayrzStepperDirection (enum)

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.

LayrzStepperController extends ChangeNotifier

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.

LayrzStepIndicator (exported independently)

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.

Not exported: the two layouts

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.

Design tokens used

  • Spacing: sp1sp4 for the compact layout's row padding and the wide layout's inter-band gaps.
  • Colors: primary (active indicator, progressed connector), success (completed), danger (error), sf1sf3 (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 + easing for the compact layout's AnimatedSize.

Related documents

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)

Clone this wiki locally