# Stepper (Moonarc)

A form, or any flow, in steps: a rail of the steps' names over the step in hand, with Back and Next under it. The steps are the slot's own children, a fieldset with its legend each, so every field stays in one form and sends with it. Without JavaScript nothing is hidden: the steps stand one under the other and the submit button follows the last, as a long form. With it, one step shows at a time; Next checks the step's fields with the browser's own validation before it moves on, Enter in a field does the same, a submit that finds an invalid field in a step behind brings that step back, and whatever follows the stepper in its form (the submit button) shows on the last step only. A step already done is a button on the rail.

- Import: `import Stepper from '@moonarc/core/Stepper'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/stepper.json`
- Tier C · category ui · trigger click
- Readout: `<Stepper steps={['Your visit', 'A time', 'You']}><fieldset>…</fieldset>…</Stepper>`
- Browser support: widely (Chrome 105 · Firefox 121 · Safari 15.4); elsewhere: without :has() the Back button also shows on the first step, where it does nothing
- Measured cost: 2.0 kB raw JS · 979 B gzip · + runtime (with dependencies 3.7 kB raw; CSS 7.9 kB raw)
- Page: https://moonarc.dev/components/stepper/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `steps` | `string[]` | none | The steps' names in order, one a child of the slot: two to six. |
| `words` | `{ back?, next?, step? }` | `{ back: 'Back', next: 'Next', step: 'Step {n} of {total}: {name}' }` | The buttons' words and the line a screen reader hears on a new step. |
| `label` | `string` | `'Steps'` | The rail's accessible name. |

## Usage

```astro
<form method="post" action="/book/">
  <Stepper steps={['Your visit', 'A time', 'You']}>
    <fieldset><legend>What is it for?</legend><!-- fields --></fieldset>
    <fieldset><legend>When suits you?</legend><!-- fields --></fieldset>
    <fieldset><legend>How do we reach you?</legend><!-- fields --></fieldset>
  </Stepper>
  <button type="submit">Request the appointment</button>
</form>
```

## Reduced motion

A new step appears without its rise, and the rail fills without a transition.

## With ClientRouter

Bound per element through the runtime; a fresh page binds again and starts at the first step.

## Craft

- Next calls checkValidity() on the step's own fields and reportValidity() on the first that fails: the browser's message, in the browser's language, at the field.
- Enter in a field of an early step is caught on keydown, before the browser's implicit submission would check every field of the form and point at one the reader has not reached; a submit in the capture phase covers a script's requestSubmit.
- An invalid event from a hidden step shows that step before the browser looks for the field to point at.
- What follows the stepper in its form is hidden by one sibling rule until the last step, so the submit button stays the form's own.
- A reset of the form (a sent form coming back from the cache) goes back to the first step.

## Replaces

- Stepper (React Bits)
- Multi Step Form (Cult UI)
- Stepper blocks (shadcnblocks)

## Source

