Checkout wizard
Four steps, one state machine.
The current step is one signal, and a transition table says which events each step accepts. Next is allowed only when the step is valid, and the steps indicator follows the state.
Preview
Code
Save it as an .html file and open it in a browser. It loads
Signy and Iris CSS from the CDN, so there is nothing to install or build.
Show the full page
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Checkout wizard - Signy example</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/iriscss@1/dist/iris.min.css">
<script src="https://cdn.jsdelivr.net/npm/signy@1/dist/signy.global.min.js"></script>
</head>
<body>
<main class="container" id="app"></main>
<script>
const { Component, Signal, Computed, application, html, Switch, Match, Show } = Signy;
// Each state lists the events it accepts and where they lead. Anything else is ignored.
const MACHINE = {
cart: { next: "shipping" },
shipping: { next: "payment", back: "cart" },
payment: { next: "review", back: "shipping" },
review: { confirm: "placing", back: "payment" },
placing: { placed: "done" },
done: { restart: "cart" },
};
const STEPS = [
["cart", "Cart"],
["shipping", "Shipping"],
["payment", "Payment"],
["review", "Review"],
];
const PRICE = 18;
class Checkout extends Component {
state = new Signal("cart");
quantity = new Signal(1);
name = new Signal("");
address = new Signal("");
delivery = new Signal("standard");
card = new Signal("");
shippingCost = new Computed(() => (this.delivery.value === "express" ? 9 : 0));
total = new Computed(() => this.quantity.value * PRICE + this.shippingCost.value);
// Next is allowed only when the current step is complete
valid = new Computed(() => {
switch (this.state.value) {
case "cart":
return this.quantity.value > 0;
case "shipping":
return this.name.value.trim() !== "" && this.address.value.trim() !== "";
case "payment":
return this.card.value.replace(/\s/g, "").length === 16;
default:
return true;
}
});
send(event) {
const target = MACHINE[this.state.value][event];
if (!target) return;
if ((event === "next" || event === "confirm") && !this.valid.value) return;
this.state.value = target;
if (target === "placing") setTimeout(() => this.send("placed"), 800);
}
text(label, signal, attributes = {}) {
return html`
<label>
${label}
<input value=${signal.peek()} onInput=${(e) => (signal.value = e.currentTarget.value)} ...${attributes} />
</label>
`;
}
steps() {
return html`
<ol class="steps">
${STEPS.map(([state, label]) => html`
<li aria-current=${() => (this.state.value === state ? "step" : null)}>${label}</li>
`)}
</ol>
`;
}
step(state, body) {
return Match({ when: () => this.state.value === state, children: body });
}
render() {
return html`
<article class="card">
<header>
<h1>Checkout</h1>
${Show({
when: () => STEPS.some(([state]) => state === this.state.value),
children: this.steps(),
})}
</header>
${Switch({
children: [
this.step("cart", () => html`
<h2>Cart</h2>
<label>
Signy mugs, ${PRICE} EUR each
<input type="number" min="0" max="9" value=${this.quantity.peek()}
onInput=${(e) => (this.quantity.value = Number(e.currentTarget.value) || 0)} />
</label>
`),
this.step("shipping", () => html`
<h2>Shipping</h2>
${this.text("Name", this.name, { autocomplete: "name" })}
${this.text("Address", this.address, { autocomplete: "street-address" })}
<fieldset>
<legend>Delivery</legend>
<label><input type="radio" name="delivery" checked=${this.delivery.peek() === "standard"}
onChange=${() => (this.delivery.value = "standard")} /> Standard, free</label>
<label><input type="radio" name="delivery" checked=${this.delivery.peek() === "express"}
onChange=${() => (this.delivery.value = "express")} /> Express, 9 EUR</label>
</fieldset>
`),
this.step("payment", () => html`
<h2>Payment</h2>
${this.text("Card number", this.card, { inputmode: "numeric", autocomplete: "cc-number", placeholder: "4242 4242 4242 4242" })}
<p><small>Any 16 digits work. Nothing is charged.</small></p>
`),
this.step("review", () => html`
<h2>Review</h2>
<table>
<tbody>
<tr><th scope="row">Mugs</th><td>${() => this.quantity.value} × ${PRICE} EUR</td></tr>
<tr><th scope="row">Ship to</th><td>${() => this.name.value}, ${() => this.address.value}</td></tr>
<tr><th scope="row">Delivery</th><td>${() => this.delivery.value}</td></tr>
<tr><th scope="row">Card</th><td>ending in ${() => this.card.value.replace(/\s/g, "").slice(-4)}</td></tr>
<tr><th scope="row">Total</th><td><strong>${() => this.total.value} EUR</strong></td></tr>
</tbody>
</table>
`),
this.step("placing", () => html`<p aria-busy="true">Placing your order</p>`),
this.step("done", () => html`
<p class="alert success" role="status">Thank you! Your order is on its way.</p>
<button onClick=${() => this.send("restart")}>Start again</button>
`),
],
})}
${Show({
when: () => STEPS.some(([state]) => state === this.state.value),
children: html`
<footer class="cluster">
<button class="secondary outlined" disabled=${() => !MACHINE[this.state.value].back}
onClick=${() => this.send("back")}>Back</button>
<button class="primary" disabled=${() => !this.valid.value}
onClick=${() => this.send(this.state.value === "review" ? "confirm" : "next")}>
${() => (this.state.value === "review" ? `Pay ${this.total.value} EUR` : "Next")}
</button>
</footer>
`,
})}
</article>
`;
}
}
application(Checkout).mount("#app");
</script>
</body>
</html>
What it uses
- Signals and Computed guards
- Switch and Match for the steps
- The Iris CSS
.stepscomponent