# Recipes

> Complete components to copy, with their tests. A todo list with filters, a signup form with native validation, and fixes for common bugs.

Source: https://gyral.dev/docs/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](https://gyral.dev/docs/getting-started/#create-a-project). 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:

```ts
// 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](#toggling-an-item-in-a-filtered-list-changes-the-wrong-item).
- **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:

```ts
// 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](https://gyral.dev/docs/forms/#complete-example-a-validated-form) page starts with this same
component and explains each part; here it comes with its tests:

```ts
// 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](https://gyral.dev/docs/forms/#submitting-to-the-server).

The reducers are tested without a browser:

```ts
// 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:

```ts
// 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](https://gyral.dev/docs/forms/#one-schema-for-both-sides).

## 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:

```ts
// 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](#a-todo-list-with-filters) 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](#a-signup-form-with-native-validation) does.
