Documentation menu

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 reads value. 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. Row reads only its todo and module constants, such as i = intentsOf<typeof TodoApp>(). Its return type is written out, so Row and TodoApp don't infer each other's types. The view uses its own i.
  • The input is form state. value=${s.draft} follows Typed, and Add sets draft back to '', which empties the input. Without draft in state, the text would stay after Add.
  • Focus is a command. Add and Delete return focus('#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 need each.

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:

  • novalidate on the form, constraints on the fields. required, type="email" and minlength still set each field's validity. novalidate stops the browser from blocking the submit. Without it, a failing constraint shows the browser's bubble and no submit event fires, so your messages never appear.
  • The submit parser reads validity. problemWith(input) turns valueMissing, typeMismatch, tooShort and an unchecked checkbox into your words. Submit checks every field and returns Rejected with all the errors, or SignedUp with clean values. Reading the form is input parsing, the intent layer's job; reducers see only the result.
  • Check what the browser doesn't. required accepts a name made of spaces, so the parser trims first. tooShort is 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 writes aria-invalid and the message, and aria-describedby links the message to its field.
  • Focus goes to the first invalid field, through a focus() command from Rejected, and the role="alert" paragraph announces how many fields to fix.
  • An error clears as soon as the field is fixed. Each field sends Checked on 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. SignedUp carries the name and the email. To send the form to a server, keep the raw FormData in 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 parse value as 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.