<!-- GENERATED by scripts/build-llms.mjs from llms/layout.md — do not edit this file. -->

# `lr-stepper`

- **Import** `import '@aceshooting/lyra-ui/components/lr-stepper.js';` (stable tag alias; registers the tag)
- **Class** `LyraStepper`, also available unregistered from `@aceshooting/lyra-ui/components/layout/stepper/stepper.class.js`
- **Family** `components/layout/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 7 parts, 10 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-stepper`

Ordered multi-step wizard/form navigation: an index/label per step, independent
`pending`/`current`/`completed`/`error` progress plus disabled availability, and click-to-jump.
First-party invention (no `wa-*`/`sl-*` counterpart). Fully
data-driven and controlled, like `lr-table`'s `columns`/`rows` — it never mutates `steps` itself; a
click, or Enter/Space on a non-disabled step, fires a non-cancelable `lr-step-select`, and the host
decides whether/how `steps` changes in response.

**Properties:**

- `steps: readonly LyraStepItem[] = []` (attribute: false) — `LyraStepItem { stepId: string; label:
string; state: LyraStepState; disabled?: boolean; title?: string; icon?: unknown }`, where
  `LyraStepState` is `'pending' | 'current' | 'completed' | 'error'`. `disabled` independently gates
  activation and roving focus, so locking a current/completed/error step does not erase its progress.
  `title` is an optional native tooltip for the step's button (e.g. explaining why a `disabled` step
  is locked) — omit it for no `title` attribute at all, not an empty string. `icon` is an optional
  leading topic glyph (a `TemplateResult`, an emoji string, etc. — not restricted to a square icon)
  rendered as inert, `aria-hidden` decoration in the `step-icon` part, additionally to — never
  instead of — the state-driven `step-index`/`step-check` glyph. It provides no independent action
  or accessible name. Input is read through a realm-neutral bounded schema snapshot (at most 256
  positions); malformed/hostile entries are skipped while valid neighbors survive, and the frozen
  returned array/records never alias caller-owned objects. Duplicate step IDs are supported as
  ordered occurrences because selection detail always includes `index`; keyed rendering and focus
  restoration correlate `{ stepId, index }` so a refresh retains the focused duplicate occurrence.
  Empty (the default) renders nothing.
- `orientation: 'horizontal' | 'vertical' = 'horizontal'` (reflected) — `'horizontal'` (the default)
  lays steps out in a row (Left/Right, RTL-aware, navigate); `'vertical'` stacks them (Up/Down
  navigate instead, no RTL swap needed). The axis used at/above `orientationBreakpoint` (or always,
  when that's unset).
- `orientationBreakpoint?: number | string` (attribute `orientation-breakpoint`) — opt-in inline-size
  breakpoint measured on `[part='base']`; unset (the default) means no behavior change at all, and
  no `ResizeObserver` is armed. Below it, `narrowOrientation` becomes the effective axis instead of
  `orientation`. Accepts a bare pixel number (`900`, `orientation-breakpoint="900"`) or a CSS length
  string: `'900px'`, `'56.25rem'`, `'3em'`. Under the default `orientationBreakpointBasis='container'`,
  `rem` resolves against the document root's **computed** font size (the rule a `@container` query
  follows) and `em` against this element's own computed font size. The length is **re-resolved on
  every measurement**, never cached at first render, so a root font-size change moves the crossing
  width with no invalidation step on the consumer's side. Anything that isn't a resolvable length
  behaves exactly as unset (no observation, no `data-effective-orientation`): `''`, `'auto'`,
  garbage, a non-finite number, and deliberately `%`, `vw`/`vh` and `calc()` — a viewport-relative
  threshold would mix reference boxes against a measurement of the element's own allocation. Mirrors
  `<lr-multi-split>`'s identically-named contract, unit handling included.
- `orientationBreakpointBasis: 'container'|'viewport' = 'container'` (reflected, attribute
  `orientation-breakpoint-basis`) — which box `orientationBreakpoint` is compared against. Unset,
  behavior is identical to before this property existed. `'container'` measures the stepper's own
  `[part='base']` via `ResizeObserver`, comparing strictly `<`; `'viewport'` evaluates
  `matchMedia('(max-width: <breakpoint>)')`, arms no `ResizeObserver`, and compares inclusively
  (`<=`) per native `max-width` semantics. **A stepper given a fixed width in a row layout cannot
  react to that row stacking by measuring itself — its own width never changes — so that case
  requires `'viewport'`.** Relative units also differ by basis: inside a media query they resolve
  against the browser's _initial_ font size, ignoring `html { font-size }`, which is precisely why
  `'viewport'` matches a CSS `@media` rule authored with the same length. Mirrors `<lr-multi-split>`'s
  identically-named contract.
