Views
A view is a function from state to HTML. It receives the state, the typed intent names
i, and the context { props, read }, and returns an html template. It has no other inputs
and no side effects, so the same state always renders the same markup, on the server and in the
browser.
// src/profile.ts
import { define, html } from '@gyral/core';
export interface State {
readonly editing: boolean;
readonly name: string;
}
export type Msg = { readonly _tag: 'Edit' } | { readonly _tag: 'Save'; readonly name: string };
export const Profile = define<State, Msg>()('my-profile', {
init: () => ({ editing: false, name: 'Ada' }),
intent: {
Edit: () => ({ _tag: 'Edit' }),
Save: ({ formData }) => {
const name = formData?.get('name');
return typeof name === 'string' ? { _tag: 'Save', name } : undefined;
},
},
update: {
Edit: (s) => ({ ...s, editing: true }),
Save: (_s, m) => ({ editing: false, name: m.name }),
},
view: (s, i) => html`
<h2>${s.name}</h2>
${
s.editing &&
html`<form data-intent=${i.Save}>
<label for="name">Name</label>
<input id="name" name="name" autocomplete="name" value=${s.name} required />
<button>Save</button>
</form>`
}
<button type="button" data-intent=${i.Edit} ?hidden=${s.editing}>Edit name</button>
`,
});Templates
Everything a view needs comes from @gyral/core: html, svg, css, nothing, each,
keyed, raw and defineHook. Gyral renders with its own small view layer, built for this one job: no virtual
DOM, no runtime dependencies. A template is prepared once per call site. After that a render
only compares each ${…} with the value it wrote last time and touches the DOM where they
differ.
| Binding | Sets |
|---|---|
${value} |
Text content (escaped), a nested template, a list, or nothing |
attr=${value} |
An attribute; null, undefined and nothing remove it |
class="card ${s.kind}" |
An attribute built from several pieces (quote it) |
?attr=${bool} |
A boolean attribute, present or absent |
.prop=${value} |
A property: data for a child Gyral component, such as objects and arrays |
<input ${hook(…)}> |
An element hook on that element |
<textarea>${v}</textarea> |
The textarea's value (see Form state) |
Boolean attributes (selected, checked, disabled, open, required, hidden's siblings
and the rest of HTML's list) take ?attr=${bool}. Writing selected=${bool} would render
selected="false", which the browser reads as selected, so it's a template error with the fix
in the message.
In a text position, false, null, undefined and nothing render nothing, so
${s.open && html`…`} works. Classes and inline styles are plain strings:
class=${s.done ? 'done' : ''}, style="--w: ${s.width}px" (under a strict Content Security
Policy, see inline styles). Inline <svg>
works inside html, so icons and charts need nothing special; an SVG fragment that is a
template of its own uses svg. Property bindings carry data, never
functions: events are intents, and behaviour on an element is a
hook.
Whitespace is normalized once per template, the same way on the server and in the browser:
indentation between block-level tags disappears, and a run of whitespace between inline
elements becomes one space. <pre> and <textarea> keep theirs. Exact whitespace belongs in
<pre> or in a value.
SVG fragments
Write a whole graphic, <svg> included, in html. When part of it is a template of its own, a
mark shown only in some states or one shape per list item, write that part with svg:
// src/status-strip.ts
import { define, each, html, nothing, svg } from '@gyral/core';
export interface Service {
readonly id: number;
readonly status: 'up' | 'down' | undefined;
readonly name: string;
}
// Fragments are plain functions that return svg templates.
const mark = (status: 'up' | 'down') =>
status === 'up'
? svg`<circle cx="5" cy="5" r="3" />`
: svg`<rect x="2" y="2" width="6" height="6" />`;
// A list row: it reads only its service, so it stays pure.
const tile = (service: Service) => svg`<g transform="translate(${(service.id - 1) * 12} 0)">
${service.status === undefined ? nothing : mark(service.status)}
<text x="1" y="13">${service.name}</text>
</g>`;
export const StatusStrip = define<{ readonly services: readonly Service[] }, never>()(
'my-status-strip',
{
init: () => ({ services: [{ id: 1, status: 'up', name: 'API' }] }),
intent: {},
update: {},
view: (s) =>
html`<svg viewBox="0 0 ${s.services.length * 12} 14" role="img" aria-label="Service status">
${each(s.services, (service) => service.id, tile)}
</svg>`,
},
);An svg template is html for SVG content:
- Its top level is SVG, as if it stood inside an
<svg>:<path />and<g>are SVG elements, self-closing tags are fine, and camelCase names such asclipPathandviewBoxkeep their case. - It renders only inside SVG content: in a hole of an
<svg>or of another SVG element. At a view's root or inside an HTML element, the browser's parser would build HTML elements from the server's markup, so it is an error in development, on the server and in the browser. - HTML at its top level is a template error. HTML inside a graphic goes in a
<foreignObject>, written withhtml. - Bind
href=${…}, notxlink:href=${…}: SVG 2's plainhrefis the one a binding can set. Staticxlink:href="#a"is fine.
Only apps that use svg carry its code, about 0.2 KB minified.
No event handlers
There is no @click=${…} binding, and a template that tries one doesn't compile. Views name an
intent with data-intent=${i.Edit} and let the intent layer read the event.
Everything a view needs to express, such as "this button means Edit, for item 42", fits in
markup: the intent name and the button's value.
Checked before it runs
Templates are static, so their mistakes can be found before anyone loads the page. One set of rules runs in three places, with the same messages:
vite build, throughgyralVitePreset(): every template is checked and precompiled, and a mistake fails the build with a code frame. Precompiled templates also leave the code that prepares templates at runtime out of your bundle. With the optionalparse5package installed, the compiler also compares each template with what a full HTML parser builds.- The development runtime (the dev server and tests) checks each template the first time it
renders and throws with the same message (a production bundle built without the preset
leaves out one check, rule 15 on component tag names, to stay small), naming where the template is written:
at src/cart.ts:12:5. Under Vite the preset gives the exact file, line and column; elsewhere they come from the stack trace. Hydration mismatches name it too. - ESLint, in your editor, with
@gyral/core/eslint:
// eslint.config.js
import gyral from '@gyral/core/eslint';
export default [
// …your other configs (typescript-eslint, …)
{ files: ['src/**/*.ts'], ...gyral.configs.recommended },
];The rules catch what would otherwise break quietly: an event binding, a .value= on a form
control, a self-closing <my-el /> (HTML ignores the slash, so the element swallows what
follows it), an unquoted class=a${b}, markup the browser would repair (a <tr> directly in a
<table>, a <div> inside a <p>), an HTML element such as <button> inside an <svg> (it
would become an unknown SVG element that renders nothing), a bound xlink:href=${…}, a
${…} inside <script> or <style>, a list row that reads the view's variables, and a JSX-style <SignupForm> where the component's tag <signup-form> belongs. Each
message says what to write instead.
The ESLint config also reports a function written in a hole (${() => …}), which is never
right in a view, and warns, with gyral/unused-intent, about an intent parser that no template
in the module names with data-intent: a renamed intent or dead code. That rule is static, so
intents rendered only in some states count as used, and it skips components whose intent names
may be used in another module.
In development, Gyral also warns once when a property binding gets a function: .onclick=${fn}
on a built-in element (use data-intent), or a function prop on a Gyral component (props are
data, and they travel to the browser in hydration seeds). Other custom elements don't warn,
since a third-party widget's API may take a callback.
Use the right element
Views are where accessibility is decided, and semantic HTML gets you most of it for free:
- A
<button>for actions, an<a href>for navigation, a<form>for anything submitted. - A
<label for>for every control,<output>for computed values,<fieldset>and<legend>for groups. - Landmarks (
<header>,<main>,<nav>,<search>) and a sensible heading order. - Native
<dialog>,popoverand<details>instead of hand-built widgets.
Forms with a real action and method keep working before JavaScript loads. See
Forms.
Lists
Render a list with each(items, key, row, pick?). The key ties each row, and any child
component in it, to its item, so reordering moves elements instead of rewriting them.
A row re-renders only when its item or its pick result changes, and is skipped otherwise.
That makes long lists cheap, and it has one rule: a row reads only its arguments and
module-level values. Name intents with a module-level intentsOf<typeof Component>(), the
same names the view gets as i, and pass anything else from the view, such as the selection,
through pick. Write the row's return type (: TemplateResult) and use the view's own i, so
the row and the component don't infer each other's types:
// src/todos.ts
import { define, each, html, intentsOf, type TemplateResult } from '@gyral/core';
export interface Todo {
readonly id: number;
readonly text: string;
readonly done: boolean;
}
export interface State {
readonly todos: readonly Todo[];
readonly selected: number;
}
export type Msg =
| { readonly _tag: 'Toggle'; readonly id: number }
| { readonly _tag: 'Select'; readonly id: number };
// The intent names as a module constant, so rows can use them and stay pure.
const i = intentsOf<typeof Todos>();
// A row: reads only its item, what `pick` returned, and module constants.
const Row = (todo: Todo, selected: boolean): TemplateResult =>
html`<li class=${selected ? 'selected' : ''}>
<label>
<input type="checkbox" value=${todo.id} ?checked=${todo.done} data-intent=${i.Toggle} />
${todo.text}
</label>
<button type="button" value=${todo.id} data-intent=${i.Select}>Select</button>
</li>`;
const idOf = (value: string | undefined): number | undefined => {
const id = Number(value);
return Number.isInteger(id) ? id : undefined;
};
export const Todos = define<State, Msg>()('my-todos', {
init: () => ({ todos: [{ id: 1, text: 'Write docs', done: false }], selected: 1 }),
intent: {
Toggle: ({ value }) => {
const id = idOf(value);
return id === undefined ? undefined : { _tag: 'Toggle', id };
},
Select: ({ value }) => {
const id = idOf(value);
return id === undefined ? undefined : { _tag: 'Select', id };
},
},
update: {
Toggle: (s, m) => ({
...s,
todos: s.todos.map((t) => (t.id === m.id ? { ...t, done: !t.done } : t)),
}),
Select: (s, m) => ({ ...s, selected: m.id }),
},
view: (s) => html`
<ul aria-label="Todos">
${each(
s.todos,
(todo) => todo.id,
Row,
(todo) => todo.id === s.selected,
)}
</ul>
`,
});When the selection moves, pick runs for every row (one comparison each), and only the two
rows whose answer changed render again. pick results are compared one level deep, so returning
a small tuple or object is fine.
- Keys are unique strings or numbers. A duplicate key is an error in development.
- The ESLint rule
gyral/each-row-puritynames any variable a row reads from the view and tells you to move it intopick. - To get a fresh element when an id changes, use
keyed(s.run.id, Run(s.run)): when the key changes, the old elements (and any component state in them) are discarded and new ones created. To restart a CSS animation, a hook is lighter: see Replaying a CSS animation.
A whole list with add, delete and filters is in the
todo list recipe. Rows carry their item's id
(value=${todo.id}), never their position: with a filter on, a position points at the wrong
item (why).
.map or each?
Plain arrays render too, by position: ${s.tags.map((t) => html`<li>${t}</li>`)}. Choose
by what the list does:
.mapfor short lists whose items don't move: a few options, a breadcrumb, table headers. Its rows may read anything in the view's scope, since every row runs on every render. Keep them cheap, and sort or filter once above the template, not inside the row.eachfor lists that are long, change often or reorder: search results, an inbox, kanban columns. A moved item keeps its element, with its focus, input state and animations, and a row renders again only when its item orpickresult changes. That second point is whyeachrows must be pure, and whygyral/each-row-puritychecks onlyeachrows.
Moving items between lists
A task that moves from one kanban column to another leaves one each list and joins another,
so it is a new element, and the browser can't animate the move by itself. A FLIP hook can
("first, last, invert, play"): it remembers where each key was last seen and, when its element
appears somewhere else, plays the move with the Web Animations API.
// src/board.ts
import { define, defineHook, each, html, intentsOf, type TemplateResult } from '@gyral/core';
/** Where each key was last seen, in page coordinates. */
const seen = new Map<string, { readonly x: number; readonly y: number }>();
/** Animates the element from where `key` was last seen to where it is now. */
export const flip = defineHook<[key: string, slot: number]>({
client: (el, [key]) => {
const box = el.getBoundingClientRect();
const now = { x: box.left + scrollX, y: box.top + scrollY };
const before = seen.get(key);
seen.set(key, now);
if (before === undefined || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const dx = before.x - now.x;
const dy = before.y - now.y;
if (dx === 0 && dy === 0) return;
el.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], {
duration: 200,
easing: 'ease-out',
});
},
});
export interface Task {
readonly id: string;
readonly label: string;
}
export interface State {
readonly todo: readonly Task[];
readonly done: readonly Task[];
}
export type Msg = { readonly _tag: 'Move'; readonly id: string };
const i = intentsOf<typeof Board>();
const TaskCard = (t: Task, slot: number): TemplateResult =>
html`<li ${flip(t.id, slot)}>
<button type="button" value=${t.id} data-intent=${i.Move}>${t.label}</button>
</li>`;
const column = (label: string, tasks: readonly Task[]) =>
html`<section>
<h2>${label}</h2>
<ul>
${each(
tasks,
(t) => t.id,
TaskCard,
(t) => tasks.indexOf(t),
)}
</ul>
</section>`;
export const Board = define<State, Msg>()('my-board', {
init: () => ({
todo: [
{ id: 'a', label: 'Write the release notes' },
{ id: 'b', label: 'Update the screenshots' },
],
done: [],
}),
intent: { Move: ({ value }) => (value ? { _tag: 'Move', id: value } : undefined) },
update: {
Move: (s, { id }) => {
const task = [...s.todo, ...s.done].find((t) => t.id === id);
if (task === undefined) return s;
return s.todo.includes(task)
? { todo: s.todo.filter((t) => t !== task), done: [...s.done, task] }
: { done: s.done.filter((t) => t !== task), todo: [...s.todo, task] };
},
},
view: (s) => html`${column('To do', s.todo)} ${column('Done', s.done)}`,
});- The hook gets the item's slot (
pickreturns its index), so it also runs when a task shifts inside its column. A hook runs only when its arguments change: for the new element in the other column, and for tasks whose slot changed. Tasks that move because the layout changed around them, after a resize, aren't animated. seenkeeps the keys of deleted tasks. Clear it when the board loads new data.- A View Transition doesn't replace the hook here. No browser captures a
view-transition-nameset inside a shadow root, and components use shadow DOM by default, so rows named that way don't morph.viewTransitionwith a per-rowview-transition-nameworks only in ashadow: falsecomponent. In shadow DOM, the FLIP hook above is the way to animate moves between lists.
Form state
Each piece of form state has one spelling, and it means the same on the server and in the browser:
| Control | Write |
|---|---|
Text-like <input> |
value=${s.text} |
| Checkbox or radio | ?checked=${s.on} |
| Indeterminate checkbox | ?indeterminate=${s.some} |
<option> |
?selected=${s.size === 'm'} |
<details>, <dialog> |
?open=${s.open} |
<textarea> |
<textarea name="note">${s.note}</textarea> |
A binding writes the control only when the model's value for it changes. Then the model wins, even after the user has edited the control (the view compares with what the control shows now and writes only if they differ). A render for any other reason, such as another field's message or a reducer that refused the edit, leaves what the user typed or toggled alone. On the server the bindings write the attribute, so the page is right before JavaScript loads, and hydration keeps what the user typed before scripts ran until the model's value changes.
To put a control back after a refused edit, change the model: clamp or normalize to a value
that differs from the one rendered last, or re-create the form with a key. Keep a counter in
the state, bump it on reset, and render keyed(s.formKey, fields(s)): the new key brings fresh
elements with the model's values. form.reset() is no substitute; it restores the first values, not the model's.
Never bind form state as a property (.value=, .checked=): the server can't write a
property, so the page would arrive empty. The compiler rejects it. A checkbox's value and a
<button value> are submitted values, not state, so they're plain attributes.
Dialogs and popovers
A modal dialog opens with a method call, not an attribute, so Gyral gives it a binding of its
own. ?modal=${s.open} on a <dialog> calls showModal() when the value turns true and
close() when it turns false; ?popover-open=${b} does the same for an element with a
popover attribute (showPopover(), hidePopover()). The browser returns focus to the opener
when a modal closes. Escape and form method="dialog" close the dialog on their own, so listen
for that with an intent and keep the model in step:
Native opening runs after the element is inserted and its children render, before focus
commands. Conditional views, keyed replacements and initially open components follow the
same order. An opening action is cancelled if its element is removed before commit. If native
opening triggers a focus intent, Gyral renders that intent's new state before running focus
commands or resolving settled().
// src/confirm-delete.ts
import { define, html } from '@gyral/core';
export type Msg = { readonly _tag: 'Ask' } | { readonly _tag: 'Closed' };
export const ConfirmDelete = define<{ readonly open: boolean }, Msg>()('my-confirm-delete', {
init: () => ({ open: false }),
intent: { Ask: true, Closed: true },
update: {
Ask: () => ({ open: true }),
Closed: () => ({ open: false }),
},
view: (s, i) => html`
<button type="button" data-intent=${i.Ask}>Delete…</button>
<dialog ?modal=${s.open} data-intent=${i.Closed} data-intent-on="close">
<form method="dialog">
<p>Delete this task?</p>
<button value="cancel">Cancel</button>
<button value="delete">Delete</button>
</form>
</dialog>
`,
});On the server neither binding writes anything; a dialog that should start open opens when the
component hydrates. Use ?open only for a non-modal <dialog> or <details>.
Element hooks
A hook is a small behaviour attached to the element it sits on, written inside the start tag.
Core ships one, invalid(errors), which mirrors model errors into native validity
(setCustomValidity and aria-invalid); see Forms. Others are a few lines
each, such as capturing a pointer for press-and-hold.
Write your own with defineHook. client(el, args, prev) runs after the render whenever the
arguments change (prev is undefined the first time). An optional server(args) returns
attributes for the server-rendered start tag, so the page is right before scripts run. A hook
acts only on its own element, and listeners it adds there go away with the element, so most
hooks need no teardown:
// src/steps.ts
import { define, defineHook, html } from '@gyral/core';
/** Marks the current step, and scrolls it into view when it becomes current. */
export const currentStep = defineHook<[active: boolean]>({
// The server writes the attribute into the start tag, so it's there before scripts run.
server: ([active]) => (active ? { 'aria-current': 'step' } : {}),
client: (el, [active], prev) => {
if (active) el.setAttribute('aria-current', 'step');
else el.removeAttribute('aria-current');
if (active && prev !== undefined && !prev[0]) el.scrollIntoView({ block: 'nearest' });
},
});
export interface State {
readonly current: number;
}
export type Msg = { readonly _tag: 'Next' };
export const Steps = define<State, Msg>()('my-steps', {
init: () => ({ current: 0 }),
intent: { Next: () => ({ _tag: 'Next' }) },
update: { Next: (s) => ({ current: (s.current + 1) % 3 }) },
view: (s, i) => html`
<ol>
<li ${currentStep(s.current === 0)}>Choose</li>
<li ${currentStep(s.current === 1)}>Pay</li>
<li ${currentStep(s.current === 2)}>Done</li>
</ol>
<button type="button" data-intent=${i.Next}>Next step</button>
`,
});Hooks run after the whole render, children before parents, so a parent's hook can measure the
children that just rendered. A hook runs again when its arguments change; pass
update: 'always' to defineHook for one that runs after every render of its component, such
as a hook that keeps a scroll position anchored while a list grows.
Custom properties
To hand a value to CSS, bind a custom property in a style attribute and let the stylesheet
use it, with a fallback:
// src/upload-progress.ts
import { css, define, html, prop, type Stateless } from '@gyral/core';
export interface Props {
readonly sent: number;
readonly total: number;
}
export const UploadProgress = define<Stateless, never, Props>()('my-upload-progress', {
props: { sent: prop.number({ default: 0 }), total: prop.number({ default: 1 }) },
intent: {},
update: {},
view: (_s, _i, { props }) => html`
<div class="bar" style="--fill: ${props.sent / props.total}"></div>
<p>${props.sent} of ${props.total} files</p>
`,
styles: css`
.bar {
block-size: 0.5rem;
background: linear-gradient(to right, currentColor calc(var(--fill, 0) * 100%), #0000 0);
}
`,
});Gyral writes style bindings through the CSSOM, so they work under a Content Security Policy
without 'unsafe-inline'. In server-rendered markup such a policy blocks the attribute until the
component hydrates, which is why the stylesheet keeps a default, var(--fill, 0). Inline styles
under a strict CSP has the details.
A style binding owns the whole attribute. For the rare element whose inline style a hook
writes too, let a hook set only the properties it names:
// src/css-vars.ts
import { defineHook } from '@gyral/core';
/** Sets the named custom properties, leaving the element's other inline styles alone. */
export const cssVars = defineHook<[vars: Readonly<Record<`--${string}`, string>>]>({
client: (el, [vars]) => {
if (!(el instanceof HTMLElement)) return;
for (const [name, value] of Object.entries(vars)) el.style.setProperty(name, value);
},
});Replaying a CSS animation
A CSS animation runs once, when its element appears. A "flash on change", such as an unread badge that pulses when a message arrives or a field that shakes on a rejected value, has to replay it. Count the replays in state, and restart the element's animations with the Web Animations API when the count changes:
// src/inbox-badge.ts
import { css, define, defineHook, html } from '@gyral/core';
/** Restarts the element's CSS animations whenever `count` changes, not on the first render. */
export const replay = defineHook<[count: number]>({
client: (el, [count], prev) => {
if (prev === undefined || prev[0] === count) return;
for (const animation of el.getAnimations()) {
animation.cancel();
animation.play();
}
},
});
export interface State {
readonly unread: number;
readonly pulses: number;
}
export type Msg = { readonly _tag: 'Arrived' };
export const InboxBadge = define<State, Msg>()('my-inbox-badge', {
init: () => ({ unread: 0, pulses: 0 }),
intent: { Arrived: () => ({ _tag: 'Arrived' }) },
update: { Arrived: (s) => ({ unread: s.unread + 1, pulses: s.pulses + 1 }) },
view: (s, i) => html`
<output class="badge" ${replay(s.pulses)}>${s.unread}</output>
<button type="button" data-intent=${i.Arrived}>Simulate a message</button>
`,
styles: css`
.badge {
animation: pulse 300ms ease-out;
}
@keyframes pulse {
50% {
scale: 1.3;
}
}
@media (prefers-reduced-motion: reduce) {
.badge {
animation: none;
}
}
`,
});getAnimations() covers the CSS animations and transitions running on the element; pass
{ subtree: true } to include its descendants. The count lives in state because the model
decides when something deserves attention, and a count, unlike a boolean, changes every time.
Without a hook, keyed(s.pulses, Badge(s)) replays by replacing the element, at the cost of a
new node (and its focus, if it had any).
Widgets with a lifecycle
Something with setup and teardown, such as a WebGL stage, a chart or map library, an observer or
a connection, has a native home: a custom element of its own. Give it one property for its
input, keep its state inside, set up in connectedCallback and tear down in
disconnectedCallback. The browser then tells it about every connect, disconnect and move, the
view stays a description of data, and the widget can be tested on its own:
// src/scene-editor.ts
import { define, html } from '@gyral/core';
interface Scene {
readonly cubes: number;
}
/** The widget: one property in, its own lifecycle. */
class StageElement extends HTMLElement {
#scene: Scene = { cubes: 0 };
#frame = 0;
set view(scene: Scene) {
this.#scene = scene;
this.#schedule();
}
connectedCallback(): void {
this.#schedule(); // set up the renderer, canvas and observers here
}
disconnectedCallback(): void {
cancelAnimationFrame(this.#frame); // dispose the renderer and listeners here
}
#schedule(): void {
cancelAnimationFrame(this.#frame);
this.#frame = requestAnimationFrame(() => {
this.textContent = `${String(this.#scene.cubes)} cubes`;
});
}
}
customElements.define('my-stage', StageElement);
interface State {
readonly scene: Scene;
}
type Msg = { readonly _tag: 'Add' };
export const SceneEditor = define<State, Msg>()('my-scene-editor', {
init: () => ({ scene: { cubes: 1 } }),
intent: { Add: () => ({ _tag: 'Add' }) },
update: { Add: (s) => ({ scene: { cubes: s.scene.cubes + 1 } }) },
view: (s, i) => html`
<my-stage .view=${s.scene}></my-stage>
<button type="button" data-intent=${i.Add}>Add a cube</button>
`,
});The widget reports back with events: it dispatches
new CustomEvent(OUTPUT_EVENT, { detail: { _tag: 'Picked' }, bubbles: true }) on itself, and the
parent parses that like a child component's output.
When the widget has a model of its own, make it a Gyral component (prop.value for its input,
outputs<Out>() for its events).
For a small imperative behaviour on an element of the view, such as a timer, a
ResizeObserver or a third-party enhancer on one input, a hook with a teardown is lighter:
defineHook takes dispose(el, args) next to client.
// src/flash.ts
import { defineHook } from '@gyral/core';
const timers = new WeakMap<Element, ReturnType<typeof setTimeout>>();
/** Highlights the element for a moment whenever `value` changes. */
export const flash = defineHook<[value: unknown]>({
client: (el, _args, prev) => {
if (prev === undefined) return; // not on the first render
el.classList.add('flash');
clearTimeout(timers.get(el));
timers.set(
el,
setTimeout(() => {
el.classList.remove('flash');
}, 600),
);
},
dispose: (el) => {
clearTimeout(timers.get(el));
},
});disposeruns when Gyral removes the element (its part cleared, its template replaced, its list row removed), when the position stops holding the hook, and when the component disconnects. It never runs for moves: rows reordered in a list, or a component moved withmoveBefore().- After a component disconnects and reconnects,
clientruns again withprevundefined. - The teardown tracking is bundled only when some hook has a
dispose, so apps whose hooks need none don't pay for it.
Trusted markup with raw()
raw(markup) renders a string as HTML: Markdown you converted at build time, highlighted code,
a JSON-LD script. Use it only for markup your own code produced, never for user input. It
belongs mostly on the server: in a component that renders in the browser, every change parses
the string again, and development mode warns about it. This site renders every docs page with
raw() inside a server-only template.
Focus is a command
Moving focus is a side effect, so it comes from update, not the view. focus(selector)
focuses the first match in the component once the update has rendered, so it can target an
element the same update creates:
// src/pager.ts
import { define, focus, html } from '@gyral/core';
export interface State {
readonly page: number;
}
export type Msg = { readonly _tag: 'Next' };
export const Pager = define<State, Msg>()('my-pager', {
init: () => ({ page: 1 }),
intent: { Next: () => ({ _tag: 'Next' }) },
update: {
// Move focus to the new page's heading, as a screen reader user would expect.
Next: (s) => [{ page: s.page + 1 }, [focus('h2', { preventScroll: true })]],
},
view: (s, i) => html`
<h2 tabindex="-1">Page ${s.page}</h2>
<button type="button" data-intent=${i.Next}>Next page</button>
`,
});Headings aren't focusable by default, hence tabindex="-1".
Focusing what a later render brings
focus() runs after the render its update caused. When the target only appears later, such as
the first result once a search answers, pass wait: true: the request stays pending until a
render of the component produces the target. A newer focus() from the component replaces it,
and after one second it gives up with the usual warning. settled() doesn't wait for it.
// src/result-search.ts
import { command, define, defineDriver, focus, html } from '@gyral/core';
interface Result {
readonly id: string;
readonly title: string;
}
export interface State {
readonly query: string;
readonly results: readonly Result[];
}
export type Msg =
| { readonly _tag: 'Search'; readonly query: string }
| { readonly _tag: 'Found'; readonly results: readonly Result[] };
const search = defineDriver<string, readonly Result[]>({
name: 'search',
run: async (query, { signal }) => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal });
return (await response.json()) as readonly Result[];
},
});
export const ResultSearch = define<State, Msg>()('my-result-search', {
init: () => ({ query: '', results: [] }),
intent: {
Search: ({ formData }) => ({ _tag: 'Search', query: String(formData?.get('q') ?? '') }),
},
update: {
// The first result exists only after `Found` renders: the focus waits for it.
Search: (s, m) => [
{ ...s, query: m.query },
[
command(search, m.query, { onSuccess: (results): Msg => ({ _tag: 'Found', results }) }),
focus('#results li:first-child a', { wait: true }),
],
],
Found: (s, m) => ({ ...s, results: m.results }),
},
view: (s, i) => html`
<form data-intent=${i.Search}>
<input name="q" aria-label="Search" value=${s.query} />
<button>Search</button>
</form>
<ul id="results">
${s.results.map((r) => html`<li><a href=${`/items/${r.id}`}>${r.title}</a></li>`)}
</ul>
`,
});Focusing into a child component
focus() looks inside the component's own root, so it can't reach an element in a child's
shadow root. Give the child shadow: { delegatesFocus: true } and focus the child itself:
focusing the host then focuses its first focusable element.
// src/name-field.ts
import { define, html, type Stateless } from '@gyral/core';
/** Focusing <my-name-field> focuses its input. */
export const NameField = define<Stateless, never>()('my-name-field', {
shadow: { delegatesFocus: true },
intent: {},
update: {},
view: () => html`<label>Name <input name="name" autocomplete="name" /></label>`,
});// src/contact-form.ts
import { define, focus, html, type Stateless } from '@gyral/core';
import './name-field.js';
export type Msg = { readonly _tag: 'Edit' };
export const ContactForm = define<Stateless, Msg>()('my-contact-form', {
intent: { Edit: () => ({ _tag: 'Edit' }) },
update: { Edit: (s) => [s, [focus('my-name-field')]] },
view: (_s, i) => html`
<button type="button" data-intent=${i.Edit}>Edit name</button>
<my-name-field></my-name-field>
`,
});The same delegation works for el.focus() from page script and for a click on a part of the
child that can't take focus, and :focus matches the host while focus is inside it. The server
writes shadowrootdelegatesfocus on the declarative shadow root, so a server-rendered child
delegates focus before and after hydration.
Keeping focus across renders
Focus stays only as long as the focused element does. When a render removes it, the browser
moves focus to the page body. Render focusable rows with a keyed each, so a row moves instead
of being created again, and when the focused item really goes away, such as a deleted row,
return a focus() command for its neighbour or for the list.
Calling a method on an element
Some elements are driven by methods: a <video> plays, a third-party chart resets, a canvas
widget takes a command. call(selector, method, ...args) from @gyral/core is a command like
focus(): after the render, it finds the element in the component's own tree and calls the
method. Arguments must be plain data (they're checked in development), so the command stays a
value you can test with callsIn(commands) from @gyral/testing:
// src/clip.ts
import { call, define, html } from '@gyral/core';
export type Msg = { readonly _tag: 'Replay' };
export const Clip = define<object, Msg>()('my-clip', {
init: () => ({}),
intent: { Replay: true },
update: { Replay: (s) => [s, [call('video', 'play')]] },
view: (_s, i) => html`
<video src="/intro.webm" muted playsinline></video>
<button type="button" data-intent=${i.Replay}>Replay</button>
`,
});A missing element or method, or a method that throws, is reported through the error channel.
View transitions
Return true from the optional viewTransition(prev, next, msg) spec field to render that
change inside a View Transition:
a route change, a list that reorders. Gyral skips it where the browser has no support or the
visitor asked for reduced motion, and the animation itself is CSS. Browsers don't capture a
view-transition-name set inside a shadow root, so naming single items (rows that should morph
to their new place) needs a shadow: false component; the host element itself can carry a name.
When the DOM updates
Reducers run as soon as a message arrives; the DOM updates in a microtask, once for all the
messages that arrived together, parents before children. Element hooks then run, children
before parents. In a test, await settled() waits
until every component has rendered and messages have stopped arriving. See
Testing.
Pure means repeatable
Because a view only reads its arguments, the server can render it, the browser can hydrate the same markup without changes, and a test can render a state and inspect it. If a view needs the time, a random number or a measurement, put it in state through a command first.