```astro
---
/**
 * Stepper — a form, or any flow, in steps: a rail of the steps' names over the step in hand, and Back and Next under
 * it. The steps are the default slot's own children (a <fieldset> with its <legend> each, in a form), so every field
 * stays in one form and sends with it. Without JavaScript nothing is hidden: the steps stand one under the other and the
 * submit button follows the last, as a long form. With it, one step shows at a time; Next checks the step's fields with
 * the browser's own validation before it moves on, Enter in a field does the same, and a submit that finds an invalid
 * field in a step behind brings that step back. Whatever follows the stepper in its form (the submit button) shows on
 * the last step only.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** The steps' names, in order: the rail's words. One a child of the slot, two to six. */
  steps: string[];
  /** The buttons' words, and the line a screen reader hears on a new step ({n}, {total} and {name} are filled in). */
  words?: { back?: string; next?: string; step?: string };
  /** The rail's accessible name. */
  label?: string;
}

const { steps, words = {}, label = 'Steps', class: className, ...rest } = Astro.props;
if (steps.length < 2 || steps.length > 6) throw new Error(`Stepper: steps names ${steps.length}; it takes two to six.`);
const w = { back: 'Back', next: 'Next', step: 'Step {n} of {total}: {name}', ...words };
---

<div class:list={['ma-stepper', className]} data-ma-stepper data-say={w.step} style={`--ma-step-count:${steps.length}`} {...rest}>
  <ol class="ma-stepper__rail" aria-label={label}>
    {steps.map((s, i) => (
      <li class="ma-stepper__dot">
        <button class="ma-stepper__to" type="button" data-ma-step-to={i} disabled><span class="ma-stepper__n" aria-hidden="true">{i + 1}</span><span class="ma-stepper__name">{s}</span></button>
      </li>
    ))}
  </ol>
  <div class="ma-stepper__steps"><slot /></div>
  <div class="ma-stepper__nav">
    <button class="ma-stepper__back" type="button" data-ma-step="back">{w.back}</button>
    <button class="ma-stepper__next" type="button" data-ma-step="next">{w.next}</button>
  </div>
  <p class="ma-stepper__say" aria-live="polite"></p>
</div>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-stepper]', (el, { signal }) => {
    const steps = [...el.querySelector('.ma-stepper__steps')!.children] as HTMLElement[];
    const to = [...el.querySelectorAll<HTMLButtonElement>('[data-ma-step-to]')];
    const say = el.querySelector<HTMLElement>('.ma-stepper__say')!;
    const names = to.map((b) => b.querySelector('.ma-stepper__name')!.textContent ?? '');
    const last = steps.length - 1;
    const form = el.closest('form');
    let at = 0;
    const fields = (s: HTMLElement) => [...s.querySelectorAll<HTMLInputElement>('input, select, textarea')];
    // the first field the browser finds invalid is reported and focused; the step stays
    const valid = (s: HTMLElement) => {
      for (const f of fields(s)) if (!f.checkValidity()) { f.reportValidity(); return false; }
      return true;
    };
    // 'focus' moves the focus into the step and says which it is; 'say' only says it (the browser is about to focus a
    // field itself); 'quiet' does neither (the first paint, a reset)
    const go = (i: number, mode: 'focus' | 'say' | 'quiet') => {
      at = Math.max(0, Math.min(last, i));
      steps.forEach((s, k) => {
        s.toggleAttribute('data-current', k === at);
        s.hidden = k !== at;
      });
      to.forEach((b, k) => {
        b.disabled = k >= at;
        b.parentElement!.toggleAttribute('data-done', k < at);
        if (k === at) b.parentElement!.setAttribute('aria-current', 'step');
        else b.parentElement!.removeAttribute('aria-current');
      });
      el.toggleAttribute('data-last', at === last);
      el.style.setProperty('--ma-step-at', String(at));
      if (mode === 'quiet') return;
      say.textContent = (el.dataset.say ?? '').replace('{n}', String(at + 1)).replace('{total}', String(steps.length)).replace('{name}', names[at] ?? '');
      if (mode === 'say') return;
      const first = fields(steps[at]!).find((f) => f.type !== 'hidden' && !f.disabled) ?? steps[at]!;
      if (first === steps[at]) steps[at]!.tabIndex = -1;
      first.focus({ preventScroll: true });
      if (el.getBoundingClientRect().top < 0) el.scrollIntoView({ block: 'start' });
    };
    const next = () => valid(steps[at]!) && go(at + 1, 'focus');
    el.addEventListener('click', (e) => {
      const b = (e.target as Element).closest<HTMLButtonElement>('button');
      if (!b || !el.contains(b)) return;
      if (b.dataset.maStep === 'next') next();
      else if (b.dataset.maStep === 'back') go(at - 1, 'focus');
      else if (b.dataset.maStepTo) go(Number(b.dataset.maStepTo), 'focus');
    }, { signal });
    // Enter in a field of an earlier step moves on: caught before the browser's implicit submission, which would check
    // every field of the form and point at one in a step the reader has not reached
    el.addEventListener('keydown', (e) => {
      const t = e.target as HTMLInputElement;
      if (e.key !== 'Enter' || at === last || t.tagName !== 'INPUT' || ['button', 'submit', 'reset'].includes(t.type)) return;
      e.preventDefault();
      next();
    }, { signal });
    if (form) {
      // before the form's own listeners: a submit from an earlier step (a script's requestSubmit) moves on instead
      form.addEventListener('submit', (e) => {
        if (at === last) return;
        e.preventDefault();
        e.stopImmediatePropagation();
        next();
      }, { capture: true, signal });
      // a submit that finds an invalid field in a step behind shows that step, so the browser can point at the field
      let jumped = false;
      form.addEventListener('invalid', (e) => {
        const i = steps.findIndex((s) => s.contains(e.target as Node));
        if (jumped || i < 0 || i === at) return;
        jumped = true;
        go(i, 'say');
        setTimeout(() => (jumped = false));
      }, { capture: true, signal });
      form.addEventListener('reset', () => go(0, 'quiet'), { signal });
    }
    el.toggleAttribute('data-on', true);
    go(0, 'quiet');
  });
</script>

<style is:global>
  @layer components {
    :where(.ma-stepper) { --ma-step-at: 0; display: grid; gap: 1.5rem; }
    :where(.ma-stepper__say) { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
    :where(.ma-stepper__steps) { display: grid; gap: 2rem; }
    :where(.ma-stepper__steps > fieldset) { min-inline-size: 0; margin: 0; padding: 0; border: 0; }
    :where(.ma-stepper__steps > fieldset > legend) { padding: 0; margin-block-end: 1rem; font-weight: 650; }
    /* !important: a step's own display (a fieldset set to grid by the form around it) must not show a hidden step */
    :where(.ma-stepper[data-on] .ma-stepper__steps > [hidden]) { display: none !important; }
    :where(.ma-stepper[data-on] .ma-stepper__steps > [data-current]) { animation: ma-stepper-in var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out); }
    :where(.ma-stepper[data-on] .ma-stepper__steps > [data-current]:focus) { outline: none; }
    /* the submit button and whatever else follows the stepper in its form: the last step's (important for the same
       reason: a button sets its own display) */
    :where(.ma-stepper[data-on]:not([data-last]) ~ *) { display: none !important; }

    /* the rail: a numbered dot a step on a line that fills to the step in hand */
    :where(.ma-stepper__rail) { position: relative; display: grid; grid-template-columns: repeat(var(--ma-step-count), minmax(0, 1fr)); margin: 0; padding: 0; list-style: none; }
    :where(.ma-stepper__rail)::before,
    :where(.ma-stepper__rail)::after { content: ''; position: absolute; inset-block-start: 0.875rem; inset-inline: calc(50% / var(--ma-step-count)); block-size: 2px; background: var(--border, color-mix(in oklab, currentColor 18%, transparent)); }
    :where(.ma-stepper__rail)::after { background: var(--primary, currentColor); transform-origin: left; scale: calc(var(--ma-step-at) / (var(--ma-step-count) - 1)) 1; transition: scale var(--ma-duration-slow, 400ms) var(--ma-ease-out, ease-out); }
    :where(.ma-stepper__rail:dir(rtl))::after { transform-origin: right; }
    :where(.ma-stepper__dot) { position: relative; z-index: 1; display: grid; justify-items: center; }
    :where(.ma-stepper__to) { display: grid; justify-items: center; gap: 0.4rem; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: center; cursor: pointer; }
    :where(.ma-stepper__to:disabled) { cursor: default; }
    :where(.ma-stepper__to:focus-visible .ma-stepper__n) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
    :where(.ma-stepper__n) { display: grid; place-items: center; inline-size: 1.75rem; block-size: 1.75rem; border-radius: 999px; background: var(--background, Canvas); box-shadow: inset 0 0 0 2px var(--border, color-mix(in oklab, currentColor 30%, transparent)); font-size: 0.8125rem; font-weight: 650; font-variant-numeric: tabular-nums; transition: background-color var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out), color var(--ma-duration-base, 250ms) var(--ma-ease-out, ease-out); }
    :where(.ma-stepper__dot:is([aria-current], [data-done]) .ma-stepper__n) { background: var(--primary, CanvasText); color: var(--primary-foreground, Canvas); box-shadow: none; }
    :where(.ma-stepper__name) { font-size: 0.8125rem; line-height: 1.25; }
    :where(.ma-stepper__dot[aria-current] .ma-stepper__name) { font-weight: 650; }
    :where(.ma-stepper__dot[data-done] .ma-stepper__name) { text-decoration: underline; text-decoration-color: color-mix(in oklab, currentColor 35%, transparent); text-underline-offset: 0.2em; }

    :where(.ma-stepper__nav) { display: flex; flex-wrap: wrap; gap: 0.75rem; }
    :where(.ma-stepper__nav button) { display: inline-flex; align-items: center; min-block-size: 2.75rem; padding: 0 1.25rem; border: 0; border-radius: var(--ma-radius-md, 0.6rem); font: inherit; font-weight: 600; cursor: pointer; }
    :where(.ma-stepper__back) { background: none; color: inherit; box-shadow: inset 0 0 0 1px var(--border, color-mix(in oklab, currentColor 30%, transparent)); }
    :where(.ma-stepper__next) { background: var(--primary, CanvasText); color: var(--primary-foreground, Canvas); }
    :where(.ma-stepper__nav button:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
    :where(.ma-stepper[data-on]:has(.ma-stepper__dot:first-child[aria-current]) .ma-stepper__back) { display: none; }
    :where(.ma-stepper[data-last] .ma-stepper__next) { display: none; }

    /* without JavaScript the rail and the buttons are not there: the steps read as one long form. Last, so it wins over
       the rail's and the buttons' own display at the same (zero) specificity */
    :where(.ma-stepper:not([data-on]) :is(.ma-stepper__rail, .ma-stepper__nav)) { display: none; }

    @keyframes ma-stepper-in { from { opacity: 0; translate: 0 0.5rem; } }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-stepper[data-on] .ma-stepper__steps > [data-current]) { animation: none; }
      :where(.ma-stepper__rail)::after { transition: none; }
      :where(.ma-stepper__n) { transition: none; }
    }
  }
</style>

```
