The source it is running
These blocks are the files the application above imports, read at build time. There is no copy of them to fall out of date.
flow.ts
The same file the React page shows. One definition, two renderings - that is the claim this pair of pages exists to make.
import { defineFlow, step } from '@wizzard-packages/core';
import { eq, get } from '@wizzard-packages/core/expr';
/**
* R-A: onboarding with a conditional branch and backtracking.
*
* The whole route lives in this object. Nothing below it decides where the
* visitor goes next, which is the point: the rendering is a switch over
* `current`, and it stays a switch when a fourth step is added here.
*/
export const onboarding = defineFlow({
id: 'onboarding',
version: 1,
order: ['details', 'verify', 'company', 'payment', 'review'],
steps: {
details: step<{ email: string; payer: 'personal' | 'business' }>({
label: 'Details',
validate: { $ref: 'details' },
}),
verify: step<{ code: string }>({
label: 'Verify',
validate: { $ref: 'verify' },
// The code is a credential, not an answer. `true` drops the whole slice
// whichever way the step is left, so it is never in the object the last
// step submits and never sits in storage waiting to be restored.
clearOnLeave: true,
// A visitor who already has an account skips ahead. `company` and
// `payment` stay reachable — they are jumped over, not excluded — so the
// jump has to be an explicit target rather than a `when`.
//
// The list is exhaustive on purpose: a target list where nothing matches
// ends the flow, so the ordinary route is spelled out after the fast
// path. `company` is tried first and skipped when its own `when` is
// false, which lands a personal payer on `payment`.
on: { next: [{ to: 'review', when: get('ctx.returning') }, 'company', 'payment'] },
}),
company: step<{ name: string; vat: string }>({
label: 'Company',
// Reachability, not navigation: a step whose `when` is false is off the
// route, so it is skipped forwards and backwards and drawn as a step this
// visitor will not see.
when: eq(get('data.details.payer'), 'business'),
validate: { $ref: 'company' },
}),
payment: step<{ card: string }>({
label: 'Payment',
validate: { $ref: 'payment' },
}),
review: step({ label: 'Review' }),
},
policy: 'free',
});
registry.ts
Also the same file. The validators are functions of the data, so neither binding has a version of its own.
import type { AsyncRegistry, Scope } from '@wizzard-packages/core';
/**
* What the flow could not serialize. Each `$ref` in `flow.ts` is a key here,
* and a validator answers with the field errors it found or `null`.
*
* They are ordinary functions of the data: no component, no engine, nothing to
* mock. That is what makes the same four testable without a browser.
*/
type Fields = Record<string, string> | null;
const slice = (scope: Scope, id: string): Record<string, unknown> =>
(scope.data[id] as Record<string, unknown> | undefined) ?? {};
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const registry: AsyncRegistry = {
details: (_args, scope): Fields => {
const { email, payer } = slice(scope, 'details');
const errors: Record<string, string> = {};
if (typeof email !== 'string' || email === '') errors['email'] = 'Enter your email address.';
else if (!EMAIL.test(email)) errors['email'] = 'That does not look like an email address.';
if (payer !== 'personal' && payer !== 'business') errors['payer'] = 'Choose who is paying.';
return Object.keys(errors).length === 0 ? null : errors;
},
verify: (_args, scope): Fields => {
const { code } = slice(scope, 'verify');
// Six digits, and in a real application a call to the service that sent
// them. The example keeps it local so the page works offline.
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
return { code: 'The code is six digits.' };
}
return null;
},
company: (_args, scope): Fields => {
const { name, vat } = slice(scope, 'company');
const errors: Record<string, string> = {};
if (typeof name !== 'string' || name.trim() === '') errors['name'] = 'Enter the company name.';
if (typeof vat !== 'string' || vat.trim() === '') errors['vat'] = 'Enter the VAT number.';
return Object.keys(errors).length === 0 ? null : errors;
},
payment: (_args, scope): Fields => {
const { card } = slice(scope, 'payment');
const digits = typeof card === 'string' ? card.replace(/\s/g, '') : '';
if (!/^\d{16}$/.test(digits)) return { card: 'A card number is sixteen digits.' };
return null;
},
};
Onboarding.vue
The rendering. Step for step and message for message the React file, with `v-if` where the other has `&&`.
<script setup lang="ts">
import {
useErrors,
useField,
useNavigation,
useStep,
useWizard,
useWizardSelector,
} from '@wizzard-packages/vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
/**
* R-A on the Vue binding.
*
* The same application as `App.tsx`, step for step and message for message: the
* two files exist so that a reader can put them side by side and see that the
* engine, the route and the validation are the same object, and only the
* rendering differs.
*
* Accessibility is by hand for the same reason it is in the React file - the
* bindings supply no ARIA in 1.0, and these two are what that contract will be
* written from.
*/
const LABELS: Record<string, string> = {
details: 'Your details',
verify: 'Verify your email',
company: 'Company details',
payment: 'Payment',
review: 'Review',
};
const wizard = useWizard();
const { current, active, isLast } = useStep();
const { back, canBack, isBusy } = useNavigation();
const errors = useErrors();
const email = useField<string>('details.email');
const payer = useField<string>('details.payer');
const code = useField<string>('verify.code');
const company = useField<string>('company.name');
const vat = useField<string>('company.vat');
const card = useField<string>('payment.card');
const returning = useWizardSelector((s) => s.ctx['returning'] === true);
const completed = useWizardSelector((s) => s.completed);
const dirty = useWizardSelector((s) => s.dirty);
const data = useWizardSelector((s) => s.data);
const announcement = ref('');
const ended = ref(false);
const heading = useTemplateRef<HTMLHeadingElement>('heading');
// Before `start` has run - on the server, and for the first paint - there is no
// current step, so the form draws the step the flow is about to enter and says
// so, instead of a spinner over an empty box.
const standing = computed(() => current.value ?? active.value[0] ?? 'details');
const starting = computed(() => current.value === null);
/**
* What the flow submits: the slices of the steps that are on the route.
*
* A branch the visitor walked away from keeps its answers - the engine clears
* nothing when a `when` goes false - and it is the application that decides
* those answers are not part of this submission. `verify` is missing for the
* other reason: `clearOnLeave` dropped it the moment the step was left.
*/
const submission = computed(() => {
const out: Record<string, unknown> = {};
for (const id of active.value) {
if (data.value[id] !== undefined) out[id] = data.value[id];
}
return out;
});
// Focus follows the flow, but only once the visitor has moved: taking focus on
// the first paint would drag a reader who was still reading the page above.
let moved = false;
watch(current, (to) => {
if (to === null) return;
if (!moved) {
moved = true;
return;
}
heading.value?.focus();
const at = active.value.indexOf(to) + 1;
announcement.value = `${LABELS[to] ?? to}. Step ${at} of ${active.value.length}.`;
});
async function onNext(): Promise<void> {
if (ended.value) {
ended.value = false;
announcement.value = '';
// `reset` keeps `ctx` - it is the host's, not the run's - so the fast path
// has to be put back by hand, or starting again silently takes it.
wizard.setCtx({ returning: false });
wizard.reset();
await wizard.start();
return;
}
const result = await wizard.next();
if (result.ok) {
if (result.to === '@end') {
ended.value = true;
announcement.value = 'Onboarding complete.';
}
return;
}
// A refusal is the one thing the live region must carry: the fields below
// show it too, but a screen reader is not looking at them.
const fields = Object.values(result.errors ?? {});
announcement.value =
fields.length === 0 ? `That move was refused: ${result.reason}.` : fields.join(' ');
}
</script>
<template>
<div v-if="ended" class="app">
<h2 ref="heading" tabindex="-1">Done</h2>
<p>The submission is what the flow collected, and nothing else.</p>
<pre class="app-data">{{ JSON.stringify(submission, null, 2) }}</pre>
<div class="actions">
<button class="button button-accent" type="button" @click="onNext()">Start again</button>
</div>
<p class="app-live" role="status" aria-live="polite">{{ announcement }}</p>
</div>
<form v-else class="app" @submit.prevent="onNext()">
<h2 ref="heading" tabindex="-1">{{ LABELS[standing] ?? standing }}</h2>
<template v-if="standing === 'details'">
<!--
The label is a `for`/`id` pair rather than a wrapper, and the hint and
the error are named by `aria-describedby`: that keeps the accessible
name of the input to the label alone, while a screen reader still reads
the refusal with the field it belongs to.
-->
<div class="field">
<label class="field-label" for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
autocomplete="email"
:aria-invalid="errors['email'] ? true : undefined"
:aria-describedby="errors['email'] ? 'email-error' : undefined"
/>
<span v-if="errors['email']" id="email-error" class="field-error">
{{ errors['email'] }}
</span>
</div>
<fieldset class="field">
<legend class="field-label">Who is paying</legend>
<div class="segmented">
<button
v-for="choice in ['personal', 'business']"
:key="choice"
type="button"
:aria-pressed="payer === choice"
@click="payer = choice"
>
{{ choice === 'personal' ? 'Personal' : 'Business' }}
</button>
</div>
<p v-if="errors['payer']" class="field-error">{{ errors['payer'] }}</p>
</fieldset>
</template>
<template v-else-if="standing === 'verify'">
<div class="field">
<label class="field-label" for="code">Six-digit code</label>
<input
id="code"
v-model="code"
inputmode="numeric"
autocomplete="one-time-code"
:aria-invalid="errors['code'] ? true : undefined"
:aria-describedby="errors['code'] ? 'code-hint code-error' : 'code-hint'"
/>
<span id="code-hint" class="field-hint">Any six digits will do here.</span>
<span v-if="errors['code']" id="code-error" class="field-error">{{ errors['code'] }}</span>
</div>
<label class="check">
<input
type="checkbox"
:checked="returning"
@change="wizard.setCtx({ returning: ($event.target as HTMLInputElement).checked })"
/>
I already have an account
</label>
</template>
<template v-else-if="standing === 'company'">
<div class="field">
<label class="field-label" for="company-name">Company name</label>
<input
id="company-name"
v-model="company"
:aria-invalid="errors['name'] ? true : undefined"
:aria-describedby="errors['name'] ? 'company-name-error' : undefined"
/>
<span v-if="errors['name']" id="company-name-error" class="field-error">
{{ errors['name'] }}
</span>
</div>
<div class="field">
<label class="field-label" for="vat">VAT number</label>
<input
id="vat"
v-model="vat"
:aria-invalid="errors['vat'] ? true : undefined"
:aria-describedby="errors['vat'] ? 'vat-error' : undefined"
/>
<span v-if="errors['vat']" id="vat-error" class="field-error">{{ errors['vat'] }}</span>
</div>
</template>
<div v-else-if="standing === 'payment'" class="field">
<label class="field-label" for="card">Card number</label>
<input
id="card"
v-model="card"
inputmode="numeric"
autocomplete="cc-number"
:aria-invalid="errors['card'] ? true : undefined"
:aria-describedby="errors['card'] ? 'card-hint card-error' : 'card-hint'"
/>
<span id="card-hint" class="field-hint">Sixteen digits, spaces allowed.</span>
<span v-if="errors['card']" id="card-error" class="field-error">{{ errors['card'] }}</span>
</div>
<template v-else-if="standing === 'review'">
<p>This is what the flow will submit.</p>
<pre class="app-data">{{ JSON.stringify(submission, null, 2) }}</pre>
</template>
<div class="actions">
<button class="button button-accent" type="submit" :disabled="starting || isBusy">
{{ isLast ? 'Submit' : 'Next' }}
</button>
<button
class="button button-secondary"
type="button"
:disabled="starting || !canBack || isBusy"
@click="back()"
>
Back
</button>
</div>
<dl class="app-state">
<dt>route</dt>
<dd>{{ active.join(' → ') }}</dd>
<dt>completed</dt>
<dd>{{ completed.length === 0 ? 'none' : completed.join(', ') }}</dd>
<dt>edited</dt>
<dd>{{ dirty.length === 0 ? 'none' : dirty.join(', ') }}</dd>
</dl>
<p class="app-live" role="status" aria-live="polite">
{{ starting ? 'Starting.' : announcement }}
</p>
</form>
</template>