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
Payment method
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>import { useMemo, useState } from 'react';
import {
MdCard, MdCheckbox, MdNumberField, MdRadio, MdSelect, MdSelectOption,
MdSnackbar, MdStep, MdStepper, MdTextField,
} from '@awc-ui/react';
const COUNTRIES = [
{ code: 'ro', name: 'Romania' }, { code: 'de', name: 'Germany' }, { code: 'fr', name: 'France' },
];
const PAY_METHODS = [
{ value: 'card', label: 'Credit or debit card' },
{ value: 'bank', label: 'Bank transfer' },
{ value: 'cod', label: 'Cash on delivery' },
];
const SHIP_FIELDS = [
{ key: 'name', label: 'Full name', errorText: "Enter the recipient's name" },
{ key: 'street', label: 'Street address', errorText: 'Enter a street and house number' },
{ key: 'city', label: 'City', errorText: 'Enter a city' },
{ key: 'postal', label: 'Postal code', errorText: 'Enter a postal code' },
];
export default function CheckoutWizard() {
const [active, setActive] = useState(0);
const [ship, setShip] = useState({ name: '', street: '', city: '', postal: '', country: '' });
const [shipErrors, setShipErrors] = useState({});
const [payMethod, setPayMethod] = useState('card');
const [items, setItems] = useState([
{ name: 'Aria desk lamp', price: 49, qty: 1 },
{ name: 'Oak monitor stand', price: 89, qty: 1 },
]);
const [terms, setTerms] = useState(false);
const [termsError, setTermsError] = useState(false);
const [stepErrors, setStepErrors] = useState([false, false]);
const [loading, setLoading] = useState(false);
const [placed, setPlaced] = useState(false);
const total = useMemo(() => items.reduce((sum, i) => sum + (i.qty || 0) * i.price, 0), [items]);
const money = (n) => '€' + n.toFixed(2);
const setShipField = (key, value) => {
setShip((s) => ({ ...s, [key]: value }));
if (value.trim()) setShipErrors((e) => ({ ...e, [key]: false })); // recover inline
};
const validateStep = (index) => {
if (index === 0) {
const errors = {};
SHIP_FIELDS.forEach((f) => { errors[f.key] = !ship[f.key].trim(); });
errors.country = !ship.country;
setShipErrors(errors);
return !Object.values(errors).some(Boolean);
}
if (index === 1) {
setTermsError(!terms);
return terms;
}
return true;
};
// Gate every forward move: veto, validate, then commit by setting active.
const onBeforeChange = (e) => {
e.preventDefault();
const { index, previous } = e.detail;
if (index <= previous) return setActive(index); // backward is always allowed
const ok = validateStep(previous);
setStepErrors((errs) => errs.map((v, i) => (i === previous ? !ok : v)));
if (ok) setActive(index);
};
const placeOrder = () => {
setLoading(true); // disables Continue AND Back — no double submit
setTimeout(() => { setLoading(false); setPlaced(true); }, 900);
};
return (
<div style={{ maxWidth: 680, marginInline: 'auto' }}>
<MdStepper label="Checkout progress" finishLabel="Place order" active={active}
loading={loading} onMdBeforeChange={onBeforeChange} onMdComplete={placeOrder}>
<MdStep label="Shipping" description="Address and destination" editable
error={stepErrors[0]} errorText="Complete the highlighted fields">
<div style={{ display: 'grid', gap: 16, paddingBlock: 12 }}>
{SHIP_FIELDS.map((f) => (
<MdTextField key={f.key} label={f.label} required variant="outlined"
errorText={f.errorText} reserveSupportingSpace
value={ship[f.key]} error={!!shipErrors[f.key]}
onMdInput={(e) => setShipField(f.key, e.detail)} />
))}
<MdSelect label="Country" required variant="outlined" reserveSupportingSpace
errorText="Select a destination country" value={ship.country}
error={!!shipErrors.country} onMdChange={(e) => setShipField('country', e.detail)}>
{COUNTRIES.map((c) => (
<MdSelectOption key={c.code} value={c.code}>{c.name}</MdSelectOption>
))}
</MdSelect>
</div>
</MdStep>
<MdStep label="Payment" description="Method and order" editable
error={stepErrors[1]} errorText="Accept the terms of sale to continue">
<div style={{ display: 'grid', gap: 20, paddingBlock: 12 }}>
<div role="radiogroup" aria-label="Payment method" style={{ display: 'grid', gap: 4 }}>
{PAY_METHODS.map((m) => (
<label key={m.value} style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<MdRadio name="pay-method" value={m.value} checked={payMethod === m.value}
onMdChange={(e) => setPayMethod(e.detail.value)} /> {m.label}
</label>
))}
</div>
<MdCard variant="outlined" style={{ padding: 16, gap: 12 }}>
{items.map((item, i) => (
<div key={item.name} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ flex: 1 }}>{item.name} — {money(item.price)} each</span>
<MdNumberField label="Qty" value={item.qty} min={1} max={10} step={1}
variant="outlined" density={-2} style={{ width: 140 }}
onMdInput={(e) => setItems((list) => list.map((it, j) =>
(j === i ? { ...it, qty: e.detail.value || 0 } : it)))} />
</div>
))}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>Total</strong><strong>{money(total)}</strong>
</div>
</MdCard>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<MdCheckbox required checked={terms} error={termsError}
errorText="You must accept the terms to place an order"
onMdChange={(e) => { setTerms(e.detail.checked); if (e.detail.checked) setTermsError(false); }} />
<span>I agree to the terms of sale and the 30-day return policy</span>
</label>
</div>
</MdStep>
<MdStep label="Review">
<div style={{ display: 'grid', gap: 8, paddingBlock: 12 }}>
<strong>Review your order</strong>
<span>Deliver to: {ship.name}, {ship.street}, {ship.postal} {ship.city},
{' '}{(COUNTRIES.find((c) => c.code === ship.country) || {}).name}</span>
<span>Payment: {(PAY_METHODS.find((m) => m.value === payMethod) || {}).label}</span>
<span>Items: {items.map((i) => (i.qty || 0) + ' × ' + i.name).join(', ')}</span>
<strong>Total: {money(total)}</strong>
</div>
</MdStep>
</MdStepper>
<MdSnackbar message="Order OD-58412 placed — a confirmation email is on its way"
open={placed} onMdClose={() => setPlaced(false)} />
</div>
);
}import { Component, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
BooleanValueAccessor, MdCard, MdCheckbox, MdNumberField, MdRadio, MdSelect,
MdSelectOption, MdSnackbar, MdStep, MdStepper, MdTextField,
SelectValueAccessor, TextValueAccessor,
} from '@awc-ui/angular';
@Component({
selector: 'app-checkout-wizard',
standalone: true,
imports: [
FormsModule, MdCard, MdCheckbox, MdNumberField, MdRadio, MdSelect, MdSelectOption,
MdSnackbar, MdStep, MdStepper, MdTextField,
TextValueAccessor, SelectValueAccessor, BooleanValueAccessor,
],
// Inline and concatenated to stay single-file here — a real app would use templateUrl.
template:
'<div style="max-width: 680px; margin-inline: auto;">' +
'<md-stepper label="Checkout progress" finish-label="Place order" [active]="active"' +
' [loading]="loading" (mdBeforeChange)="gate($event)" (mdComplete)="placeOrder()">' +
'<md-step label="Shipping" description="Address and destination" [editable]="true"' +
' [error]="stepErrors[0]" error-text="Complete the highlighted fields">' +
'<div style="display: grid; gap: 16px; padding: 12px 20px 4px;">' +
'@for (f of shipFields; track f.key) {' +
'<md-text-field variant="outlined" [required]="true" [reserveSupportingSpace]="true"' +
' [label]="f.label" [errorText]="f.errorText" [name]="f.key"' +
' [(ngModel)]="ship[f.key]" [error]="shipErrors[f.key]"' +
' (mdInput)="settleShip(f.key, $event.detail)"></md-text-field>' +
'}' +
'<md-select label="Country" variant="outlined" [required]="true"' +
' [reserveSupportingSpace]="true" error-text="Select a destination country"' +
' [(ngModel)]="ship.country" [error]="shipErrors.country"' +
' (mdChange)="shipErrors.country = false">' +
'@for (c of countries; track c.code) {' +
'<md-select-option [value]="c.code">{{ c.name }}</md-select-option>' +
'}' +
'</md-select>' +
'</div>' +
'</md-step>' +
'<md-step label="Payment" description="Method and order" [editable]="true"' +
' [error]="stepErrors[1]" error-text="Accept the terms of sale to continue">' +
'<div style="display: grid; gap: 20px; padding: 12px 20px 4px;">' +
'<div role="radiogroup" aria-label="Payment method" style="display: grid; gap: 4px;">' +
'@for (m of payMethods; track m.value) {' +
'<label style="display: flex; align-items: center; gap: 10px;">' +
'<md-radio name="pay-method" [value]="m.value" [checked]="payMethod === m.value"' +
' (mdChange)="payMethod = m.value"></md-radio> {{ m.label }}</label>' +
'}' +
'</div>' +
'<md-card variant="outlined" style="padding: 16px; gap: 12px;">' +
'@for (item of items; track item.name) {' +
'<div style="display: flex; align-items: center; gap: 12px;">' +
'<span style="flex: 1;">{{ item.name }} — {{ money(item.price) }} each</span>' +
'<md-number-field label="Qty" variant="outlined" [density]="-2" style="width: 140px;"' +
' [min]="1" [max]="10" [step]="1" [value]="item.qty"' +
' (mdInput)="item.qty = $event.detail.value || 0"></md-number-field>' +
'</div>' +
'}' +
'<div style="display: flex; justify-content: space-between;">' +
'<strong>Total</strong><strong>{{ money(total) }}</strong>' +
'</div>' +
'</md-card>' +
'<label style="display: flex; align-items: flex-start; gap: 10px;">' +
'<md-checkbox [required]="true" [(ngModel)]="terms" [error]="termsError"' +
' error-text="You must accept the terms to place an order"' +
' (mdChange)="settleTerms($event.detail.checked)"></md-checkbox>' +
'<span>I agree to the terms of sale and the 30-day return policy</span>' +
'</label>' +
'</div>' +
'</md-step>' +
'<md-step label="Review">' +
'<div style="display: grid; gap: 8px; padding: 12px 20px 4px;">' +
'<strong>Review your order</strong>' +
'<span>Deliver to: {{ ship.name }}, {{ ship.street }}, {{ ship.postal }}' +
' {{ ship.city }}, {{ countryName(ship.country) }}</span>' +
'<span>Payment: {{ payLabel(payMethod) }}</span>' +
'<span>Items: {{ itemsSummary() }}</span>' +
'<strong>Total: {{ money(total) }}</strong>' +
'</div>' +
'</md-step>' +
'</md-stepper>' +
'<md-snackbar #snack' +
' message="Order OD-58412 placed — a confirmation email is on its way"></md-snackbar>' +
'</div>',
})
export class CheckoutWizardComponent {
@ViewChild('snack') snack!: MdSnackbar;
shipFields = [
{ key: 'name', label: 'Full name', errorText: "Enter the recipient's name" },
{ key: 'street', label: 'Street address', errorText: 'Enter a street and house number' },
{ key: 'city', label: 'City', errorText: 'Enter a city' },
{ key: 'postal', label: 'Postal code', errorText: 'Enter a postal code' },
];
countries = [
{ code: 'ro', name: 'Romania' }, { code: 'de', name: 'Germany' }, { code: 'fr', name: 'France' },
];
payMethods = [
{ value: 'card', label: 'Credit or debit card' },
{ value: 'bank', label: 'Bank transfer' },
{ value: 'cod', label: 'Cash on delivery' },
];
active = 0;
loading = false;
ship: Record<string, string> = { name: '', street: '', city: '', postal: '', country: '' };
shipErrors: Record<string, boolean> = {};
payMethod = 'card';
items = [
{ name: 'Aria desk lamp', price: 49, qty: 1 },
{ name: 'Oak monitor stand', price: 89, qty: 1 },
];
terms = false;
termsError = false;
stepErrors = [false, false];
get total() { return this.items.reduce((sum, i) => sum + (i.qty || 0) * i.price, 0); }
money(n: number) { return '€' + n.toFixed(2); }
countryName(code: string) { return (this.countries.find((c) => c.code === code) || { name: '' }).name; }
payLabel(value: string) { return (this.payMethods.find((m) => m.value === value) || { label: '' }).label; }
itemsSummary() { return this.items.map((i) => (i.qty || 0) + ' × ' + i.name).join(', '); }
settleShip(key: string, value: string) { if (value.trim()) this.shipErrors[key] = false; } // recover inline
settleTerms(checked: boolean) { if (checked) this.termsError = false; }
// Gate every forward move: veto, validate, then commit by setting active.
gate(e: CustomEvent<{ index: number; previous: number }>) {
e.preventDefault();
const { index, previous } = e.detail;
if (index <= previous) { this.active = index; return; } // backward is always allowed
const ok = previous === 0 ? this.validateShipping() : previous === 1 ? this.validateTerms() : true;
this.stepErrors[previous] = !ok;
if (ok) this.active = index;
}
validateShipping() {
for (const f of this.shipFields) this.shipErrors[f.key] = !this.ship[f.key].trim();
this.shipErrors['country'] = !this.ship['country'];
return !Object.values(this.shipErrors).some(Boolean);
}
validateTerms() { this.termsError = !this.terms; return this.terms; }
placeOrder() {
this.loading = true; // disables Continue AND Back — no double submit
setTimeout(() => { this.loading = false; this.snack.show(); }, 900);
}
}<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import {
MdCard, MdCheckbox, MdNumberField, MdRadio, MdSelect, MdSelectOption,
MdSnackbar, MdStep, MdStepper, MdTextField,
} from '@awc-ui/vue';
const shipFields = [
{ key: 'name', label: 'Full name', errorText: "Enter the recipient's name" },
{ key: 'street', label: 'Street address', errorText: 'Enter a street and house number' },
{ key: 'city', label: 'City', errorText: 'Enter a city' },
{ key: 'postal', label: 'Postal code', errorText: 'Enter a postal code' },
];
const countries = [
{ code: 'ro', name: 'Romania' }, { code: 'de', name: 'Germany' }, { code: 'fr', name: 'France' },
];
const payMethods = [
{ value: 'card', label: 'Credit or debit card' },
{ value: 'bank', label: 'Bank transfer' },
{ value: 'cod', label: 'Cash on delivery' },
];
const active = ref(0);
const loading = ref(false);
const placed = ref(false);
const ship = reactive<Record<string, string>>({ name: '', street: '', city: '', postal: '', country: '' });
const shipErrors = reactive<Record<string, boolean>>({});
const payMethod = ref('card');
const items = reactive([
{ name: 'Aria desk lamp', price: 49, qty: 1 },
{ name: 'Oak monitor stand', price: 89, qty: 1 },
]);
const terms = ref(false);
const termsError = ref(false);
const stepErrors = reactive([false, false]);
const total = computed(() => items.reduce((sum, i) => sum + (i.qty || 0) * i.price, 0));
const money = (n: number) => '€' + n.toFixed(2);
const countryName = computed(() => (countries.find((c) => c.code === ship.country) || { name: '' }).name);
const payLabel = computed(() => (payMethods.find((m) => m.value === payMethod.value) || { label: '' }).label);
const itemsSummary = computed(() => items.map((i) => (i.qty || 0) + ' × ' + i.name).join(', '));
function setShipField(key: string, value: string) {
ship[key] = value;
if (value.trim()) shipErrors[key] = false; // recover inline
}
function validateStep(index: number) {
if (index === 0) {
for (const f of shipFields) shipErrors[f.key] = !ship[f.key].trim();
shipErrors.country = !ship.country;
return !Object.values(shipErrors).some(Boolean);
}
if (index === 1) {
termsError.value = !terms.value;
return terms.value;
}
return true;
}
// Gate every forward move: veto, validate, then commit by setting active.
function gate(e: CustomEvent<{ index: number; previous: number }>) {
e.preventDefault();
const { index, previous } = e.detail;
if (index <= previous) { active.value = index; return; } // backward is always allowed
const ok = validateStep(previous);
stepErrors[previous] = !ok;
if (ok) active.value = index;
}
function onTerms(checked: boolean) {
terms.value = checked;
if (checked) termsError.value = false;
}
function placeOrder() {
loading.value = true; // disables Continue AND Back — no double submit
setTimeout(() => { loading.value = false; placed.value = true; }, 900);
}
</script>
<template>
<div style="max-width: 680px; margin-inline: auto;">
<MdStepper label="Checkout progress" finish-label="Place order" :active="active"
:loading="loading" @md-before-change="gate" @md-complete="placeOrder">
<MdStep label="Shipping" description="Address and destination" :editable="true"
:error="stepErrors[0]" error-text="Complete the highlighted fields">
<div style="display: grid; gap: 16px; padding: 12px 20px 4px;">
<MdTextField v-for="f in shipFields" :key="f.key" variant="outlined" :required="true"
:reserve-supporting-space="true" :label="f.label" :error-text="f.errorText"
:value="ship[f.key]" :error="shipErrors[f.key]"
@md-input="setShipField(f.key, $event.detail)" />
<MdSelect label="Country" variant="outlined" :required="true" :reserve-supporting-space="true"
error-text="Select a destination country" :value="ship.country" :error="shipErrors.country"
@md-change="setShipField('country', $event.detail)">
<MdSelectOption v-for="c in countries" :key="c.code" :value="c.code">{{ c.name }}</MdSelectOption>
</MdSelect>
</div>
</MdStep>
<MdStep label="Payment" description="Method and order" :editable="true"
:error="stepErrors[1]" error-text="Accept the terms of sale to continue">
<div style="display: grid; gap: 20px; padding: 12px 20px 4px;">
<div role="radiogroup" aria-label="Payment method" style="display: grid; gap: 4px;">
<label v-for="m in payMethods" :key="m.value"
style="display: flex; align-items: center; gap: 10px;">
<MdRadio name="pay-method" :value="m.value" :checked="payMethod === m.value"
@md-change="payMethod = m.value" /> {{ m.label }}
</label>
</div>
<MdCard variant="outlined" style="padding: 16px; gap: 12px;">
<div v-for="item in items" :key="item.name"
style="display: flex; align-items: center; gap: 12px;">
<span style="flex: 1;">{{ item.name }} — {{ money(item.price) }} each</span>
<MdNumberField label="Qty" variant="outlined" :density="-2" style="width: 140px;"
:min="1" :max="10" :step="1" :value="item.qty"
@md-input="item.qty = $event.detail.value || 0" />
</div>
<div style="display: flex; justify-content: space-between;">
<strong>Total</strong><strong>{{ money(total) }}</strong>
</div>
</MdCard>
<label style="display: flex; align-items: flex-start; gap: 10px;">
<MdCheckbox :required="true" :checked="terms" :error="termsError"
error-text="You must accept the terms to place an order"
@md-change="onTerms($event.detail.checked)" />
<span>I agree to the terms of sale and the 30-day return policy</span>
</label>
</div>
</MdStep>
<MdStep label="Review">
<div style="display: grid; gap: 8px; padding: 12px 20px 4px;">
<strong>Review your order</strong>
<span>Deliver to: {{ ship.name }}, {{ ship.street }}, {{ ship.postal }} {{ ship.city }}, {{ countryName }}</span>
<span>Payment: {{ payLabel }}</span>
<span>Items: {{ itemsSummary }}</span>
<strong>Total: {{ money(total) }}</strong>
</div>
</MdStep>
</MdStepper>
<MdSnackbar message="Order OD-58412 placed — a confirmation email is on its way"
:open="placed" @md-close="placed = false" />
</div>
</template><script>
// Svelte uses the raw md-* elements — register them once on the client.
// (SvelteKit: import { browser } from '$app/environment' and guard with it.)
import { defineCustomElements } from '@awc-ui/svelte';
if (typeof window !== 'undefined') defineCustomElements(window);
const shipFields = [
{ key: 'name', label: 'Full name', errorText: "Enter the recipient's name" },
{ key: 'street', label: 'Street address', errorText: 'Enter a street and house number' },
{ key: 'city', label: 'City', errorText: 'Enter a city' },
{ key: 'postal', label: 'Postal code', errorText: 'Enter a postal code' },
];
const countries = [
{ code: 'ro', name: 'Romania' }, { code: 'de', name: 'Germany' }, { code: 'fr', name: 'France' },
];
const payMethods = [
{ value: 'card', label: 'Credit or debit card' },
{ value: 'bank', label: 'Bank transfer' },
{ value: 'cod', label: 'Cash on delivery' },
];
let active = 0;
let loading = false;
let placed = false;
let ship = { name: '', street: '', city: '', postal: '', country: '' };
let shipErrors = {};
let payMethod = 'card';
let items = [
{ name: 'Aria desk lamp', price: 49, qty: 1 },
{ name: 'Oak monitor stand', price: 89, qty: 1 },
];
let terms = false;
let termsError = false;
let stepErrors = [false, false];
$: total = items.reduce((sum, i) => sum + (i.qty || 0) * i.price, 0);
function money(n) { return '€' + n.toFixed(2); }
function countryName(code) { return (countries.find((c) => c.code === code) || { name: '' }).name; }
function payLabel(value) { return (payMethods.find((m) => m.value === value) || { label: '' }).label; }
function setShipField(key, value) {
ship[key] = value;
if (value.trim()) shipErrors[key] = false; // recover inline
}
function validateStep(index) {
if (index === 0) {
for (const f of shipFields) shipErrors[f.key] = !ship[f.key].trim();
shipErrors.country = !ship.country;
return !Object.values(shipErrors).some(Boolean);
}
if (index === 1) {
termsError = !terms;
return terms;
}
return true;
}
// Gate every forward move: veto, validate, then commit by setting active.
function gate(e) {
e.preventDefault();
const { index, previous } = e.detail;
if (index <= previous) { active = index; return; } // backward is always allowed
const ok = validateStep(previous);
stepErrors[previous] = !ok;
if (ok) active = index;
}
function placeOrder() {
loading = true; // disables Continue AND Back — no double submit
setTimeout(() => { loading = false; placed = true; }, 900);
}
</script>
<div style="max-width: 680px; margin-inline: auto;">
<md-stepper label="Checkout progress" finish-label="Place order" {active} {loading}
on:mdBeforeChange={gate} on:mdComplete={placeOrder}>
<md-step label="Shipping" description="Address and destination" editable
error={stepErrors[0]} error-text="Complete the highlighted fields">
<div style="display: grid; gap: 16px; padding: 12px 20px 4px;">
{#each shipFields as f (f.key)}
<md-text-field variant="outlined" required reserve-supporting-space
label={f.label} error-text={f.errorText} value={ship[f.key]} error={shipErrors[f.key]}
on:mdInput={(e) => setShipField(f.key, e.detail)}></md-text-field>
{/each}
<md-select label="Country" variant="outlined" required reserve-supporting-space
error-text="Select a destination country" value={ship.country} error={shipErrors.country}
on:mdChange={(e) => setShipField('country', e.detail)}>
{#each countries as c (c.code)}
<md-select-option value={c.code}>{c.name}</md-select-option>
{/each}
</md-select>
</div>
</md-step>
<md-step label="Payment" description="Method and order" editable
error={stepErrors[1]} error-text="Accept the terms of sale to continue">
<div style="display: grid; gap: 20px; padding: 12px 20px 4px;">
<div role="radiogroup" aria-label="Payment method" style="display: grid; gap: 4px;">
{#each payMethods as m (m.value)}
<label style="display: flex; align-items: center; gap: 10px;">
<md-radio name="pay-method" value={m.value} checked={payMethod === m.value}
on:mdChange={() => (payMethod = m.value)}></md-radio>
{m.label}
</label>
{/each}
</div>
<md-card variant="outlined" style="padding: 16px; gap: 12px;">
{#each items as item, i (item.name)}
<div style="display: flex; align-items: center; gap: 12px;">
<span style="flex: 1;">{item.name} — {money(item.price)} each</span>
<md-number-field label="Qty" variant="outlined" density="-2" style="width: 140px;"
min="1" max="10" step="1" value={item.qty}
on:mdInput={(e) => (items[i].qty = e.detail.value || 0)}></md-number-field>
</div>
{/each}
<div style="display: flex; justify-content: space-between;">
<strong>Total</strong><strong>{money(total)}</strong>
</div>
</md-card>
<label style="display: flex; align-items: flex-start; gap: 10px;">
<md-checkbox required checked={terms} error={termsError}
error-text="You must accept the terms to place an order"
on:mdChange={(e) => { terms = e.detail.checked; if (terms) termsError = false; }}></md-checkbox>
<span>I agree to the terms of sale and the 30-day return policy</span>
</label>
</div>
</md-step>
<md-step label="Review">
<div style="display: grid; gap: 8px; padding: 12px 20px 4px;">
<strong>Review your order</strong>
<span>Deliver to: {ship.name}, {ship.street}, {ship.postal} {ship.city}, {countryName(ship.country)}</span>
<span>Payment: {payLabel(payMethod)}</span>
<span>Items: {items.map((i) => (i.qty || 0) + ' × ' + i.name).join(', ')}</span>
<strong>Total: {money(total)}</strong>
</div>
</md-step>
</md-stepper>
<md-snackbar message="Order OD-58412 placed — a confirmation email is on its way"
open={placed} on:mdClose={() => (placed = false)}></md-snackbar>
</div>How it’s built
Section titled “How it’s built”| Component | Role in this screen |
|---|---|
md-stepper | Owns 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-step | One 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-field | Address 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-select | Country picker. Form-associated with a real required state and a localizable value-missing-label. |
md-radio | Payment 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-field | Quantities in the order summary — typed or stepped, clamped to min/max on every path, emitting mdInput so the total recomputes live. |
md-card | The outlined order-summary container inside the Payment step. |
md-checkbox | Terms 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-snackbar | The low-priority “order placed” confirmation after Finish. |
The details that make it production-grade
Section titled “The details that make it production-grade”- The gate follows the documented veto-then-commit pattern. The
mdBeforeChangehandler callspreventDefault(), awaitscheckValidity()on every field in the step being left, and commits withwiz.active = index— assigningactivedirectly is the sanctioned way to finish an async validation, because callingnext()orgoTo()from the handler would re-emit the event and re-enter it. - Errors live where the fix is. A failed gate sets
erroron the step header (announced assertively by the stepper’s live region) and flips each invalid field’s ownerrorstate; 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.
mdCompletesetsloadingon 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-fieldclamps arrow keys, stepper buttons and wheel tomin="1"/max="10", and a typed out-of-range value is clamped at commit — the total can never go negative.
Variations
Section titled “Variations”- Vertical layout for mobile: set
orientation="vertical"on the stepper — each step then expands in place with its own Back / Continue actions, andvariant="mobile"gives an even more compact Back · progress · Continue bar (with the active panel in the stepper’scontentslot). - Declarative gating instead of the veto: bind the stepper’s
next-disabledto the current step’s validity from each field’smdValidityChange— Continue stays disabled until the step is complete, and nomdBeforeChangehandler is needed. - Real server submit: keep
loadingon while youawait fetch(...)in themdCompletehandler, and usesetCustomValidity()on a field when the server rejects a value (an unavailable postal code, a declined card). - Payment confirmation step: insert an
md-otp-fieldstep between Payment and Review for 3-D Secure style verification — see the two-factor recipe.
Related
Section titled “Related”- Recipe: Two-factor verification — the OTP step you would slot in for payment confirmation.
- Component manuals:
md-stepper— the full navigation contract, andmd-number-field— typed vs stepped value semantics.