# Playground

Edit a zerolith app in the browser and run it with one key. The code runs in a sandboxed frame with the development builds of `zerolith`, `@zerolith/router`, `@zerolith/forms`, `@zerolith/a11y`, and `@zerolith/i18n`, so diagnostics carry their full text. A view renders into `<main id="app">`, and `console` output shows under the preview. Copy link puts the code in the address, so a link opens with your code in the editor.

## Examples

### Launch control

A small simulator: signals, an effect that runs a clock while it is needed, `batch`, a keyed log, scoped styles, unit formatting, and announcements.

```js
/*
 * Launch control: a countdown, a climb to orbit, and a flight log, all
 * from a few signals. Every readout is a computed value, an effect runs
 * the clock only while the rocket is live, and on each tick only the
 * text that changed is touched.
 *
 * Try: press Launch, then drag the throttle during the climb. Full
 * throttle climbs fastest but burns the fuel before orbit; find the
 * setting that makes it. The console under the preview logs each stage.
 */
import {
    batch, computed, css, each, effect, html, render, signal
} from "zerolith";
import { createAnnouncer } from "@zerolith/a11y";
import { formatters } from "@zerolith/i18n";

const ORBIT = 300;
const format = formatters(navigator.language);
const announcer = createAnnouncer();

const stage = signal("ready");
const countdown = signal(5);
const altitude = signal(0);
const fuel = signal(100);
const throttle = signal(80);
const log = signal([]);

const live = computed(() =>
    stage.get() === "countdown" || stage.get() === "flight");
const ready = computed(() => stage.get() === "ready");
const idle = computed(() => !live.get());

const STAGES = {
    ready: "On the pad",
    countdown: "Counting down",
    flight: "Climbing",
    orbit: "In orbit",
    aborted: "Aborted"
};

const panel = css`
    :scope {
        display: grid;
        gap: 16px;
        max-width: 560px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    .readout {
        margin: 0;
        font: 600 clamp(44px, 12vw, 72px)/1 var(--font-display);
        letter-spacing: -0.04em;
    }
    .stage {
        margin: 0;
        color: var(--muted);
        font-family: var(--font-mono);
        font-size: 12px;
        letter-spacing: 0.06em;
        text-transform: uppercase;
    }
    :scope[data-stage="orbit"] .readout { color: var(--accent-text); }
    :scope[data-stage="aborted"] .readout { color: var(--danger); }
    label {
        display: grid;
        gap: 8px;
        color: var(--muted);
        font: 12px var(--font-mono);
        letter-spacing: 0.06em;
        text-transform: uppercase;
    }
    .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: end; }
    .controls label { flex: 1 1 160px; }
    input[type="range"] { width: 100%; }
    ol { margin: 0; padding: 0; list-style: none; }
    li {
        padding: 8px 0;
        border-top: 2px solid var(--line);
        font-family: var(--font-mono);
        font-size: 13px;
    }