- `narrowOrientation: 'horizontal' | 'vertical' = 'vertical'` (reflected, attribute
  `narrow-orientation`)
- `wrapLabels: boolean = false` (reflected, attribute `wrap-labels`) — when true, allows long
  labels to wrap when the effective orientation is vertical. The default preserves single-line
  labels, and horizontal labels remain single-line even when this is enabled. Set this when a
  narrow or localized vertical stepper would otherwise clip labels or overflow its allocation.
- `readonly: boolean = false` (reflected) — renders the same `steps` data as a passive progress
  display instead of a navigable control. Each step becomes a non-interactive item inside its
  existing `role="listitem"` wrapper rather than a `<button>`: no `tabindex` (the stepper takes no
  tab stop at all), no `aria-disabled`, no click or Enter/Space activation, and therefore no
  `lr-step-select` — including from a synthetic click dispatched at `::part(step)`.
  Arrow/Home/End become a no-op and no longer call `preventDefault()`, so Space keeps scrolling
  the page the way it does anywhere else in static content. Everything that describes _progress_
  is untouched: the `step-index` chip, the `step-check` glyph, the optional `step-icon`, the
  per-step `title`, `aria-current="step"` on the current step, and every `--lr-stepper-*` custom
  property. It is deliberately **not** a disabled treatment — `disabled` means "you may not do
  this", read-only means "there is nothing to do here" — so a read-only step keeps normal opacity
  and only loses its pointer cursor, matching `<lr-slider>`'s and `<lr-rating>`'s own `readonly`.
  A per-step `disabled` flag is inert while read-only for the same reason: there is no activation
  left for it to gate, so it adds no dimming, and the step still shows its progress state. A
  focused step loses focus when `readonly` is turned on mid-session, because the control holding
  it stops existing; nothing is left stranded in the tab order. Unset (the default) is
  byte-for-byte the previous behavior.
- `effectiveOrientation: 'horizontal' | 'vertical'` (readonly getter) — the live layout/navigation
  axis actually in effect; identical to `orientation` whenever `orientationBreakpoint` is unset or
  doesn't resolve to a length. Also reflected as `data-effective-orientation` (only present while
  `orientationBreakpoint` resolves to a usable length).
- `accessibleLabel: string | null = null` (attribute `aria-label`) — accessible name applied to the
  `role="list"` step strip; attribute-reflects from a host-level `aria-label`. Unset, the list
  renders without an `aria-label` (there is no localized default name); an explicitly empty
  attribute remains empty rather than being treated as absent.

**Events:** `lr-step-select` (`detail: { stepId, index }`) — fired on click, or Enter/Space while
focused, on a non-`disabled` step. Never fired while `readonly`. It is non-cancelable because the
component takes no default action to veto: it never mutates `steps`. `lr-stepper-orientation-change`
(`detail: { orientation }`) — fired only when an enabled `orientationBreakpoint` actually changes
`effectiveOrientation`.

**Slots:** none.

