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;
}