Skip to content

Checkout wizard

A checkout that cannot be skipped ahead of. Three steps on a linear md-stepper — Shipping, Payment, Review. Every forward move is vetoed in mdBeforeChange, the current step’s fields are checked through the constraint-validation API (required on each field does the real work), and the transition is committed by assigning active only when everything passes. Failures land on the step header (error + error-text) and inline on the offending field. The order summary recomputes as quantities change, the Review step is rendered from the entered values, and placing the order puts the stepper’s Finish button into a loading state before an md-snackbar confirms.

Live preview — leave a field empty and press Continue to see the gate
Romania Germany France Netherlands Spain
Payment method
Order summary
Aria desk lamp €49.00 each
Oak monitor stand €89.00 each
Total €138.00
Review your order
Deliver to Payment Items Total
Press Place order to confirm. You can still go back and edit any step.
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-stepper label="Checkout progress" finish-label="Place order">
  <md-step label="Shipping" description="Address and destination" editable
    error-text="Complete the highlighted fields">
    <md-text-field label="Full name" name="name" required variant="outlined"
      error-text="Enter the recipient's name" reserve-supporting-space></md-text-field>
      <md-select label="Country" name="country" required variant="outlined"
        value-missing-label="Select a destination country.">
        <md-select-option value="ro">Romania</md-select-option>
        <md-select-option value="de">Germany</md-select-option>
      </md-select>
    </md-step>
    <md-step label="Payment" description="Method and order" editable
      error-text="Accept the terms of sale to continue">
      <label><md-radio name="pay-method" value="card" checked></md-radio> Credit or debit card</label>
      <label><md-radio name="pay-method" value="bank"></md-radio> Bank transfer</label>
      <md-card variant="outlined">
        <md-number-field label="Qty" value="1" min="1" max="10" step="1"
          variant="outlined" density="-2"></md-number-field>
        </md-card>
        <label>
          <md-checkbox name="terms" required
            error-text="You must accept the terms to place an order"></md-checkbox>
            I agree to the terms of sale
          </label>
        </md-step>
        <md-step label="Review">Summary rendered from the entered values.</md-step>
      </md-stepper>
      <md-snackbar message="Order OD-58412 placed — a confirmation email is on its way"></md-snackbar>

<script type="module">
  const wiz = document.querySelector('md-stepper');
  const steps = wiz.querySelectorAll('md-step');
  const shipFields = Array.prototype.slice.call(document.querySelectorAll('[data-ship]'));
  const country = document.querySelector('[data-ship-country]');
  const terms = document.querySelector('[data-terms]');
  const radios = Array.prototype.slice.call(document.querySelectorAll('md-radio[name="pay-method"]'));
  const qtyFields = Array.prototype.slice.call(document.querySelectorAll('[data-qty]'));
  const totalEl = document.querySelector('[data-total]');
  const snack = document.querySelector('md-snackbar');
  const PAY_LABELS = { card: 'Credit or debit card', bank: 'Bank transfer', cod: 'Cash on delivery' };

  const money = (n) => '\u20AC' + n.toFixed(2);
  const currentTotal = () => qtyFields.reduce(
    (sum, f) => sum + (f.value || 0) * Number(f.dataset.price), 0);

  // Live order total: md-number-field emits mdInput on every value move.
  qtyFields.forEach((f) => f.addEventListener('mdInput', () => {
    totalEl.textContent = money(currentTotal());
  }));

  // Constraint validation does the checking; we only surface the result.
  const validateAll = (fields) =>
    Promise.all(fields.map((f) =>
      f.checkValidity().then((ok) => { f.error = !ok; return ok; })
    )).then((results) => results.every(Boolean));

  // Recover inline: once a flagged field becomes valid, clear its error.
  shipFields.concat([terms]).forEach((f) => {
    const settle = () => f.checkValidity().then((ok) => { if (ok) f.error = false; });
    f.addEventListener('mdInput', settle);
    f.addEventListener('mdChange', settle);
  });

  function fillReview() {
    const val = (sel) => document.querySelector(sel).value;
    const opt = country.querySelector('md-select-option[value="' + country.value + '"]');
    document.querySelector('[data-review-address]').textContent =
      val('[data-ship-name]') + ', ' + val('[data-ship-street]') + ', ' +
      val('[data-ship-zip]') + ' ' + val('[data-ship-city]') + ', ' +
      (opt ? opt.textContent.trim() : '');
    const picked = radios.filter((r) => r.checked)[0];
    document.querySelector('[data-review-payment]').textContent =
      picked ? PAY_LABELS[picked.value] : '';
    document.querySelector('[data-review-items]').textContent = qtyFields
      .map((f) => (f.value || 0) + ' \u00D7 ' + f.dataset.item)
      .join(', ');
    document.querySelector('[data-review-total]').textContent = money(currentTotal());
  }

  // Gate every forward move: veto, validate async, commit by assigning
  // 'active' (never next()/goTo() here - they would re-enter this handler).
  wiz.addEventListener('mdBeforeChange', (e) => {
    const to = e.detail.index;
    const from = e.detail.previous;
    if (to <= from) return; // backward is always allowed
    e.preventDefault();
    const gate =
      from === 0 ? validateAll(shipFields) :
      from === 1 ? validateAll([terms]) :
      Promise.resolve(true);
    gate.then((ok) => {
      steps[from].error = !ok;
      if (!ok) return;
      if (to === 2) fillReview();
      wiz.active = to;
    });
  });

  // Place order: loading disables Continue AND Back (no double submit),
  // then the snackbar confirms.
  wiz.addEventListener('mdComplete', () => {
    wiz.loading = true;
    setTimeout(() => {
      wiz.loading = false;
      snack.show();
    }, 900);
  });