`;

/** Writes a line to the flight log, the console, and screen readers. */
function note(text)
{
    log.set([{ id: log.get().length + 1, text }, ...log.get()]);
    console.log(text);
    announcer.announce(text);
}

/** Moves the countdown on by a tenth of a second. */
function count()
{
    countdown.set(Math.max(0, countdown.get() - 0.1));
    if (countdown.get() === 0)
    {
        stage.set("flight");
        note("Liftoff");
    }
}

/** Moves the climb on by a tenth of a second, burning fuel. */
function climb()
{
    const power = throttle.get() / 100;
    altitude.set(Math.min(ORBIT, altitude.get() + power * 6));
    fuel.set(Math.max(0, fuel.get() - power * power * 2.2));
    if (altitude.get() === ORBIT)
    {
        stage.set("orbit");
        const left = format.number(fuel.get() / 100, { style: "percent" });
        note(`Orbit, with ${left} fuel left`);
    }
    else if (fuel.get() === 0)
    {
        stage.set("aborted");
        note(`Out of fuel at ${format.number(Math.round(altitude.get()))} km`);
    }
}

effect(() =>
{
    if (!live.get())
    {
        return undefined;
    }
    const clock = setInterval(
        () => (stage.get() === "countdown" ? count() : climb()), 100);
    return () => clearInterval(clock);
});

function launch()
{
    stage.set("countdown");
    note("Countdown started");
}

function abort()
{
    stage.set("aborted");
    note("Aborted by launch control");
}

function reset()
{
    batch(() =>
    {
        countdown.set(5);
        altitude.set(0);
        fuel.set(100);
        log.set([]);
        stage.set("ready");
    });
}

const tenths = { minimumFractionDigits: 1, maximumFractionDigits: 1 };
const km = { style: "unit", unit: "kilometer", maximumFractionDigits: 0 };

const readout = computed(() =>
{
    if (stage.get() === "ready")
    {
        return "Ready";
    }
    if (stage.get() === "countdown")
    {
        return `T-${format.number(countdown.get(), tenths)}`;
    }
    return format.number(altitude.get(), km);
});

/** A button that is enabled only while the `enabled` signal is true. */
function Control(label, enabled, action, kind = "")
{
    return html`
        <button class=${kind} ?disabled=${computed(() => !enabled.get())}
            @click=${action}>${label}</button>`;
}

const steer = (event) => throttle.set(Number(event.target.value));

render(html`
    <section class=${panel} data-stage=${stage}>
        <p class="readout">${readout}</p>
        <p class="stage">${computed(() => STAGES[stage.get()])}</p>
        <label>Altitude
            <progress max=${ORBIT} value=${altitude}></progress>
        </label>
        <label>Fuel
            <progress max="100" value=${fuel}></progress>
        </label>
        <div class="controls">
            ${Control("Launch", ready, launch, "primary")}
            ${Control("Abort", live, abort)}
            ${Control("Reset", idle, reset)}
            <label>Throttle ${throttle}%
                <input type="range" min="40" max="100"
                    .value=${throttle} @input=${steer}>
            </label>
        </div>
        <ol>${each(log, (entry) => entry.id, (entry) => html`
            <li>${computed(() => entry.get().text)}</li>`)}</ol>
    </section>`, "#app");
```

### Counter

A signal, a derived value, an effect, and an event handler.