**CSS parts:** `base` (root wrapper, `role="list"`), `step-item` (the `role="listitem"` wrapper for
one step), `step` (a single native button — or a non-interactive `<div>` carrying the same part
while `readonly`; the current step carries `aria-current="step"` and every
other step carries `aria-current="false"`),
`step-icon` (optional inert, `aria-hidden` leading topic glyph from the step's `icon` field; only
rendered when the step has one, additionally to — never instead of — `step-index`/`step-check`),
`step-index` (the numbered index chip, shown for `pending`/`current`/`error` steps), `step-check`
(the completed-checkmark glyph, shown for `completed` steps instead of `step-index`), `step-label`
(the step's label text).

**Themeable custom properties:** `--lr-stepper-hover-bg` (default
`var(--lr-color-brand-quiet)`) and `--lr-stepper-hover-color` (default
`var(--lr-color-text)`) style a hovered non-disabled step. `--lr-stepper-active-bg` (default
`color-mix(in oklab, var(--lr-color-brand-quiet), var(--lr-color-mix-partner)
var(--lr-color-mix-active))`) and `--lr-stepper-active-color` (default `var(--lr-color-text)`)
style a pressed non-disabled step. `--lr-stepper-current-color` (default `var(--lr-color-text)`) —
text color of the `current` step. `--lr-stepper-current-font-weight` (default
`var(--lr-font-weight-semibold)`) — font weight of the `current` step's label.
`--lr-stepper-error-color` (default `var(--lr-color-danger)`) —
text color of an `error` step. `--lr-stepper-current-index-bg` (default `var(--lr-color-brand)`) and
`--lr-stepper-current-index-color` (default `var(--lr-color-on-brand)`) — background and text color
of the `current` step's numbered `step-index` chip. The text color reads the dedicated on-brand
foreground token, not `--lr-color-surface`, so the chip stays legible in dark mode and under forced
colors, where surface and on-brand diverge. Each is an inline `var()` fallback at the point
of use, never declared on `:host`, so it can be set on the element or on any ancestor; and each is
scoped to its own `data-state`, so recoloring the current step leaves `pending`/`completed`/`error`
steps alone. The hooks exist because `::part(step)[data-state='current']` is invalid CSS — Shadow
Parts forbids an attribute selector after `::part()` — so state-specific theming previously meant
overriding a library-wide `--lr-color-*` token and repainting everything else that read it. Unset,
each falls back to the token its rule used before.
`--lr-scroll-fade-size` (default `2rem`) controls the decorative horizontal overflow fade, which is
disabled under forced-colors while the native scroll owner remains available. Otherwise shared tokens —
`--lr-space-m`/`-xs`/`-2xs`,
`--lr-color-text-quiet`/`-text`/`-danger`/`-brand`/`-on-brand`, `--lr-radius`/`-pill`,
`--lr-font-size-xs`, `--lr-font-weight-semibold`, `--lr-opacity-disabled`,
`--lr-focus-ring-*`.

**Optional peer deps:** none.

```html
<lr-stepper></lr-stepper>
<script type="module">
  const stepper = document.querySelector("lr-stepper");
  stepper.steps = [
    { stepId: "account", label: "Account", state: "completed" },
    { stepId: "billing", label: "Billing", state: "current" },
    { stepId: "review", label: "Review", state: "pending" },
  ];
  stepper.addEventListener("lr-step-select", (e) =>
    console.log(e.detail.stepId, e.detail.index)
  );
</script>
```

**Known gotchas:**

- `orientationBreakpointBasis='container'` (the default) observes **the stepper's own allocated
  inline size**, so it fits a stepper that is the sole flex/grid item in its measured container. It
  does **not** fit a stepper sitting beside a fixed-width sibling in a row that stacks via a CSS
  `@media` rule: while the row is a row, the stepper's width shrinks with the viewport; the instant
  the row stacks (a pure-CSS event no component can observe) it jumps to the _full_ row width —
  wider than it was just before the transition. Because the measured width is not monotonic across
  that transition, no single container threshold both stays wide while the row is a row and goes
  narrow exactly when it stacks — and a fixed-width sibling is worse still, since its own width
  never changes with the viewport at all, so no container breakpoint on it can ever react to the
  stacking. Use `orientationBreakpointBasis='viewport'` for that layout — give the stepper and its
  sibling the same `orientation-breakpoint` and `orientation-breakpoint-basis='viewport'` and they
  flip together, in lockstep with the CSS rule that stacks the row:
  ```html
  <lr-stepper
    orientation-breakpoint="56.25rem"
    orientation-breakpoint-basis="viewport"
  ></lr-stepper>
  <lr-multi-split
    orientation-breakpoint="56.25rem"
    orientation-breakpoint-basis="viewport"
  ></lr-multi-split>
  <style>
    @media (max-width: 56.25rem) {
      .shell {
        flex-direction: column;
      }
    }
  </style>
  ```
  A consumer-side `matchMedia()` controller driving the `orientation` attribute directly is still
  supported and still correct — it is simply no longer required for this case.
- there's no built-in "step forward/back" method — advancing the wizard is entirely the host's job:
  react to `lr-step-select` (or its own Next/Back buttons) and reassign `steps` with updated
  `state` values.
- The stepper exposes ordered progress/navigation semantics (`list`/`listitem` plus native step
  buttons), not tabs: it owns no tab panels. Roving tabindex and orientation-aware arrow-key
  navigation remain available independently of those semantics.
- Left/Right (horizontal) and Up/Down (vertical) are mutually exclusive per `orientation` — there's
  no single set of keys that works in both.

---
