Wizard
Wizard demo built with Expressive MVC - the complete source below runs as an editable sandbox when JavaScript is enabled.
App.tsx
import './App.css';
import State, { Component } from '@expressive/react';
import { Route, Router } from '@expressive/router';
import type { ReactNode } from 'react';
export default () => (
<div className="container">
<h1>Wizard</h1>
<p>
Steps are sibling routes, not a counter. Each entry guard names the first
missing step, so direct navigation, Back, and Continue obey the same rules.
</p>
<Wizard />
<small>
The parent Route exposes its children in declaration order and its active
child. Progress and controls derive from that route tree instead of
duplicating navigation state.
</small>
</div>
);
class Application extends State {
name = '';
email = '';
agreed = false;
submitted = false;
get incomplete() {
return !this.name.trim() ? '/name'
: !this.email.includes('@') ? '/details'
: '';
}
}
class Wizard extends Component {
application = new Application();
router = new Router({ path: '/name' });
details() {
return this.application.name.trim() ? '' : '/name';
}
review() {
return this.application.incomplete;
}
done() {
const { incomplete, submitted } = this.application;
return submitted ? '' : incomplete || '/review';
}
render() {
const { details, done, review } = this;
return (
<Route as={Frame}>
<Route to="name" as={Name} label="Name" />
<Route to="details" as={Details} label="Details"
redirect={details} />
<Route to="review" as={Review} label="Review"
redirect={review} />
<Route to="done" as={Done}
redirect={done} />
<Route none redirect="/name" />
</Route>
);
}
}
const Frame = (props: { children?: ReactNode }) => (
<>
<Progress />
<div className="view">{props.children}</div>
</>
);
const Progress = () => {
const { active, inner, router } = Route.get();
const steps = inner.filter((route) => route.label);
const at = steps.findIndex((step) => step.path === active?.path);
return (
<nav className="steps">
{steps.map((step, i) => (
<button
key={step.path}
className={i === at ? 'step here' : at < 0 || i < at ? 'step past' : 'step'}
onClick={() => router.goto(step.path)}>
<b>{i + 1}</b> {step.label}
</button>
))}
</nav>
);
};
const Controls = (props: { children?: ReactNode }) => {
const { parent, path, router } = Route.get();
const steps = parent!.inner.filter((route) => route.label);
const at = steps.findIndex((step) => step.path === path);
const back = steps[at - 1];
const next = steps[at + 1];
return (
<div className="controls">
<button disabled={!back} onClick={() => router.goto(back.path)}>
Back
</button>
{props.children || (
<button onClick={() => router.goto(next.path)}>Continue</button>
)}
</div>
);
};
const Name = () => {
const { is: me, name } = Application.get();
return (
<div className="card">
<label>
Full name
<input
value={name}
placeholder="Ada Lovelace"
onChange={(e) => (me.name = e.target.value)}
/>
</label>
<Controls />
</div>
);
};
const Details = () => {
const { is: me, email } = Application.get();
return (
<div className="card">
<label>
Email address
<input
value={email}
placeholder="[email protected]"
onChange={(e) => (me.email = e.target.value)}
/>
</label>
<Controls />
</div>
);
};
const Review = () => {
const { is: me, name, email, agreed } = Application.get();
const { router } = Route.get();
const submit = () => {
me.submitted = true;
router.goto('/done');
};
return (
<div className="card">
<dl>
<dt>Name</dt>
<dd>{name}</dd>
<dt>Email</dt>
<dd>{email}</dd>
</dl>
<label className="agree">
<input
type="checkbox"
checked={agreed}
onChange={(e) => (me.agreed = e.target.checked)}
/>
Everything above is correct.
</label>
<Controls>
<button disabled={!agreed} onClick={submit}>
Submit
</button>
</Controls>
</div>
);
};
const Done = () => {
const { is: me, name } = Application.get();
const { router } = Route.get();
const restart = () => {
me.name = '';
me.email = '';
me.agreed = false;
me.submitted = false;
router.goto('/name');
};
return (
<div className="card done">
<p>Application received. Thanks, {name}!</p>
<button onClick={restart}>Start over</button>
</div>
);
};
App.css
.steps {
display: flex;
gap: var(--s2);
width: 100%;
}
.step {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--s2);
padding: var(--s2) var(--s3);
font-size: var(--t-sm);
color: var(--muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r-sm);
cursor: pointer;
}
.step b {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
font-size: var(--t-xs);
border-radius: 50%;
background: var(--surface-2);
color: var(--fg-soft);
}
.step.here {
color: var(--accent);
border-color: var(--accent);
background: var(--accent-soft);
}
.step.here b,
.step.past b {
background: var(--accent);
color: var(--accent-fg);
}
.step.past {
color: var(--fg-soft);
}
.view {
width: 100%;
}
.card {
display: flex;
flex-direction: column;
gap: var(--s4);
width: 100%;
padding: var(--s4);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--r);
}
.card label {
display: flex;
flex-direction: column;
gap: var(--s2);
font-size: var(--t-sm);
color: var(--fg-soft);
}
.card label.agree {
flex-direction: row;
align-items: center;
}
.card dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--s2) var(--s4);
margin: 0;
font-size: var(--t-sm);
}
.card dt {
color: var(--muted);
}
.card dd {
margin: 0;
}
.controls {
display: flex;
justify-content: space-between;
padding-top: var(--s2);
}
.done {
align-items: flex-start;
}
.done p {
margin: 0;
}