```js
/*
 * Counter: a signal, a value derived from it, an effect, and event
 * handlers. The template binds the signals themselves, so a click
 * updates two text nodes and nothing else; Counter does not run again.
 *
 * Try: click Add one and watch the console under the preview, where the
 * effect prints every new count. Then make doubled triple the count and
 * press Run.
 */
import { computed, css, effect, html, render, signal } from "zerolith";

const count = signal(0);
const doubled = computed(() => count.get() * 2);

effect(() => console.log("count is", count.get()));

const panel = css`
    :scope {
        display: grid;
        gap: 24px;
        max-width: 360px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    output {
        font: 600 96px/1 var(--font-display);
        letter-spacing: -0.04em;
    }
    p { margin: 0; color: var(--muted); }
    .actions { display: flex; flex-wrap: wrap; gap: 8px; }
`;

function Counter()
{
    const add = () => count.set(count.get() + 1);
    const reset = () => count.set(0);
    return html`
        <section class=${panel} aria-label="Counter">
            <output>${count}</output>
            <p>Doubled, a computed value: ${doubled}</p>
            <div class="actions">
                <button class="primary" @click=${add}>Add one</button>
                <button @click=${reset}>Reset</button>
            </div>
        </section>`;
}

render(() => Counter(), "#app");
```

### Packing list

A keyed list with `each`, a form that adds to it, and counts derived from the list.

```js
/*
 * Packing list: a keyed list with each, a form that adds to it, and a
 * count derived from the list. A row keeps its element while its key
 * stays, so checking an item touches only that row and the count.
 *
 * Try: check items off and add one with the form. Then add a fourth
 * starting item to the list in the code and press Run.
 */
import { computed, css, each, html, render, signal } from "zerolith";

const items = signal([
    { id: 1, name: "Passport", packed: true },
    { id: 2, name: "Charger", packed: false },
    { id: 3, name: "Rain jacket", packed: false }
]);
const packed = computed(() =>
    items.get().filter((item) => item.packed).length);
const total = computed(() => items.get().length);

const panel = css`
    :scope {
        display: grid;
        gap: 16px;
        max-width: 420px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    h1 { font-size: 32px; }
    .count { margin: 0; color: var(--muted); font-family: var(--font-mono); }
    ul { margin: 0; border-top: 2px solid var(--line); }
    li { padding: 12px 0; border-bottom: 2px solid var(--line); }
    li label { display: flex; }
    li:has(:checked) { color: var(--muted); text-decoration: line-through; }
    form { display: flex; gap: 8px; }
    input[name="name"] { flex: 1; min-width: 0; }
`;

function toggle(id)
{
    items.set(items.get().map((item) =>
        item.id === id ? { ...item, packed: !item.packed } : item));
}

function add(event)
{
    event.preventDefault();
    const field = event.currentTarget.elements.namedItem("name");
    const name = field.value.trim();
    if (name !== "")
    {
        const id = Math.max(0, ...items.get().map((item) => item.id)) + 1;
        items.set([...items.get(), { id, name, packed: false }]);
        field.value = "";
    }
}

function Item(item)
{
    const { id } = item.get();
    return html`
        <li>
            <label>
                <input type="checkbox"
                    .checked=${computed(() => item.get().packed)}
                    @change=${() => toggle(id)}>
                ${computed(() => item.get().name)}
            </label>
        </li>`;
}

render(html`
    <section class=${panel}>
        <h1>Packing list</h1>
        <p class="count">${packed} of ${total} packed</p>
        <progress max=${total} value=${packed}></progress>
        <ul>${each(items, (item) => item.id, Item)}</ul>
        <form @submit=${add}>
            <input name="name" aria-label="Item" placeholder="Add an item"
                autocomplete="off">
            <button>Add</button>
        </form>
    </section>`, "#app");
```

### Conditions

Showing one view or another with `when`, and a branch that cleans up after itself with `onDispose`.

```js
/*
 * Conditions: when shows one view or another as a signal changes. A
 * branch is built the first time it shows and taken down when it hides,
 * which stops whatever it started, such as the timer in Welcome.
 *
 * Try: sign in, wait a few seconds, sign out, and sign in again: the
 * timer starts over, because the branch is built anew each time.
 */
import { css, html, onDispose, render, signal, when } from "zerolith";

const user = signal(null);

const panel = css`
    :scope {
        display: grid;
        gap: 16px;
        justify-items: start;
        max-width: 400px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    h2 { font-size: 28px; }
    p { margin: 0; color: var(--muted); }
    .who { display: flex; align-items: center; gap: 16px; }
    .initials {
        display: grid;
        place-items: center;
        width: 56px;
        height: 56px;
        background: var(--text);
        color: var(--bg);
        font: 600 20px var(--font-display);
    }
    .time { color: var(--text); font-family: var(--font-mono); }
`;

function SignIn()
{
    return html`
        <h2>Signed out</h2>
        <p>Signing in starts a session timer; signing out stops it.</p>
        <button class="primary" @click=${() => user.set("Ada Lovelace")}>
            Sign in as Ada
        </button>`;
}

function Welcome()
{
    const seconds = signal(0);
    const timer = setInterval(() => seconds.set(seconds.get() + 1), 1000);
    onDispose(() => clearInterval(timer));
    return html`
        <div class="who">
            <span class="initials" aria-hidden="true">AL</span>
            <h2>${user}</h2>
        </div>
        <p>Signed in for <span class="time">${seconds} s</span>.</p>
        <button @click=${() => user.set(null)}>Sign out</button>`;
}

render(html`
    <section class=${panel}>${when(user, Welcome, SignIn)}</section>`,
    "#app");
```

### Async data

A `resource` with loading, error, and reload, keeping its last value while the next one loads.

```js
/*
 * Async data: a resource loads from a signal and loads again when the
 * signal changes, aborting the request it replaces. Its status, its
 * error, and its last value are signals too, and the last value stays
 * on screen, dimmed, while the next one loads.
 *
 * Try: pick another city, then Atlantis, which fails, and Try again.
 */
import { computed, css, html, render, resource, signal, when } from "zerolith";

/** A stand-in for fetch: the preview has no backend, so this waits. */
function fetchWeather(city, abort)
{
    return new Promise((resolve, reject) =>
    {
        const timer = setTimeout(() =>
        {
            if (city === "Atlantis")
            {
                reject(new Error("No station in Atlantis."));
            }
            else
            {
                resolve({ city, celsius: 12 + city.length });
            }
        }, 600);
        abort.addEventListener("abort", () => clearTimeout(timer));
    });
}

const city = signal("Lisbon");
const weather = resource({
    params: () => city.get(),
    loader: ({ params, abort }) => fetchWeather(params, abort)
});

const loading = computed(() => weather.status.get() === "pending");
const failed = computed(() => weather.status.get() === "error");
const reason = computed(() => weather.error.get()?.message ?? "");
const report = computed(() =>
{
    const value = weather.get();
    return value === undefined ? "" : `${value.city} ${value.celsius} °C`;
});

const panel = css`
    :scope {
        display: grid;
        gap: 20px;
        max-width: 400px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    label { display: grid; gap: 8px; justify-items: start; }
    label span {
        color: var(--muted);
        font: 12px var(--font-mono);
        letter-spacing: 0.06em;
        text-transform: uppercase;
    }
    .reading {
        min-height: 1.1em;
        margin: 0;
        font: 600 40px/1.1 var(--font-display);
        letter-spacing: -0.03em;
    }
    .reading[aria-busy="true"] { opacity: 0.4; }
    .status { margin: 0; color: var(--muted); font-family: var(--font-mono); }
    [role="alert"] {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 12px;
        margin: 0;
        color: var(--danger);
    }
`;

function Report()
{
    return html`<p class="reading" aria-busy=${loading}>${report}</p>`;
}

function Failure()
{
    return html`
        <p role="alert">${reason}
            <button @click=${() => weather.reload()}>Try again</button>
        </p>`;
}

const choose = (event) => city.set(event.target.value);

render(html`
    <section class=${panel}>
        <label>
            <span>City</span>
            <select @change=${choose}>
                <option>Lisbon</option>
                <option>Reykjavik</option>
                <option>Atlantis</option>
            </select>
        </label>
        ${when(failed, Failure, Report)}
        <p class="status">status: ${weather.status}</p>
    </section>`, "#app");
```

### Forms

A native form with `@zerolith/forms`: validation in the browser first, then field errors from the server.

```js
/*
 * Forms: a native form sent with @zerolith/forms. The schema runs in the
 * browser before anything is sent, and the server's answer comes back as
 * a problem whose fields land next to the inputs they are about.
 *
 * Try: submit a one-letter name, then "ada", which the stand-in server
 * says is taken, then a name of your own.
 */
import { css, html, render, signal, when } from "zerolith";
import { createForm } from "@zerolith/forms";

/** A Standard Schema written by hand; zod or valibot plug in the same way. */
const schema = {
    "~standard": {
        version: 1,
        vendor: "playground",
        validate(value)
        {
            const name = String(value.name ?? "").trim();
            if (name.length < 2)
            {
                const message = "Use at least two letters.";
                return { issues: [{ message, path: ["name"] }] };
            }
            return { value: { name } };
        }
    }
};

/** A stand-in for the server, which answers as a real API would. */
async function send({ data })
{
    await new Promise((resolve) => setTimeout(resolve, 400));
    const name = String(data.get("name")).trim();
    if (name.toLowerCase() === "ada")
    {
        const problem = { title: "Taken", status: 409, fields: {
            name: ["That name is taken. Try another."]
        } };
        const headers = { "content-type": "application/problem+json" };
        return Response.json(problem, { status: 409, headers });
    }
    return Response.json({ name }, { status: 201 });
}

const welcomed = signal("");
const form = createForm({
    schema,
    send,
    reset: true,
    onSuccess: ({ result }) => welcomed.set(result.name)
});
const name = form.field("name");

const panel = css`
    :scope {
        display: grid;
        gap: 20px;
        max-width: 400px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    h2 { font-size: 28px; }
    form { display: grid; gap: 8px; }
    label { display: grid; gap: 8px; }
    label span {
        color: var(--muted);
        font: 12px var(--font-mono);
        letter-spacing: 0.06em;
        text-transform: uppercase;
    }
    output { min-height: 1.6em; color: var(--danger); font-size: 14px; }
    form button { justify-self: start; }
    .done { margin: 0; color: var(--muted); }
`;

render(html`
    <section class=${panel}>
        <h2>Create an account</h2>
        <form method="post" action="/signup" @submit=${form.submit}>
            <label>
                <span>Username</span>
                <input name="name" autocomplete="off"
                    aria-invalid=${name.invalid}
                    aria-describedby="name-error">
            </label>
            <output id="name-error">${name.error}</output>
            <button class="primary" ?disabled=${form.pending}>Sign up</button>
        </form>
        ${when(welcomed, () => html`
            <p class="done">Welcome aboard, ${welcomed}.</p>`)}
    </section>`, "#app");
```

### Scoped styles

Component styles with the `css` tag, where they stop, and a value set through a custom property.

```js
/*
 * Scoped styles: each css literal styles its own component and stops at
 * the components inside it, with no shadow DOM. A value that changes
 * goes in a custom property, set through a style binding.
 *
 * Try: drag the slider. Note that the card's p rule makes its own
 * paragraph bold but never reaches the paragraph inside the badge.
 */
import { computed, css, html, render, signal } from "zerolith";

const badge = css`
    :scope {
        display: inline-block;
        padding: 6px 10px;
        border: 2px solid var(--control);
    }
    p {
        margin: 0;
        font-family: var(--font-mono);
        font-size: 12px;
        letter-spacing: 0.06em;
        text-transform: uppercase;
    }
`;

const card = css`
    :scope {
        display: grid;
        gap: 12px;
        justify-items: start;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    h2 { font-size: calc(var(--size) * 1px); }
    p { margin: 0; font-weight: 700; }
`;

const page = css`
    :scope { display: grid; gap: 24px; max-width: 420px; }
    label { display: grid; gap: 8px; }
    span {
        color: var(--muted);
        font: 12px var(--font-mono);
        letter-spacing: 0.06em;
        text-transform: uppercase;
    }
    output { color: var(--text); }
`;

const size = signal(32);

function Badge({ text })
{
    return html`<div class=${badge}><p>${text}</p></div>`;
}

function Card()
{
    const style = computed(() => `--size: ${size.get()}`);
    return html`
        <article class=${card} style=${style}>
            <h2>Scoped styles</h2>
            <p>The card's own paragraph is bold.</p>
            ${Badge({ text: "The badge's is not" })}
        </article>`;
}

const resize = (event) => size.set(Number(event.target.value));

render(html`
    <div class=${page}>
        ${Card()}
        <label>
            <span>Heading size <output>${size}px</output></span>
            <input type="range" min="16" max="48" .value=${size}
                @input=${resize}>
        </label>
    </div>`, "#app");
```

### Accessible dialog

A dialog with a focus trap, Escape to close, and an announcement from `@zerolith/a11y`.

```js
/*
 * Accessible dialog: a focus trap keeps the keyboard inside the dialog
 * while it is open and gives focus back when it closes, and the
 * announcer tells screen readers what happened, without moving focus.
 *
 * Try: open the dialog and press Tab a few times, then Escape. Open it
 * again and choose Delete, then restore the note.
 */
import { computed, css, html, render, signal, when } from "zerolith";
import { createAnnouncer, focusTrap } from "@zerolith/a11y";

const open = signal(false);
const deleted = signal(false);
const announcer = createAnnouncer();

const panel = css`
    :scope {
        display: grid;
        gap: 16px;
        justify-items: start;
        max-width: 400px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    h2 { font-size: 28px; }
    p { margin: 0; color: var(--muted); }
    .note[data-deleted="true"] { text-decoration: line-through; }
    .backdrop {
        position: fixed;
        inset: 0;
        display: grid;
        place-items: center;
        padding: 16px;
        background: color-mix(in srgb, var(--bg) 80%, transparent);
    }
    [role="dialog"] {
        display: grid;
        gap: 16px;
        width: min(100%, 400px);
        padding: 32px;
        border: 2px solid var(--danger);
        background: var(--surface);
    }
    .actions { display: flex; flex-wrap: wrap; justify-content: end; gap: 8px; }
    .danger {
        border-color: var(--danger);
        background: var(--danger);
        color: var(--bg);
    }
`;

function close()
{
    open.set(false);
}

function escape(event)
{
    if (event.key === "Escape")
    {
        close();
    }
}

/** Closes the dialog when a click lands on the backdrop, not inside. */
function outside(event)
{
    if (event.target === event.currentTarget)
    {
        close();
    }
}

function remove()
{
    deleted.set(true);
    close();
    announcer.announce("Note deleted");
    console.log("Deleted, and announced to screen readers");
}

/** Opens the dialog, or restores a deleted note without asking. */
function act()
{
    if (deleted.get())
    {
        deleted.set(false);
        announcer.announce("Note restored");
        return;
    }
    open.set(true);
}

function Dialog()
{
    return html`
        <div class="backdrop" @click=${outside}>
            <div role="dialog" aria-modal="true" aria-labelledby="title"
                ref=${focusTrap()} @keydown=${escape}>
                <h2 id="title">Delete the note?</h2>
                <p>Tab stays inside until you choose; Escape cancels.</p>
                <div class="actions">
                    <button @click=${close}>Cancel</button>
                    <button class="danger" @click=${remove}>Delete</button>
                </div>
            </div>
        </div>`;
}

const label = computed(() => (deleted.get() ? "Restore" : "Delete note"));

render(html`
    <section class=${panel}>
        <h2>Groceries</h2>
        <p class="note" data-deleted=${deleted}>
            Oat milk, lemons, and coffee beans.
        </p>
        <button @click=${act}>${label}</button>
        ${when(open, Dialog)}
    </section>`, "#app");
```

### Formatting

Money, dates, relative times, and lists that follow a locale signal, with the currency chosen apart from the locale.

```js
/*
 * Formatting: money, dates, relative times, and lists that follow a
 * locale signal, through the browser's own Intl data. The currency
 * belongs to the amount, so it is chosen apart from the locale: a price
 * in yen stays in yen, and the locale only decides how it is written.
 *
 * Try: switch the locale, then the currency. Note that yen has no cents
 * and that ar-EG writes right to left.
 */
import { computed, css, html, render, signal } from "zerolith";
import { createLocale, formatters } from "@zerolith/i18n";

const locales = ["en-US", "de-DE", "ja-JP", "ar-EG"];
const currencies = ["EUR", "USD", "JPY"];
const locale = createLocale({ supported: locales, initial: "en-US" });
const format = formatters(locale.locale);
const currency = signal("EUR");
const amount = signal(1234.5);
const departure = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000);

const price = computed(() => format.number(amount.get(), {
    style: "currency",
    currency: currency.get()
}));
const date = computed(() => format.date(departure, { dateStyle: "full" }));
const soon = computed(() => format.relative(3, "day", { numeric: "auto" }));
const bags = computed(() => format.list(["Passport", "Charger", "Jacket"]));

const panel = css`
    :scope {
        display: grid;
        gap: 24px;
        max-width: 520px;
        padding: clamp(20px, 5vw, 32px);
        border: 2px solid var(--line);
        background: var(--surface);
    }
    table { border-collapse: collapse; border-top: 2px solid var(--line); }
    th, td {
        padding: 12px 0;
        border-bottom: 2px solid var(--line);
        vertical-align: baseline;
    }
    th, .label {
        color: var(--muted);
        font-family: var(--font-mono);
        font-size: 12px;
        font-weight: 400;
        letter-spacing: 0.06em;
        text-align: start;
        text-transform: uppercase;
    }
    th { width: 112px; padding-inline-end: 16px; }
    td { font-variant-numeric: tabular-nums; }
    fieldset { display: flex; flex-wrap: wrap; padding: 0; border: 0; }
    legend { margin-bottom: 8px; padding: 0; }
    fieldset button { padding: 0 12px; }
    fieldset button + button { margin-inline-start: -2px; }
    label { display: grid; gap: 8px; justify-items: start; }
`;

function Row({ label, value })
{
    return html`<tr><th scope="row">${label}</th><td>${value}</td></tr>`;
}

/** A row of buttons, one pressed: the value `current` holds. */
function Choice({ label, options, current, choose })
{
    return html`
        <fieldset>
            <legend>${label}</legend>
            ${options.map((option) => html`
                <button type="button"
                    aria-pressed=${computed(() => current.get() === option)}
                    @click=${() => choose(option)}>${option}</button>`)}
        </fieldset>`;
}

const type = (event) => amount.set(Number(event.target.value));

render(html`
    <section class=${panel}>
        <table dir=${locale.direction}>
            ${Row({ label: "Price", value: price })}
            ${Row({ label: "Departure", value: date })}
            ${Row({ label: "In", value: soon })}
            ${Row({ label: "Packed", value: bags })}
        </table>
        ${Choice({
            label: "Locale",
            options: locales,
            current: locale.locale,
            choose: (tag) => locale.set(tag)
        })}
        ${Choice({
            label: "Currency",
            options: currencies,
            current: currency,
            choose: (code) => currency.set(code)
        })}
        <label>
            <span class="label">Amount</span>
            <input type="number" step="0.01" .value=${amount} @input=${type}>
        </label>
    </section>`, "#app");
```
