Recipes
Each recipe is a whole component in one file, with its tests. They need only @gyral/core,
plus @gyral/testing and Vitest for the tests, so they work in a new basic app from
create-gyral. Every recipe compiles against Gyral
0.3.6, and the site tests each one in a browser when it builds.
A todo list with filters
Add todos, tick them off, delete them, and show all, active or done ones:
// src/todo-app.ts
import { define, each, focus, html, intentsOf, type TemplateResult } from '@gyral/core';
export interface Todo {
readonly id: number;
readonly text: string;
readonly done: boolean;
}
export type Filter = 'all' | 'active' | 'done';
export interface State {
readonly todos: readonly Todo[];
readonly nextId: number;
readonly draft: string;
readonly filter: Filter;
}
export type Msg =
| { readonly _tag: 'Typed'; readonly text: string }
| { readonly _tag: 'Add'; readonly text: string }
| { readonly _tag: 'Toggle'; readonly id: number }
| { readonly _tag: 'Delete'; readonly id: number }
| { readonly _tag: 'Show'; readonly filter: Filter };
const FILTERS: readonly Filter[] = ['all', 'active', 'done'];
const LABELS: Readonly<Record<Filter, string>> = { all: 'All', active: 'Active', done: 'Done' };
const isFilter = (value: string | undefined): value is Filter => FILTERS.some((f) => f === value);
/** A row's id from its control's `value`, or `undefined` for anything else. */
const idOf = (value: string | undefined): number | undefined =>
value !== undefined && /^\d+$/.test(value) ? Number(value) : undefined;
/** The todos a filter shows. State keeps the whole list; the view shows this. */
export const visible = (todos: readonly Todo[], filter: Filter): readonly Todo[] =>
filter === 'all' ? todos : todos.filter((t) => t.done === (filter === 'done'));
// The intent names as a module constant, so rows can use them and stay pure.
const i = intentsOf<typeof TodoApp>();
// A row reads only its todo and module constants. Its controls carry the id, never a position.
const Row = (todo: Todo): TemplateResult =>
html`<li>
<label>
<input type="checkbox" value=${todo.id} ?checked=${todo.done} data-intent=${i.Toggle} />
${todo.text}
</label>
<button
type="button"
value=${todo.id}
aria-label=${`Delete ${todo.text}`}
data-intent=${i.Delete}
>
Delete
</button>
</li>`;
export const TodoApp = define<State, Msg>()('my-todo-app', {
init: () => ({ todos: [], nextId: 1, draft: '', filter: 'all' }),
intent: {
Typed: ({ value }) => ({ _tag: 'Typed', text: value ?? '' }),
Add: ({ formData }) => {
const text = formData?.get('text');
return typeof text === 'string' && text.trim() !== ''
? { _tag: 'Add', text: text.trim() }
: undefined;
},
Toggle: ({ value }) => {
const id = idOf(value);
return id === undefined ? undefined : { _tag: 'Toggle', id };
},
Delete: ({ value }) => {
const id = idOf(value);
return id === undefined ? undefined : { _tag: 'Delete', id };
},
Show: ({ value }) => (isFilter(value) ? { _tag: 'Show', filter: value } : undefined),
},
update: {
Typed: (s, m) => ({ ...s, draft: m.text }),
// Clearing `draft` empties the input; focus goes back to it for the next todo.
Add: (s, m) => [
{
...s,
todos: [...s.todos, { id: s.nextId, text: m.text, done: false }],
nextId: s.nextId + 1,
draft: '',
},
[focus('#new-todo')],
],
Toggle: (s, m) => ({
...s,
todos: s.todos.map((t) => (t.id === m.id ? { ...t, done: !t.done } : t)),
}),
// The deleted row had focus: give it to the input, not to the page body.
Delete: (s, m) => [{ ...s, todos: s.todos.filter((t) => t.id !== m.id) }, [focus('#new-todo')]],
Show: (s, m) => ({ ...s, filter: m.filter }),
},
view: (s, i) => {
const shown = visible(s.todos, s.filter);
const left = s.todos.filter((t) => !t.done).length;
return html`
<form data-intent=${i.Add}>
<label for="new-todo">New todo</label>
<input
id="new-todo"
name="text"
autocomplete="off"
value=${s.draft}
data-intent=${i.Typed}
/>
<button>Add</button>
</form>
<div role="group" aria-label="Show">
${FILTERS.map(
(f) =>
html`<button
type="button"
value=${f}
aria-pressed=${s.filter === f}
data-intent=${i.Show}
>
${LABELS[f]}
</button>`,
)}
</div>
<ul aria-label="Todos">
${each(shown, (t) => t.id, Row)}
</ul>
<p>${shown.length === 0 ? 'Nothing to show.' : ''}</p>
<p><output aria-live="polite">${left} left</output></p>
`;
},
});How it works:
- State keeps the whole list. The view filters it.
visible(todos, filter)is a plain function that the view and the tests call. The filter is state; the filtered list isn't, so the two can't get out of step. - Controls carry the id:
value=${todo.id}, and the parser readsvalue. A message says which todo, never which row. See the wrong item changes. - Rows are keyed by id:
each(shown, (t) => t.id, Row). A todo keeps its element when the filter changes or another row goes away. - Rows are pure.
Rowreads only its todo and module constants, such asi = intentsOf<typeof TodoApp>(). Its return type is written out, soRowandTodoAppdon't infer each other's types. The view uses its owni. - The input is form state.
value=${s.draft}followsTyped, andAddsetsdraftback to'', which empties the input. Withoutdraftin state, the text would stay after Add. - Focus is a command.
AddandDeletereturnfocus('#new-todo'), so focus never falls to the page body when its row is deleted. - The filter buttons use
.map: three buttons that never move don't needeach.
Test the model without a browser. run folds messages from init (or from state), and
parse runs one parser without a DOM:
// src/todo-app.test.ts
import { expect, it } from 'vitest';
import { focusTargetsIn, parse, run, step } from '@gyral/testing';
import { TodoApp, visible } from './todo-app.js';
const added = run(TodoApp.spec, [
{ _tag: 'Add', text: 'Write docs' },
{ _tag: 'Add', text: 'Ship it' },
{ _tag: 'Add', text: 'Celebrate' },
]).state;
it('adds todos with their own ids and clears the input', () => {
expect(added.todos.map((t) => [t.id, t.text])).toEqual([
[1, 'Write docs'],
[2, 'Ship it'],
[3, 'Celebrate'],
]);
expect(added.draft).toBe('');
const { commands } = step(TodoApp.spec, added, { _tag: 'Add', text: 'More' });
expect(focusTargetsIn(commands)).toEqual([{ selector: '#new-todo' }]);
});
it('toggles and deletes by id, whatever the filter shows', () => {
const { state } = run(
TodoApp.spec,
[
{ _tag: 'Toggle', id: 1 },
{ _tag: 'Show', filter: 'active' },
// "Celebrate" is the second row on screen now, but the message names it by id.
{ _tag: 'Toggle', id: 3 },
{ _tag: 'Delete', id: 2 },
],
{ state: added },
);
expect(state.todos).toEqual([
{ id: 1, text: 'Write docs', done: true },
{ id: 3, text: 'Celebrate', done: true },
]);
expect(visible(state.todos, 'active')).toEqual([]);
expect(visible(state.todos, 'done').map((t) => t.id)).toEqual([1, 3]);
});
it('reads ids and filters from the markup, and ignores anything else', () => {
expect(parse(TodoApp, 'Toggle', { value: '3' })).toEqual({ _tag: 'Toggle', id: 3 });
expect(parse(TodoApp, 'Toggle', { value: 'three' })).toBeUndefined();
expect(parse(TodoApp, 'Show', { value: 'done' })).toEqual({ _tag: 'Show', filter: 'done' });
expect(parse(TodoApp, 'Show', { value: 'archived' })).toBeUndefined();
});A signup form with native validation
No extra packages. The browser's constraint checks find what is wrong, and your own messages say it. The Forms page starts with this same component and explains each part; here it comes with its tests:
// src/account-form.ts
import { define, focus, html } from '@gyral/core';
/** The fields, in the order they appear: the first one with an error gets focus. */
export const FIELDS = ['name', 'email', 'password', 'consent'] as const;
export type Field = (typeof FIELDS)[number];
/** One message per field; `''` means no error. */
export type Errors = Readonly<Record<Field, string>>;
export const NO_ERRORS: Errors = { name: '', email: '', password: '', consent: '' };
export interface State {
readonly errors: Errors;
/** What the live region announces after a rejected submit; `''` otherwise. */
readonly summary: string;
/** The clean values after a successful signup. */
readonly account: { readonly name: string; readonly email: string } | undefined;
}
export type Msg =
| { readonly _tag: 'Rejected'; readonly errors: Errors }
| { readonly _tag: 'SignedUp'; readonly name: string; readonly email: string }
| { readonly _tag: 'Checked'; readonly field: Field; readonly error: string };
const isField = (name: string): name is Field => FIELDS.some((f) => f === name);
/**
* Your message for a field's first problem, from the browser's own checks (`validity`), or
* `''` when the field is fine. The constraints in the markup (`required`, `type="email"`,
* `minlength`) still decide what is wrong; `novalidate` only stops the browser from
* blocking the submit and showing its own bubble.
*/
export function problemWith(input: HTMLInputElement): string {
const { validity } = input;
// A checkbox is missing when unchecked. `required` accepts " ", so blank text is missing too.
const missing =
input.type === 'checkbox' ? !input.checked : validity.valueMissing || input.value.trim() === '';
switch (input.name) {
case 'name':
return missing ? 'Enter your name.' : '';
case 'email':
if (missing) return 'Enter your email address.';
return validity.typeMismatch ? 'Enter an email address like ada@example.com.' : '';
case 'password':
if (missing) return 'Enter a password.';
// `tooShort` is set only for typed values; the length check also covers a pre-filled one.
return validity.tooShort || input.value.length < input.minLength
? 'Use at least 8 characters.'
: '';
case 'consent':
return missing ? 'Accept the privacy policy to continue.' : '';
default:
return '';
}
}
/** What the status region announces after a successful signup. */
const welcome = (account: State['account']): string =>
account === undefined ? '' : `Welcome, ${account.name}! We’ll write to ${account.email}.`;
/** Reads a submitted form: every field's error, or the clean values. */
function submission(form: Element, data: FormData | undefined): Msg | undefined {
if (!(form instanceof HTMLFormElement) || data === undefined) return undefined;
const errors: Record<Field, string> = { ...NO_ERRORS };
for (const field of FIELDS) {
const input = form.elements.namedItem(field);
if (input instanceof HTMLInputElement) errors[field] = problemWith(input);
}
if (FIELDS.some((f) => errors[f] !== '')) return { _tag: 'Rejected', errors };
const text = (field: Field): string => {
const value = data.get(field);
return typeof value === 'string' ? value.trim() : '';
};
// The password stays in the form data: never keep it in state.
return { _tag: 'SignedUp', name: text('name'), email: text('email') };
}
export const AccountForm = define<State, Msg>()('my-account-form', {
init: () => ({ errors: NO_ERRORS, summary: '', account: undefined }),
intent: {
// Not a message tag, so it may return either message.
Submit: ({ target, formData }) => submission(target, formData),
// Text fields send it on `input`, the checkbox on `change`.
Checked: ({ target }) =>
target instanceof HTMLInputElement && isField(target.name)
? { _tag: 'Checked', field: target.name, error: problemWith(target) }
: undefined,
},
update: {
// Show every error at once, announce how many, and focus the first field that has one.
Rejected: (s, m) => {
const count = FIELDS.filter((f) => m.errors[f] !== '').length;
const first = FIELDS.find((f) => m.errors[f] !== '');
const summary = `Fix ${String(count)} ${count === 1 ? 'field' : 'fields'} to continue.`;
const focusFirst = first === undefined ? [] : [focus(`#${first}`)];
return [{ ...s, errors: m.errors, summary }, focusFirst];
},
// In an app, this also returns the command that posts the form.
SignedUp: (_s, m) => ({
errors: NO_ERRORS,
summary: '',
account: { name: m.name, email: m.email },
}),
// While the user types, only a field that already shows an error is checked again,
// so a message changes or disappears as soon as the value is fixed.
Checked: (s, m) =>
s.errors[m.field] === '' ? s : { ...s, errors: { ...s.errors, [m.field]: m.error } },
},
// No `value` bindings: what the user typed stays in the fields after a rejected submit.
view: (s, i) => html`
<form novalidate data-intent=${i.Submit}>
<p role="alert">${s.summary}</p>
<label for="name">Name</label>
<input
id="name"
name="name"
autocomplete="name"
required
aria-invalid=${s.errors.name !== ''}
aria-describedby="name-error"
data-intent=${i.Checked}
/>
<p id="name-error">${s.errors.name}</p>
<label for="email">Email</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
required
aria-invalid=${s.errors.email !== ''}
aria-describedby="email-error"
data-intent=${i.Checked}
/>
<p id="email-error">${s.errors.email}</p>
<label for="password">Password</label>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
required
minlength="8"
aria-invalid=${s.errors.password !== ''}
aria-describedby="password-error"
data-intent=${i.Checked}
/>
<p id="password-error">${s.errors.password}</p>
<input
id="consent"
name="consent"
type="checkbox"
required
aria-invalid=${s.errors.consent !== ''}
aria-describedby="consent-error"
data-intent=${i.Checked}
/>
<label for="consent">I accept the privacy policy</label>
<p id="consent-error">${s.errors.consent}</p>
<button>Create account</button>
</form>
<p role="status">${welcome(s.account)}</p>
`,
});How it works:
novalidateon the form, constraints on the fields.required,type="email"andminlengthstill set each field'svalidity.novalidatestops the browser from blocking the submit. Without it, a failing constraint shows the browser's bubble and nosubmitevent fires, so your messages never appear.- The submit parser reads
validity.problemWith(input)turnsvalueMissing,typeMismatch,tooShortand an unchecked checkbox into your words.Submitchecks every field and returnsRejectedwith all the errors, orSignedUpwith clean values. Reading the form is input parsing, the intent layer's job; reducers see only the result. - Check what the browser doesn't.
requiredaccepts a name made of spaces, so the parser trims first.tooShortis set only after the user types, not for a value set by script or rendered by a server, so the parser checks the length too. - Errors are state: one string per field,
''for none. The view writesaria-invalidand the message, andaria-describedbylinks the message to its field. - Focus goes to the first invalid field, through a
focus()command fromRejected, and therole="alert"paragraph announces how many fields to fix. - An error clears as soon as the field is fixed. Each field sends
Checkedon input (the checkbox on change). The reducer updates only a field that already shows an error, so nothing turns red while someone is still typing. - Enter submits. The form has a submit button, so Enter in any field submits it, like a click.
- No password in state.
SignedUpcarries the name and the email. To send the form to a server, keep the rawFormDatain the message and post it with a command, as in Submitting to the server.
The reducers are tested without a browser:
// src/account-form.test.ts
import { expect, it } from 'vitest';
import { focusTargetsIn, run, step } from '@gyral/testing';
import { AccountForm, NO_ERRORS } from './account-form.js';
const rejected = {
name: '',
email: 'Enter an email address like ada@example.com.',
password: 'Use at least 8 characters.',
consent: '',
};
it('shows every error, announces how many and focuses the first invalid field', () => {
const { state, commands } = step(
AccountForm.spec,
{ errors: NO_ERRORS, summary: '', account: undefined },
{ _tag: 'Rejected', errors: rejected },
);
expect(state.errors).toEqual(rejected);
expect(state.summary).toBe('Fix 2 fields to continue.');
expect(focusTargetsIn(commands)).toEqual([{ selector: '#email' }]);
});
it('clears an error as soon as the field is fixed, and leaves untouched fields alone', () => {
const { state } = run(AccountForm.spec, [
{ _tag: 'Rejected', errors: rejected },
{ _tag: 'Checked', field: 'email', error: '' },
{ _tag: 'Checked', field: 'name', error: 'Enter your name.' },
]);
expect(state.errors).toEqual({ ...rejected, email: '' });
});
it('welcomes the new user and forgets old errors', () => {
const { state } = run(AccountForm.spec, [
{ _tag: 'Rejected', errors: rejected },
{ _tag: 'SignedUp', name: 'Ada', email: 'ada@example.com' },
]);
expect(state).toEqual({
errors: NO_ERRORS,
summary: '',
account: { name: 'Ada', email: 'ada@example.com' },
});
});The submit parser reads the form's fields, so test it in a browser too. In a basic app,
npm test runs both files in Chromium. mount() sets values with script, which is why the
parser's own length check matters:
// src/account-form.browser.test.ts
import { expect, it } from 'vitest';
import { mount } from '@gyral/testing';
import { AccountForm } from './account-form.js';
it('reads the browser’s checks on submit and focuses the first invalid field', async () => {
const form = await mount(AccountForm);
await form.type('#email', 'ada');
await form.type('#password', 'short');
await form.click('button');
expect(form.$('[role="alert"]')?.textContent).toBe('Fix 4 fields to continue.');
expect(form.$('#name-error')?.textContent).toBe('Enter your name.');
expect(form.$('#email-error')?.textContent).toBe('Enter an email address like ada@example.com.');
expect(form.$('#password-error')?.textContent).toBe('Use at least 8 characters.');
expect(form.$('#consent-error')?.textContent).toBe('Accept the privacy policy to continue.');
expect(form.el.shadowRoot?.activeElement?.id).toBe('name');
await form.type('#name', ' Ada ');
expect(form.$('#name-error')?.textContent).toBe('');
await form.type('#email', 'ada@example.com');
await form.type('#password', 'correct horse');
await form.click('#consent');
expect(form.$('#consent-error')?.textContent).toBe('');
await form.click('button');
expect(form.$('[role="status"]')?.textContent).toBe(
'Welcome, Ada! We’ll write to ada@example.com.',
);
form.dispose();
});To validate on the server with the same rules, use one schema for both sides: Forms.
Troubleshooting
Toggling an item in a filtered list changes the wrong item
What you see. With a filter on, ticking a row ticks a different item, often one that isn't shown.
Why. The control carries the row's position, and the reducer uses it as an index into the whole list:
// Wrong: in the view, `n` is the row's place in the filtered list…
${visible(s.todos, s.filter).map(
(t, n) => html`<input type="checkbox" value=${n} data-intent=${i.Toggle} />`,
)}
// …and the reducer reads it as a place in the whole list.
Toggle: (s, m) => ({
...s,
todos: s.todos.map((t, n) => (n === m.index ? { ...t, done: !t.done } : t)),
}),With "Active" showing, the second row on screen may be the fourth todo, so the reducer changes
s.todos[1] instead. A sort or a search breaks it the same way.
Fix. Carry the id, and find the item by id:
- Put
value=${todo.id}on the control, and parsevalueas an id. - Match by id in the reducer:
t.id === m.id. - Key the list by id:
each(shown, (t) => t.id, Row).
A key alone doesn't fix it. The key keeps each element with its item; the message still has to say which item. The todo list recipe does all three.
Custom error messages never show
What you see. Submitting an empty form shows the browser's bubble ("Please fill out this field"), and your parser never runs.
Why. Without novalidate, the browser checks required, type="email", minlength and
pattern before it fires submit. When one fails, there is no submit event, so there is no
intent.
Fix. Add novalidate to the <form> and keep the constraints on the fields. Read each
field's validity in the submit parser, as the
signup form does.