</script>
ComponentRole in this screen
md-stepperOwns the flow: mode="linear" (the default) keeps future step headers unreachable, mdBeforeChange is the validation gate, finish-label="Place order" renames the last Continue, and loading locks the nav during the fake submit.
md-stepOne panel per step, rendered full-width under the horizontal row. editable lets a completed step be revisited without wiping downstream progress; error + error-text announce a failed gate right on the header.
md-text-fieldAddress fields. required feeds the constraint-validation API; error-text swaps in for the supporting line and reserve-supporting-space keeps the panel from jumping when it does.
md-selectCountry picker. Form-associated with a real required state and a localizable value-missing-label.
md-radioPayment method. Radios sharing name="pay-method" form one exclusive group with a roving tabstop, and one option is pre-checked as M3 expects.
md-number-fieldQuantities in the order summary — typed or stepped, clamped to min/max on every path, emitting mdInput so the total recomputes live.
md-cardThe outlined order-summary container inside the Payment step.
md-checkboxTerms of sale. required plus its own error-text line, so a failed check is explained next to the box, not in a transient balloon.
md-snackbarThe low-priority “order placed” confirmation after Finish.
  • The gate follows the documented veto-then-commit pattern. The mdBeforeChange handler calls preventDefault(), awaits checkValidity() on every field in the step being left, and commits with wiz.active = index — assigning active directly is the sanctioned way to finish an async validation, because calling next() or goTo() from the handler would re-emit the event and re-enter it.
  • Errors live where the fix is. A failed gate sets error on the step header (announced assertively by the stepper’s live region) and flips each invalid field’s own error state; the field clears itself the moment it becomes valid again.
  • Back never loses work. Shipping and Payment are marked editable, so revisiting a completed step keeps later steps completed — without it, the stepper’s auto-complete would un-complete everything downstream on a backward move.
  • No double submit. mdComplete sets loading on the stepper, which puts Finish into a spinner state and disables Back until the (simulated) server call resolves.
  • Quantities are bounded on every input path. md-number-field clamps arrow keys, stepper buttons and wheel to min="1"/max="10", and a typed out-of-range value is clamped at commit — the total can never go negative.
  • Vertical layout for mobile: set orientation="vertical" on the stepper — each step then expands in place with its own Back / Continue actions, and variant="mobile" gives an even more compact Back · progress · Continue bar (with the active panel in the stepper’s content slot).
  • Declarative gating instead of the veto: bind the stepper’s next-disabled to the current step’s validity from each field’s mdValidityChange — Continue stays disabled until the step is complete, and no mdBeforeChange handler is needed.
  • Real server submit: keep loading on while you await fetch(...) in the mdComplete handler, and use setCustomValidity() on a field when the server rejects a value (an unavailable postal code, a declined card).
  • Payment confirmation step: insert an md-otp-field step between Payment and Review for 3-D Secure style verification — see the two-factor recipe.