` needs no import in React.
`npm run dev`, and the text is on a canvas. [Your first
component](https://gesso-docs.vercel.app/guide/counter) is the next ten minutes.
---
# Your first component
A Gesso component is a function. It is called once, and what it returns
is a tree of nodes that stays. Nothing here re-runs when the count
changes: the `text` prop is bound to an Observable, so one property on
one node is written and the next frame is drawn from it.
The counter above is running in a render worker on this page. Its source
is the whole example, and the same file is what the test suite asserts
on:
```tsx
import { percent } from 'gesso-core';
import { Button } from 'gesso-components';
import { type ComponentContext, type Inputs, computed, input, internalState } from 'gesso-framework';
export function Counter(inputs: Inputs<{ label?: string }>, _context: ComponentContext) {
const label = input(inputs.label, 'Count'); // inputs are cells; this one has a default
const count = internalState(0);
const caption = computed(() => `${label.value}: ${count.value}`);
return (
count.value++} />
);
}
```
Two things in it are worth naming. `input(inputs.label, 'Count')` gives an
optional prop a default while keeping it a cell, so a parent that later
changes the label still reaches this node. And `internalState(0)` is
state the component owns; writing `count.value++` marks exactly the
bindings that read it.
Notice what is _not_ in it. There is no colour: not a hex value, not
even a palette name, because a `Button` reads the theme's control
tokens and text takes its colour from the type scale, so the counter
follows this site's light and dark toggle without knowing that either
exists. [Light and dark](https://gesso-docs.vercel.app/guide/appearance) shows the root component
that arranges it.
There is no size either. `textStyle="title"` names a role in the
theme's [type scale](https://gesso-docs.vercel.app/appearance/typography) rather than a font size, a
weight and a line height, and the button's padding comes from the
theme's spacing scale rather than from four numbers on the element.
Tokens are the idiom throughout this site: a number or a colour written
on an element is the exception, and usually means a role is missing
from the scale.
`
` is one prop for two jobs. It is the words
on the face of the button and the name an assistive technology reads,
because in a button that has words on it those are the same thing. The
[Button page](https://gesso-docs.vercel.app/components/button) has the rest.
## Next
[Components run once](https://gesso-docs.vercel.app/guide/components-run-once) is what "called once"
means for where state and derived values go.
---
# Components run once
A component function is called once per instance. It is not called again
when its state changes, when its inputs change, or when the screen
repaints. What it returns is a tree of retained nodes that stays on
screen, and everything dynamic in that tree is an Observable bound
straight into it.
Press the button as many times as you like. The readings change; the
count of component bodies does not move.
Four bodies ran, the panel and its three rows, and they ran while the
screen was being built. After that, a press writes three numbers into
three nodes that already exist.
```tsx
/**
* One reading. Its body runs when the row is mounted and never again;
* `value` is a cell, so a new number is written into the node that is
* already on screen.
*/
function Reading(inputs: Inputs<{ name: string; value: number }>, _ctx: ComponentContext) {
bodies.value++;
return (
value.toFixed(2)))} fontSize={15} color="text" />
);
}
```
## What this replaces
If you have written React, the habits worth unlearning are the ones
that exist to survive re-rendering:
| The habit | Why it is not here |
| -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `useMemo` around an expensive derived value | The body runs once, so the expression runs once. There is nothing to memoise against. |
| `useCallback` for a stable handler | The closure is created once and attached to a node once. It is already stable. |
| A dependency array | A binding subscribes to exactly the Observables it reads. Nothing is compared, so nothing can be under-declared. |
| `key` to preserve identity across a render | Component identity **is** node identity. A key matters only for a list whose contents are rebuilt; see below. |
| Splitting a component to avoid re-rendering a peer | A peer is never re-rendered. Components are split for readability alone. |
The last row is the one that changes how a screen is written. In a
re-rendering framework, the shape of a component tree is partly a
performance decision. Here it is not: a value written into one node
touches that node.
## What still costs something
Running once is not the same as being free. What costs is the work a
change actually causes:
- **A content binding** (`text`, `image`) writes a property and
repaints.
- **A layout binding** (`width`, `flexGrow`, `gap`) writes a property
and runs layout again from the nearest node that can absorb it.
- **A paint binding** (`backgroundColor`, `opacity`) writes a property
and repaints without touching layout.
- **A children binding** (an Observable of a list) mounts and unmounts
components, which is the one case where bodies run again, for the
items that are new.
So the question to ask about a screen is not "how often does this
re-render", which is once, but "what does this value change cause". A
number in a label is cheap; the same number in a `width` is a layout
pass.
## Mounting is when a body runs
A component body runs when the component is mounted, and that is the
only time. Three things mount a component:
1. The application starting.
2. A children binding emitting a list that contains something new.
3. A route change, or any other place a component appears in a tree
that was not there before.
Everything else, every value and colour and size, is a write into a
node that already exists.
```ts
// Runs once, when the row is mounted.
const format = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 2 });
// Runs on every change, because it is inside the computed.
return Text({ text: computed(() => format.format(reading.value)) });
```
That distinction, outside the computed once and inside it per change,
is the whole performance model, and it is visible in the source rather
than in a profiler.
## What is live, and what is read once
Every input reaches a component as a cell. Reading `inputs.label.value`
inside the body gives the value at that moment and nothing afterwards;
binding `inputs.label`, or a `computed` that reads it, into a node's prop
gives every value it will ever have. The body runs once, so a `.value` read in it is
a snapshot, and a snapshot of something that changes is a bug that
fails silently: the screen simply stays as it was.
Two things make that bug visible. If a body reads a prop with `.value`
and that prop later changes with nothing following it, the framework
warns once, naming the component, the prop and, for an object, the
field that moved. And the library's own components follow their inputs:
an `Image` bound to a `src` that changes shows the new picture and
releases the old, an `Icon` bound to a `path` or `color` redraws in
place. Neither needs a `key` to change what it shows.
A `key` is for identity, not for change. Give one to each item of a
list bound to an Observable, so a reordered list moves nodes rather than
rebuilding them; give one to a child whose _shape_ changes, an `Image`
that becomes a lettered box when there is no picture, so the reconciler
replaces the node rather than handing new inputs to a body that has
already run.
A few rules that follow from the same model and are worth knowing before
they are met:
- Inside `
` and `` a lone Observable child is the text.
`{icon$} ` binds an element to the button's label and
draws nothing; the framework warns once when a text turns out not to
be text. Put such a child in an array, `{[icon$]}`, or in a ``.
- A `button` stacks its children as a `box` does, but centred both ways
rather than at its origin, as an HTML button centres its label. To lay
out an icon beside a label, put a `` inside it, and give the
button `x="stretch"` when that row should span it.
- A modifier written inline, `modifiers={[interactive({ hovered: ... })]}`,
is compared by what it holds, so it is the same modifier across a
rebuild. A modifier whose arguments include a fresh callback each time
is a new one; hoist the callback, or the whole modifier.
- `Presence` fills whatever holds it. Placed in an app's root it sits over
every screen and takes their clicks; hold it in a box the size of what
it animates.
- Paint and hit order among siblings is tree order, then `zIndex`. An
absolutely positioned control followed by a positioned column is
painted under the column and cannot be clicked; give it a `zIndex`.
## What the body may ask for
The second argument to a component function is its context: what the
function may ask of the framework while it runs.
| On `ctx` | What it does |
| -------------------- | ---------------------------------------------------------------- |
| `inject(Service)` | The runtime service of this class |
| `channel(token)` | The replica of a channel: `view` keys to read, `send` to command |
| `onMount(fn)` | Runs once after the nodes exist and the bindings are connected |
| `onUnmount(fn)` | Runs once when the component leaves the tree |
| `effect(source, fn)` | Follows a stream for as long as the component is in the tree |
| `bounds()` | A cell holding a node's box, with the modifier that fills it |
Most values a component reads are bound into the tree, and a binding
needs no lifecycle: it is torn down with the node it feeds. `effect` is
for the other kind, a value the component has to **act** on rather than
draw, such as telling the audio element to load a track or asking a
channel for the page a url names.
```ts
ctx.effect(queue.view.current, track => audio.load(track.stream));
```
The subscription belongs to the host and goes when the component does,
after `onUnmount` has run. Writing that pair by hand, a `subscribe` in
the body and an `onUnmount` that unsubscribes, is the same thing with
two places to forget.
`bounds()` answers the other question a body cannot answer for itself:
where is this node. Turning a pointer position into a fraction of a
track needs the track's box, and a canvas has no `getBoundingClientRect`.
```tsx
const track = ctx.bounds();
seek((e.x - track.value.x) / track.value.width)} />;
```
It is an ordinary cell: read it in a handler, bind it, derive from it.
It reports a move and only a move, so a box that has not changed does
not wake everything reading it; a list notifies its listeners on every
frame it scrolls, and its viewport is exactly what stays still while it
does.
## Where state goes instead
If the body does not re-run, a `useState` equivalent would have nowhere
to live. It lives in a cell instead: a value the component holds, which
bindings read. That is [the next page](https://gesso-docs.vercel.app/guide/cells-and-bindings).
---
# Cells and bindings
A cell is a value a component holds and a binding reads. There are three
kinds, and choosing between them is most of what writing a Gesso
component is.
| Where the value comes from | What to use | What it is |
| -------------------------- | --------------------------- | ----------------------------------------------- |
| The parent | `input(inputs.x, fallback)` | An input cell the host keeps feeding |
| This component | `internalState(value)` | A cell only this component writes |
| Other cells | `computed(() => ...)` | A cell that is a function of the cells it reads |
The third row is the one people overlook. A value that is a function of
other values needs no store and no synchronisation: `computed` reads the
cells its function touches, follows them, and cannot be stale because
there is no copy of it anywhere that a change could miss.
Each line keeps its own quantity. The currency comes from the parent and
changes both. The total is neither: it is one expression over the two.
```tsx
/**
* One order line, holding all three kinds of value at once.
*
* - `currency` comes from the parent. `input()` reads the prop cell
* and gives it a default, so the line follows a parent that changes
* its mind, without this body running a second time.
* - `quantity` is the line's own. `internalState` is for what
* originates here and dies with the component.
* - `total` is `computed` from the two above. It reads them as values
* and follows them as cells: nothing to keep in sync, nothing to
* invalidate, and no way for it to disagree with them.
*/
function OrderLine(inputs: Inputs<{ currency?: Currency }>, _ctx: ComponentContext) {
const currency = input(inputs.currency, 'USD');
const quantity = internalState(1);
const total = computed(
() => `${currency.value === 'EUR' ? '€' : '$'}${(quantity.value * PRICE_USD * RATES[currency.value]).toFixed(2)}`
);
return (
(quantity.value = Math.max(1, quantity.value - 1))} />
String(quantity.value))} fontSize={14} color="text" width={20} textAlign="center" />
quantity.value++} />
);
}
```
## `input`, a prop that keeps arriving
Inputs arrive as cells, not as values, which is what lets the body run
once and still follow its parent. `inputs.currency` is an `InputCell`,
and `input(inputs.currency, 'USD')` reads it with a default:
```ts
function OrderLine(inputs: Inputs<{ currency?: Currency }>) {
const currency = input(inputs.currency, 'USD');
}
```
A parent can pass a plain value or an Observable, since every input is
`Reactive = T | Observable`, and the child cannot tell the
difference. That is why the example passes `currency` (a cell) straight
down: to `OrderLine` it is simply an input that changes.
## `internalState`, what the component owns
For what originates here and dies with the component: a tooltip's open
flag, a caret, a scroll offset, the active tab, the quantity above.
```ts
const quantity = internalState(1);
quantity.value++; // marks exactly the bindings that read it
```
Reading `.value` is synchronous, writing it is a notification. Nothing
is scheduled twice: several writes in one handler mark their nodes and
the next frame draws the result once.
The name is on purpose. `internalState` says _where the value came from_
rather than what it is, so `internalState(products)` reads as the
mistake it usually is. Anything that survives a reload, or that another
screen cares about, is application state and belongs on a channel or in
a service, not in a component.
## `computed`, a function of other cells
```ts
const total = computed(() => format(quantity.value * PRICE * RATES[currency.value]));
```
Write the value as the expression it is, reading each cell with
`.value`. The function runs, `computed` notes which cells it read, and
from then on follows exactly those: when one changes the function runs
again, and if the result differs, everything bound to `total` is written.
A cell the function did not read this time is not followed; a branch
that reads a different cell next time is.
There is no effect that recomputes `total` into a second piece of state,
and no moment where it disagrees with `quantity`. Anything that would be
a `useMemo` or a `useEffect` writing into state is this instead. Read it
in a handler with `total.value`; bind it to a prop like any cell. Pass
`{ equal: 'structural' }` when the function builds an object and a
rebuilt equal one should not count as a change.
The rule of thumb: **if you can write the value as an expression over
other cells, it is a `computed`, not a state.** `internalState` is for a
value that nothing else determines.
## Observables underneath
Every cell is an RxJS `Observable`, and every prop accepts one, so a
value that arrives as a stream rather than a cell binds the same way:
`text={stream}`. A stream is not a cell, though: it has no current value
to read with `.value`, so `computed` hands its function a `read` for
exactly that case.
```ts
const late = computed(read => read(clock) > deadline);
```
`read` answers with the stream's latest value and follows it as
`computed` follows a cell, and one subscription is shared by every
computed reading the same stream. Pass it a cell and it simply reads it,
so a call site does not have to know which it has, and a service that
later turns a stream into a cell breaks nothing.
That makes `computed` the one derivation to reach for, whatever the
sources are. `derive([a, b], fn)` still works and is deprecated: it
listed its sources beside the expression, which is a second thing to
keep in step with the expression, and a screen with a `derive` and a
`computed` in it is written in two dialects.
```ts
// Before
const scheme = derive([settings.appearance, shell.colorScheme], (chosen, platform) =>
chosen === 'auto' ? platform : chosen
);
// Now
const scheme = computed(() =>
settings.appearance.value === 'auto' ? shell.colorScheme.value : settings.appearance.value
);
```
`pipe` is still there for everything RxJS is good at, which is streams
of events rather than values that are: debouncing, retrying, switching.
A binding that is a value has no reason to use it.
## One field of a cell
`select` is one field, or one projection, of a cell, as a cell:
```ts
const title = select(inputs.track, 'title');
const tags = select(inputs.track, entry => entry.tags.slice(0, 8));
```
It compares structurally by default, so a projection that rebuilds an
equal array or object does not re-bind everything reading it, which is
the reason most hand-written comparators exist. Reading four fields of
one input is four of these.
An optional input that the parent did not pass is a live cell holding
`undefined`, not a missing one. Binding it straight to a property writes
`undefined` there, which draws as though the property had never been
set, so give it a value: `input(inputs.tint, INK)` for a default, or
`select` for anything else.
## What a binding costs
A binding is attached to one property of one node. When it emits, that
property is written and the node is marked. What that costs depends on
which property, and [components run once](https://gesso-docs.vercel.app/guide/components-run-once)
lists the groups: content and paint properties repaint, layout
properties run layout again from the nearest ancestor that can absorb
the change, and `transform` does neither, moving the node without
touching layout or paint.
Fan-out is free when the nodes share a cell. Ten nodes bound to the same
Observable are ten writes from one emission, with no list to re-render
and no diff. That is why the two order lines above can share a currency
cell without the parent knowing how many lines there are.
What is not free is ten thousand nodes each reading a _different_ slice
of one upstream value, which is the next section.
## `fanOut`, when every node wants its own slice
A grid, a timeline and a log viewer all arrive at the same shape: one
source, and N things on screen each reading one part of it. The natural
spelling is a pipe per part:
```ts
const cell = cells.pipe(
map(all => all[key]),
distinctUntilChanged()
);
```
It is the one spelling in this guide that does not scale. Every
emission runs N pipelines whatever changed, and each pipeline is one
more observer on one Subject, which RxJS removes by scanning the list:
so unmounting a window of N costs N². At a few dozen nodes none of that
is measurable. At ten thousand it is the frame.
`fanOut` is the same idea with the costs the other way round:
```ts
const cells = fanOut(sheet.view.cells, (all, key) => all[key] ?? null, {
initial: null,
changed: (next, previous) => next.touched
});
const cell = cells.for(`${row}:${column}`); // a stable cell, made on demand
Text({ text: cell });
cells.release(`${row}:${column}`); // when it leaves the screen
```
One subscription on the source for the whole registry, whatever N is.
Each cell carries its own subject with one observer, so its teardown is
the constant-time case. And `changed` is where the frame is won: a
source that knows which keys a patch touched reads only those, so a
change to one cell of ten thousand costs one read rather than ten
thousand. Leave it out and every live key is read on every emission,
which is correct, is what the pipe costs too, and is the honest answer
for a source that genuinely does not know.
Measured, ten thousand live keys, against the pipe per key:
| | mount | one key changes | unmount |
| ----------------------- | ------- | --------------- | ------- |
| a pipe per key | 29.8 ms | 2.2 ms | 9.7 ms |
| `fanOut` | 10.7 ms | 2.2 ms | 4.0 ms |
| `fanOut` with `changed` | 10.8 ms | 0.1 ms | 3.7 ms |
A registry is a resource: it holds a key until `release` and the source
until the last key goes. That is deliberate. The surfaces that need it
are the ones that mount and unmount deliberately, and a cell that
vanished when its last binding did would be rebuilt on every scroll
frame by exactly the code that cannot afford it. It is also the one
thing here that can be got wrong silently, so a registry that has grown
past a couple of thousand keys and released none of them says so once.
**It compares by reference, where `select` and `derive` compare by
content.** The opposite default, for the opposite reason: those run
once per emission, and this runs once per live key per emission. On ten
thousand keys with the source republishing the lot, which is what a
scroll does, structural comparison costs 1.9 ms against 0.8. Pass
`equal: 'structural'` when the reader _builds_ a value rather than
passing one through. Better still, hand back the same object for an
unchanged key, one `EMPTY` for every cell with nothing in it, and keep
the cheap test.
Reach for it when N is large **and each emission touches few of them**.
That second half is the one to check: a source that republishes its
whole window on every scroll gets the cheaper mount and teardown and
nothing from `changed`, because every key really did change. A log
that appends, a presence list, a dashboard of independent tiles are
where the 0.1 ms above comes from.
For a list of components, [`each`](https://gesso-docs.vercel.app/guide/components-run-once) is still
the answer: a row that is a component already has its own identity and
its own inputs, and does not need a registry to give it one.
## Cells outside a component
`internalState` is not magic and is not tied to a component: it is a
`BehaviorSubject` with a `.value` setter. A service can hold one, and a
component can bind to it directly. That is how the runtime's own
services expose state, and how a small application shares a value
between two screens without a channel.
What a cell must not become is the application's data layer. When state
outlives a screen, is authoritative, or belongs on another thread, it
goes behind a channel. The framework has an opinion about the barrier
and none at all about what lives above it.
---
# Lists and conditionals
A component body runs once, so a list and a conditional are not control
flow in the body: they are values that change, bound into the tree like
any other. Two helpers cover the whole of it.
| What changes | What to write |
| -------------------------- | ------------------------------------------------ |
| The rows of a list | `{row => ...} ` |
| Whether one child is there | `{() => ...} ` |
| A value inside a row | Bind it, or `select` it. Neither helper re-runs. |
## `Each`, a keyed list
```tsx
{track => }
```
`of` is a cell, any Observable of an array, or a plain array. `by` says
what identifies a row: a field name, or a function
(`by={row => row.id}`). The key goes on the element the row function
returns, so a row component is written without one.
The key is what makes the list cheap and correct. A row keeps its node,
its component instance and its animation for as long as it is in the
list, so inserting a row at the top moves nothing else, and a text field
in a row keeps the text somebody typed into it.
Only what changed is redrawn. `Each` holds the element it built for each
key and hands the same one back while that row's value has not changed,
so a list that re-emits equal data draws nothing at all and the tree is
never re-entered. A row whose value changed is rebuilt on its own.
Leave `by` out and rows are keyed by their position, which is what the
tree falls back to and almost never what you want: a row inserted
anywhere but the end shifts every row after it onto a different node.
`Each` says so once, in a warning, rather than letting it be discovered
as a bug.
For a list long enough that the rows out of view should not exist at
all, [`LazyColumn`](https://gesso-docs.vercel.app/recipes/virtualized-feed) is the same shape with a
`count` and an `index => child`.
## `Show`, one child while a condition holds
```tsx
{() => }
```
`when` is a cell, an Observable or a plain value; anything truthy shows
the child. `otherwise` gives the other branch:
```tsx
}>
{() => }
```
The child is built once, on the first frame it is shown, and kept: a
condition that emits `true` again draws nothing. It carries a stable
key, so the node it built is the node it gets back when the condition
returns, and `Show` puts nothing else under the parent, so layout sees
the child or nothing and never a wrapper.
`{cond && }` still works when `cond` is a plain value, and it is
the shorter thing to write when the answer is settled while the body
runs. `Show` is for a condition that changes afterwards.
## What does not re-run
Neither helper re-runs its function when the data inside changes, only
when the list or the condition does. So a value shown inside a row or
inside a `Show` is bound, not read:
```tsx
{() => {select(track, entry => entry?.title ?? '')} }
```
`select` is one field, or one projection, of a cell, as a cell. It
compares structurally, so a projection that rebuilds an equal array or
object does not re-bind everything reading it:
```ts
const title = select(inputs.track, 'title');
const tags = select(inputs.track, entry => entry.tags.slice(0, 8));
```
Reading four fields of one input is four of these, and none of them
needs an operator, a comparator or a subscription of its own.
---
# Inputs and outputs
A component takes inputs and sends outputs. Both are cells. An input is
a cell the host feeds from whatever the parent passed; an output is a
cell that holds whatever the parent gave to be called, and `emit` fires
it. The body runs once, so nothing about either is read at render time
except through a binding.
A note on the word "props". Elements have props: `width`, `color`,
`onClick` on a `` or a ``, which become properties of a node.
Components have inputs and outputs. The parameter a function component
receives is its inputs, and it is called `inputs` throughout the guide,
the library and the examples; its type is `Inputs<...>`. A component
element's `props` property, where the parent's values are held before
they become cells, is the one place the framework's own types keep the
other word.
## Declaring inputs
The first parameter's type says what the component takes. Every member
arrives as a cell, whether the parent passed a value or an Observable.
```tsx
interface RowInputs {
/** Required: the parent must pass it, as a value or an Observable. */
title: string;
/** Optional: arrives as a cell that may hold undefined. */
emphasis?: 'normal' | 'strong';
/** An output: the parent passes a handler, or `into(subject)`. */
onPick: (title: string) => void;
/** An optional output, fired with no arguments. */
onHover?: () => void;
}
export function Row(inputs: Inputs) {
const weight = derive([input(inputs.emphasis, 'normal')], emphasis => (emphasis === 'strong' ? 700 : 400));
```
A member typed as a function is an output. A member that admits
`undefined` is optional; `input(inputs.size, 16)` gives it a default
that keeps following the source.
## Reading and binding
Inside the body, bind: pass the cell, or a `pipe` of it, into a node's
prop. Read `.value` only in a handler, where the current value is what
you want. A `.value` read in the body is a snapshot, and the framework
warns once if that snapshot goes stale.
`derive` is the shape most bindings take, several inputs projected into
one value and emitted only when it changes:
```ts
const playing = derive(
[queue.view.playlistId, audio.state],
(id, state) => id === card.id && state.status === 'playing'
);
```
It is `combineLatest`, `map` and `distinctUntilChanged` in one call,
with `{ equal: 'structural' }` for a projection that builds a fresh
object of the same shape each time.
## Sending outputs
An output is fired with `emit`, from as many places as the component
needs, and the component never touches the handler itself:
```tsx
return (
inputs.onHover.emit()}
onClick={() => inputs.onPick.emit(inputs.title.value)}>
{inputs.title}
);
```
A parent that passed nothing is fine: the emission reaches nobody. A
component that wants to react to its own output reads it as a stream
through `events`.
## Receiving outputs
A parent passes a function, as it always could. Or it passes
`into(subject)` and receives the output as a stream, which is what lets
a list of rows merge their outputs, a slider's output be debounced, or a
child's output be handed straight to a channel command:
```tsx
export function List() {
const picked = new Subject();
const hovered = new Subject();
// Two outputs from many rows, merged into one stream for the parent.
const activity = merge(picked.pipe(map(title => `picked ${title}`)), hovered.pipe(map(() => 'hovering')));
return (
{['Alpha', 'Beta', 'Gamma'].map(title => (
))}
{activity}
);
}
```
Wrapped in `into` rather than passed bare, because a bare `Subject` is
an Observable and would be read as an input the parent is feeding the
child, which is the opposite direction.
## Class components
A class declares inputs as `@Input()` fields made with `input()`, and
outputs as `@Output()` fields made with `output()`:
```ts
@Define('stepper')
class Stepper extends Component {
@Input() step = input(1);
@Output() changed = output<[value: number]>();
}
```
The host wires both the same way; the decorator is so a reader can tell
the directions apart.
## Who owns a value
A control that shows a value and changes it has two honest forms.
Controlled: the parent passes `value` and hears every change through
`onChange`; the parent owns the state. Self-managed: the parent passes
`defaultValue`, or nothing, and the control owns it, still reporting
changes. `controlled()` implements that contract and throws when both
are passed, because two owners is a bug:
```ts
const value = controlled({
component: 'Slider',
name: 'value',
source: inputs.value,
initial: inputs.defaultValue,
fallback: 0,
onChange: inputs.onChange
});
```
For the plain case where the owner is a local cell, `bind` is the
spread: ` ` passes the cell as `value` and
writes changes back into it. A control whose value has another name
takes that name as the second argument, and the handler stays
`onChange`: ` ` spreads
beside the props around it. When the value belongs to a store or a
channel, pass the cell and a handler that sends the command instead.
## Where the framework helps you
- A body that reads an input with `.value` and never hears it change is
warned about once, with the component, the input and the field that
moved.
- A `text` bound to something that is not text is warned about once.
- The inspector names the cell a bound property comes from, so a value
on screen can be traced to the input that fed it.
---
# Layout basics
Gesso's layout borrows CSS's model and most of CSS's names, so a box
that would be 240 px wide in a browser is 240 px wide here, and the
engine is checked against Chrome on a few hundred generated fixtures. What is
different is that the names are typed, and there is no cascade: a
property is set on an element, and that is where it comes from.
## Three containers
| Element | Lays children out |
| ---------- | ---------------------------------------------------- |
| `` | Along the horizontal axis |
| `` | Along the vertical axis |
| `` | Stacked on top of each other, aligned within the box |
`` is also the plain rectangle: a background, a border and a size.
The same element is exported as `Stack` for when layering is what you
mean.
## `x` and `y` place everything
Two props do the work of `justify-content`, `align-items` and their
per-child overrides:
- **`x`** places children horizontally.
- **`y`** places children vertically.
- **`selfX` / `selfY`** let one child override its parent on that axis.
They are named for the screen rather than for the axis, so they mean the
same thing on a row as on a column. On a `row`, `x` distributes along
the main axis (`start`, `center`, `end`, `space-between`, and the rest)
and `y` places across it. On a `column` they swap roles without swapping
names.
Press either button. `x` walks the distributions, `y` walks the
cross-axis values:
```tsx
/**
* A row of three boxes, and the two props that place them.
*
* `x` distributes the children along the row's main axis; `y` sizes and
* places them across it. They are the same two props on a `column`,
* where the axes swap, which is why they are named for the screen
* rather than for the axis.
*
* None of the boxes sets a height, which is what lets `y: 'stretch'`
* do anything: stretch gives a child the container's cross size only
* when the child has not already chosen one. Give a box a `height` and
* it keeps that height under every value of `y`.
*/
function Boxes(inputs: Inputs<{ x: UiAlignment; y: UiAlignment }>, _ctx: ComponentContext) {
return (
);
}
/** A box sized by what is in it, so `stretch` has something to change. */
function Tile(inputs: Inputs<{ name: string }>, _ctx: ComponentContext) {
return (
);
}
```
**The cross axis defaults to `stretch`**, matching CSS. Stretch gives a
child the container's cross size _only when the child has not chosen
one_: the tiles above have no `height`, so they fill the row, and the
moment one takes a `height` it keeps it under every value of `y`. This
is the single most common surprise in the model, which is why the
example is built to show it.
## Lengths are values, not strings
A number is pixels. Anything else is a tagged value you import:
```tsx
import { auto, percent } from 'gesso-core';
// 240 px
// half the parent's content box
// sized by content
```
There is no `"50%"`. A string where a length belongs throws at layout
and names the property, because the alternative is a hot path that
parses strings on every measure. Percentages resolve against the
parent's definite content box, and behave as `auto` when it has none.
## Space
`gap` between children, `padding` inside a container, `margin` outside a
child, all in pixels. Both `padding` and `margin` come with an axis and
four sides:
```tsx
```
`paddingX` is the left and right of the box, `paddingY` the top and
bottom, and `marginX` and `marginY` are the same outside it. Nearly
every box is padded more on one axis than the other, so those two are
the pair to reach for; `padding` on its own is for the box that really
is padded equally.
The four sides are still there for the inset that is genuinely on one
edge, and the most specific value wins: a side beats its axis, and an
axis beats the shorthand. So
```tsx
```
is nothing on the left, sixteen on the right, and eight top and bottom.
What has gone is the pair: `paddingLeft={24} paddingRight={24}` says
one thing twice, and `paddingX={24}` is that thing.
`padding` is a number and nothing else. It does not take a tuple or an
object, deliberately.
For a single horizontal edge, reach for `paddingStart` and `paddingEnd`
rather than `paddingLeft` and `paddingRight`. They name the edge the
reading starts and ends at, so an indent stays an indent when the
application runs right to left, and they resolve between the axis and
the physical sides. See [right to left](https://gesso-docs.vercel.app/layout/right-to-left).
A row and a column can also space their axes apart independently with
`rowGap` and `columnGap`. `margin` takes `auto` on either axis, which is
still the shortest way to push one child to the far end of a row.
The numbers themselves belong in a theme rather than in the markup.
`theme.spacing` is an eight step scale on the same names as the shape
scale, so a screen written from it reads `paddingX={theme.spacing.large}`
and a compact density shrinks every box on the page at once. See
[themes and the environment](https://gesso-docs.vercel.app/appearance/themes-and-the-environment).
## Who grows
Sizing follows flexbox, including the parts people forget:
- `flexGrow` takes a share of the space left over; `flexShrink` gives
back a share of the overflow; `flex={1}` is the usual shorthand for
both.
- **A flex item never shrinks below its content.** An item's automatic
minimum is the smaller of its declared size and its min-content size,
which is the longest word or the widest summed row. That is what stops
a column of cards being crushed to nothing by a tall sibling. Set
`minWidth`/`minHeight` explicitly to overrule it; scroll containers
and clipped text have no automatic minimum by design.
- Space a clamped item cannot take is redistributed to the others,
rather than being lost.
## When a box is the wrong size
Ask the engine rather than guessing. Every runtime can explain any
node's size: the rule that fixed each axis, the constraints it was
given, and the ancestor a change would be laid out from.
```ts
formatExplanation(runtime.explain(node));
// "shrank to 0 (flexShrink 1 …); its automatic minimum is 0:
// a scroll container or clipped text has none"
```
The same explanation is what `gesso-testing`'s `toHaveBox` prints when
an assertion misses, so a failing layout test tells you why rather than
only what.
## Next
[Text](https://gesso-docs.vercel.app/guide/text) is the other half of layout: wrapping, clamping, and
what a paragraph does to the box around it.
---
# Text
Text is the input to most layout decisions. A `` element is not a
rectangle with a string in it. It is measured, broken into lines, and
sized by what it says, and the box around it is decided by that.
The card below is 420 px wide. Press the button to narrow it and watch
which parts answer:
```tsx
/**
* A card whose width the reader changes, so the text has to answer.
*
* Three text behaviours worth knowing, all of them props on `text`:
*
* - The title takes `maxLines` and `textOverflow`, so it clamps to two
* lines and ends in an ellipsis rather than growing the card.
* - The body has neither, so it wraps as far as it needs to. Width is
* CSS `fit-content`: given a loose bound it takes what it wants.
* - The footer row aligns on `baseline`, so a large number and a small
* label sit on the same line rather than on their box centres.
*/
function Card(inputs: Inputs<{ width: number }>, _ctx: ComponentContext) {
return (
);
}
```
The body re-wraps and gets taller. The title, clamped to two lines, does
not: it drops what will not fit and ends in an ellipsis. Both are the
same element with different props.
## Styling
```tsx
```
`color` takes a theme token as readily as a value, which is why nothing
in these examples names a hex code. Text that sets neither size nor
colour inherits both from the type scale in the environment. See
[light and dark](https://gesso-docs.vercel.app/guide/appearance) for what provides that.
## Wrapping, clamping, ellipsis
| Prop | What it does |
| -------------- | --------------------------------------------------------------------- |
| `textWrap` | `word` (the default), `char`, or `none` |
| `maxLines` | Stop after _n_ lines |
| `textOverflow` | `clip` (the default) or `ellipsis`, for what a clamped line ends with |
A clamped title is the common case and worth spelling out: `maxLines`
alone truncates, and `textOverflow: 'ellipsis'` is what makes the
truncation legible.
## How a paragraph sizes its box
Width is CSS `fit-content`: given room to choose, a paragraph takes what
it wants up to what it is offered. Two rules follow, and between them
they explain most surprises:
- **A tight bound is authoritative.** Told exactly how wide to be, text
wraps to that width.
- **A loose bound is available space, not a clamp.** Told "up to this
much", text takes what it needs, and content that does not fit
overflows exactly as it would in a browser.
Flex sizing then runs twice around this: items are measured at their
max-content width to resolve the main axis, and any item whose width
changed is measured again at the width it ended up with. That second
pass is what makes a wrapped paragraph's height right instead of
approximately right.
## Baseline alignment
`y="baseline"` on a row puts its children on a shared text baseline
rather than on their box edges or centres, which is the only way a
22-pixel number and a 12-pixel label look like they belong on one line:
```tsx
```
Baselines come from the font's own metrics, and a box with no text in it
gets a synthesised one the way CSS specifies, so a badge or an icon can
sit in a baseline-aligned row without falling out of it.
## One measurer
Layout, both renderers and the editing caret all break lines through the
same `layoutParagraph`. That is a correctness property rather than an
implementation note: a line cannot wrap in one place for layout and
another for paint, and a caret cannot land between two characters that
the renderer drew somewhere else.
The consequence for you is that text costs measurement. It is memoised
per node and per constraint, so an unchanged paragraph is not measured
again on a later frame, but a screen that re-wraps a thousand
paragraphs on every keystroke is doing real work, and the frame profiler
will say so.
Text you can _edit_, with a caret, a selection and composition from an
IME, is a different subject from text you lay out, and lives in
`EditableText` and the `TextInput` component rather than in these props.
## Next
[Light and dark](https://gesso-docs.vercel.app/guide/appearance) is where the type scale and the
colours these examples inherit actually come from.
---
# Rich text
A `` element takes either a string or a list of runs. The runs are
what make prose possible: a word in bold, a phrase in italic, a snippet
in a monospaced face, a link the reader can press, all inside one
paragraph that breaks its lines from all of them together.
Everything on that card is four `` elements: no component, and
no node per word. Runs are enough to read markdown. To edit it, the
library has [RichTextEditor](https://gesso-docs.vercel.app/components/rich-text-editor), which is
built from the same runs, and its read-only twin `RichTextView`.
## Runs
`spans` replaces `text`. Each run carries its own text and the style
fields it overrides; anything it does not set it inherits from the
element, and the element from its environment, exactly as it always
did.
```tsx
```
| Field | What it changes |
| ------------------------------------------- | -------------------------------------------------- |
| `fontFamily`, `fontSize`, `fontWeight` | The face the run is measured and drawn in |
| `fontStyle` | `normal`, `italic` or `oblique` |
| `fontStretch`, `fontVariant`, `fontKerning` | The width axis, small caps, and the font's kerning |
| `letterSpacing` | Tracking, for this run only |
| `hidden` | Keeps the run in the text but draws nothing for it |
| `color`, `backgroundColor` | A value or a theme token, resolved per run |
| `textDecoration` | `underline`, `line-through`, or both |
| `link` | Makes the run pressable; see below |
The first eight change how wide the run measures, so a paragraph is
laid out again when one of them changes. The last three do not: a run
that changed only its colour, its background, its underline or its
handler is repainted from the lines the layout already found.
## One string, whatever the runs
This is the rule everything else follows from. **The runs' texts
concatenated are the paragraph**, so an offset into it means the same
thing to every layer:
- the line breaker measures each run in its own font and breaks the
line from all of them,
- a selection crosses a run boundary without noticing one,
- find-in-page matches text that starts in one run and ends in another,
- and the accessibility mirror reads the paragraph as prose.
A run is a shape of a paragraph rather than a node inside it, which is
why none of that needed a second tree.
## Inline links
A run with a `link` is pressable. It shows a pointer cursor, lights up
under the pointer with a wash of its own colour and an underline, and
runs `onClick` when a press is released where it landed. A press that
drags is a selection instead, as it is on a page.
```tsx
{ text: 'the reference', color: 'primary', link: { href: '/reference', onClick: open, label: 'the API reference' } }
```
`label` names the run for a screen reader when its own words would not:
"here" and "read more" are links nobody can use out of context. `href`
is carried for the application's own use; the runtime never navigates
to it.
In the accessibility mirror the paragraph becomes a `paragraph` whose
children are the prose and the links in reading order, so a screen
reader reaches the link the way it reaches one in a page rather than
hearing its words go past inside a sentence.
## A markdown document
The example above is a markdown reader in forty lines. Blocks become
elements and inline markers become runs:
```tsx
/** A block of the document: a heading at some level, or a paragraph. */
export interface MarkdownBlock {
/** 0 for a paragraph, 1 or 2 for a heading. */
readonly level: number;
readonly spans: readonly UiTextSpan[];
}
/**
* A deliberately small markdown reader: blank lines separate blocks,
* a leading `#` or `##` makes a heading, and inside a block `**bold**`,
* `*italic*`, `` `code` `` and `[label](href)` become runs.
*
* It is short because the interesting part is not the parsing. What
* matters is what it produces: a flat list of runs per block, which is
* exactly what a `` element takes.
*/
export function markdownBlocks(source: string, onLink: (href: string) => void): MarkdownBlock[] {
return source
.split(/\n{2,}/)
.map(block => block.trim())
.filter(block => block.length > 0)
.map(block => {
const heading = /^(#{1,2})\s+/.exec(block);
const level = heading === null ? 0 : heading[1].length;
// A wrapped source line is one paragraph, so the newlines inside
// a block are spaces rather than breaks, as markdown has them.
const text = block.slice(heading?.[0].length ?? 0).replace(/\s*\n\s*/g, ' ');
return { level, spans: inlineSpans(text, onLink) };
});
}
/** The four inline forms, in one pass, longest marker first. */
const INLINE = /\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g;
function inlineSpans(text: string, onLink: (href: string) => void): UiTextSpan[] {
const spans: UiTextSpan[] = [];
let at = 0;
INLINE.lastIndex = 0;
for (let match = INLINE.exec(text); match !== null; match = INLINE.exec(text)) {
if (match.index > at) {
spans.push({ text: text.slice(at, match.index) });
}
const [, bold, italic, code, label, href] = match;
if (bold !== undefined) {
spans.push({ text: bold, fontWeight: 700 });
} else if (italic !== undefined) {
spans.push({ text: italic, fontStyle: 'italic' });
} else if (code !== undefined) {
spans.push({ text: code, fontFamily: 'monospace', color: 'text', backgroundColor: 'background' });
} else {
spans.push({
text: label,
color: 'primary',
textDecoration: 'underline',
link: { href, onClick: () => onLink(href) }
});
}
at = match.index + match[0].length;
}
if (at < text.length) {
spans.push({ text: text.slice(at) });
}
return spans;
}
```
And the document is one element per block, with a heading differing
from a paragraph only in size and weight:
```tsx
/**
* One `` per block, and nothing else.
*
* A heading is the same element as a paragraph with a larger size and
* a heavier weight; the runs inside it are the runs inside any other
* block. There is no rich-text component here, because there does not
* need to be one.
*/
function Document(inputs: Inputs<{ blocks: MarkdownBlock[] }>, _ctx: ComponentContext) {
return (
{inputs.blocks.pipe(
map(blocks =>
blocks.map((block, index) => (
))
)
)}
);
}
```
## The line box
A run inherits the paragraph's line height as a length, so a run
smaller than the paragraph does not change the line and a run larger
than it grows the line around its own box, which is what CSS does with
the same two inline boxes.
Where Gesso differs from a browser is that **every line of a paragraph
gets the tallest line's box**. A paragraph is its line count times one
line height here, and both renderers step by that. Keep a run's size
close to its paragraph's and the two agree exactly; the fixture that
pins the difference is `runs/a-taller-run-grows-only-its-own-line`.
## Runs in a field being typed into
`` takes `spans` too, and there it means something
narrower. A `` gets its paragraph _from_ its runs; a field's
paragraph belongs to the thing the user is typing, so its runs only
describe that text rather than supplying it.
```tsx
```
**The runs' texts concatenated have to equal `value`.** When they do
not, the field draws its text plainly and ignores them. That is not a
nicety: every offset a field works in (the caret, the selection, the
hit test that turns a click into a text position) is an offset into
the string the model holds, so runs describing a different string
would put the glyphs where the caret does not agree with them, and the
field would look subtly and unfixably wrong. A frame of plain colour
while the application catches up is the better failure.
Everything else about a field is unchanged: it still scrolls its own
text, still shows a placeholder, still carries a caret and a
selection.
## Hidden runs
A run with `hidden: true` stays in the text and leaves the picture: it
takes no room, the line breaker counts it as nothing, and neither
renderer draws it. Its characters keep their offsets, so a selection,
a copy, find-in-page and undo all still see them. It is how a markdown
editor draws `**bold**` as a bold word while the asterisks stay in the
string being typed, which the rule above insists on.
[RichTextEditor](https://gesso-docs.vercel.app/components/rich-text-editor) is that editor.
```tsx
```
In a field, the caret treats each stretch of hidden text (runs that
touch count as one) as a unit it cannot stop inside, so every key moves
something you can see:
- **Arrows** cross one visible character, or with the word modifier one
visible word, and the hidden text in the way. They stop on the side
of any hidden text there nearest where they started: from the end of
`**bold**`, Left stops between `l` and `d`; from just after the `d`,
Right goes over the markers and on. Where nothing visible is left in
that direction the caret goes past the hidden text to the end, and
in an editing group it goes on to the next field.
- **Backspace and Delete** remove the visible character or word next to
the caret and keep hidden text at either end of it: Backspace at the
end of `**bold**` leaves `**bol**`. Hidden text strictly inside what
they remove goes with it. A selection is deleted exactly as selected,
hidden text included. Where nothing visible is left in that
direction they delete nothing.
- **A press** lands on the nearest boundary as always. Where hidden
text sits there, both its ends are drawn at one x, so the side of the
press decides: before the boundary (left of it, in left-to-right
text) is before the hidden text, past it is after. A double click
selects the word as drawn, with hidden text inside the word included
and hidden text round it left out.
- **The selection highlight** skips hidden text, which has no width to
highlight; `selectedText` and a copy still include it.
- **An offset the application sets** with a selection is kept as given,
even inside hidden text, since the application may be about to show
that text. The next move steps out of it.
- **An IME** composes at the caret as usual. While it composes the
application has not been told about the new characters, so its runs
describe the text without them; rather than dropping the runs and
showing every hidden marker until the commit, the field moves them
to make room. The composing text joins the visible run it is typed
at the end of or inside, and is never made hidden.
The accessibility mirror, and the editing proxy a screen reader reads a
field through, still have the whole text, hidden runs included: a
screen reader hears `**bold**` with its asterisks. That is a known
limitation, not a decision.
## What is not here
Justified text and hyphenation are still deferred. Font features
beyond the ones in the table above are not reachable: a canvas font
string carries style, variant, weight, size and family and nothing
else, so `tnum` and `liga` cannot be asked for from here. A variable
font's `wght` is reached through a numeric `fontWeight` and its `wdth`
through `fontStretch`.
---
# Using components
`gesso-components` is the library you build screens out of: inputs,
overlays, structure, data and media, twenty-eight components in all.
Every one of them is themed, keyboard operable, and announces itself to
an assistive technology from the day it was written.
The form below is four of them. Tab through it: the fields take focus
in order, Space toggles the checkbox, and the select opens and chooses
from the keyboard alone. Press Continue while it is empty and nothing
is sent, which is [Forms](https://gesso-docs.vercel.app/guide/forms) rather than any of the four
controls.
```tsx
/**
* A form: three controls, three rules, and a button that will not go
* until they pass.
*
* `form` holds the fields by name and each field carries its own
* checks. `bind()` spreads onto a control everything the control needs
* from the form: the value, the writer, the message to show once the
* field has been left, and the ref the form uses to put the caret in
* the first field that failed. Nothing about validation is written on
* a control, and nothing about it is written twice.
*
* Press Continue with the form empty and nothing is sent: the caret
* lands in the email field and every rule that failed says so, in
* words a screen reader announces as well as draws.
*/
export function Form(_inputs: Inputs<{}>, ctx: ComponentContext) {
const sent = internalState('');
const signUp = form(
ctx,
{
email: field({ initial: '', validate: [required('We need an address'), email()] }),
plan: field({ initial: 'team' }),
terms: field({ initial: false, validate: [required('Accept the terms to continue')] })
},
{
onSubmit: values => {
sent.value = `Sent: ${values.email} on ${values.plan}`;
}
}
);
return (
void signUp.submit()} />
);
}
```
## What a control brings with it
The example spreads a field's binding onto each control and stops. What
that binding holds is a `value`, an `onChange` and a message; a control
given those three by hand behaves the same way. Everything else arrives
with the component:
- **The label above the field**, positioned and styled, and used as the
accessible name rather than duplicated into one.
- **A focus ring** on the element that actually takes focus, drawn on
the surface behind the control so it stays legible over any of them.
- **Hover and press states** in theme tokens, so a control looks
different under the pointer without your naming a colour.
- **The keyboard map** the platform expects: Space and Enter on a
checkbox, arrows and typeahead in a select, Escape to dismiss.
- **`role`, `label`, `value` and `states`**, which is what makes the
spec beside this example able to find the checkbox by asking for a
checkbox.
That last one is worth dwelling on, because it is what the tests on this
site are written against:
```ts
expect(ui.getByRole('checkbox')).toHaveSemantics({
role: 'checkbox',
name: 'I accept the terms'
});
```
There is no second definition of what a control is. The tree a screen
reader reads is the tree a test queries.
## Controlled, or not
Every input takes either form. Hand it `value` and `onChange` and it
shows what you give it; hand it `defaultValue` and it keeps its own:
```tsx
(email.value = next)} />
```
A controlled input given no `onChange` does not move, because nothing
writes the value back. That is the same effect `readOnly` has, for the
same reason.
## The five tiers
| Tier | What is in it |
| ------------- | ------------------------------------------------------------------------------------ |
| **Inputs** | `TextInput`, `TextArea`, `Checkbox`, `Switch`, `RadioGroup`, `Slider`, `NumberInput` |
| **Overlays** | `Tooltip`, `Menu`, `Select`, `Dialog`, `Toast` |
| **Structure** | `Tabs`, `Toolbar`, `SplitPane`, `Accordion`, `Card`, `Divider` |
| **Data** | `LazyList`, `Tree`, `DataTable` |
| **Media** | `Image`, `Icon`, `Spinner`, `ProgressBar`, `Video` |
Overlays flip and shift at the edge of the viewport, follow their anchor
through a scroll, and trap focus where they should. A `Dialog` returns
focus to whatever opened it. `DataTable` shares grid tracks across
virtualized rows, which is what lets a hundred thousand of them scroll.
## Styling one
Components take their colours from the theme rather than from props, on
purpose: a `backgroundColor` prop on every control would fork the theme
at every call site. What they do take is layout:
```tsx
```
They also take `rootModifiers`, which attaches behaviour to the
component's own root rather than to a box wrapped around it. That is
what a modifier needs in order to see the control's real geometry: a
`measure` on a wrapper reports the wrapper.
A modifier's arguments are compared by value when the control is
rebuilt, so one constructed in the call stays attached as long as its
options say the same thing. Declaring it once at module scope is the
cheaper habit, and the one thing that does have to be the same value
each time is a handler or a stream among those options: a fresh
function is a new argument.
If a control needs to look different from the rest of the application,
the answer is a different theme in the environment for that subtree, not
a colour prop on the control.
## When to build your own
The library is not a wall. A component is an ordinary function returning
elements, so anything the library does you can do with the same
materials: `interactive()` and `focusRing()` come from `gesso-core`,
and the library's own controls are built out of exactly those two.
The rule worth keeping is the one the library follows: emit `role`,
`label` and `states` from the first version you write. Retrofitting
semantics across a screen already built is the expensive kind of work,
and a control with none is invisible to a screen reader and to every
test on this site.
## Next
[Light and dark](https://gesso-docs.vercel.app/guide/appearance) is how the theme these controls read
gets into the environment in the first place.
---
# Forms
A form is three problems, and only the first is about controls. The
second is knowing that six fields exist, that two of them are wrong,
that nobody has been near the third yet, and which one the caret should
go to when the button is pressed. The third is saying all of that out
loud, so a person who cannot see the red border still learns what
happened.
`gesso-components` answers the first with its controls and the other
two with `form`.
```tsx
/**
* A form: three controls, three rules, and a button that will not go
* until they pass.
*
* `form` holds the fields by name and each field carries its own
* checks. `bind()` spreads onto a control everything the control needs
* from the form: the value, the writer, the message to show once the
* field has been left, and the ref the form uses to put the caret in
* the first field that failed. Nothing about validation is written on
* a control, and nothing about it is written twice.
*
* Press Continue with the form empty and nothing is sent: the caret
* lands in the email field and every rule that failed says so, in
* words a screen reader announces as well as draws.
*/
export function Form(_inputs: Inputs<{}>, ctx: ComponentContext) {
const sent = internalState('');
const signUp = form(
ctx,
{
email: field({ initial: '', validate: [required('We need an address'), email()] }),
plan: field({ initial: 'team' }),
terms: field({ initial: false, validate: [required('Accept the terms to continue')] })
},
{
onSubmit: values => {
sent.value = `Sent: ${values.email} on ${values.plan}`;
}
}
);
return (
void signUp.submit()} />
);
}
```
Press Continue with the form empty: nothing is sent, the caret lands in
the email field, and each rule that failed says so under its control. A
screen reader hears the messages as they appear, because each one is a
live region, and hears the field's message again after its name when
focus arrives.
## A form is a helper, not a requirement
Nothing in Gesso needs a form group. A control given `value` and
`onChange` is complete on its own, it is what every page in
[Using components](https://gesso-docs.vercel.app/guide/using-components) shows, and a screen with two
fields and no rules is better written without a group at all:
```tsx
(filter.value = next)} />
```
Reach for `form` when there are rules, a submit, or a message to place.
## The group
`form(ctx, fields, options)` takes the component's context, because it
does two things a component does: it puts the caret somewhere, and it
follows every field for as long as the screen is up.
```tsx
const signUp = form(
ctx,
{
email: field({ initial: '', validate: [required(), email()] }),
handle: field({ initial: '', validate: [required(), minLength(3)] })
},
{ onSubmit: values => account.send.create(values) }
);
```
`signUp.fields` is the object you passed, so `signUp.fields.email` is a
`Field` and `signUp.values` is `{ email: string; handle: string }`.
Nothing is looked up by a string at runtime and nothing is typed twice.
### Binding a field to a control
`bind()` spreads onto a control everything the control needs from the
form: the value, the writer, the message to show, whether the field is
required, and the ref the form focuses when that field is the first one
wrong.
```tsx
```
`bindAs` is for a control that calls the value something else. A
`Checkbox` takes `checked`, so `bindAs('checked')` hands it a `checked`
and no `value` it would reject.
### The four cells
| Cell | True when |
| ----------- | ------------------------------------------------------- |
| `valid` | every field passes and no form-wide rule found anything |
| `dirty` | some field differs from the value it started with |
| `touched` | somebody has been in a field and left it again |
| `submitted` | the form has been submitted at least once, valid or not |
They are cells, so bind them:
```tsx
!signUp.valid.value)} onClick={() => void signUp.submit()} />
```
A field's message is shown once the field is `touched` or the form is
`submitted`, and not before. Nobody should be told they got it wrong
while they are still typing it for the first time.
### Submit
`submit()` marks every field touched, blocks if anything is wrong,
focuses the first field that failed in declaration order, and otherwise
runs the handler.
It returns a promise, and **it runs the handler in the same task when
nothing is pending.** That is deliberate. A browser grants a popup, a
clipboard write or a file picker only while the gesture that asked for
it is fresh, so a submit that always awaited would quietly break every
handler that opens one. The promise is what to await when a field has an
asynchronous check in flight, and then the form waits for the answer
before deciding.
## Validators
A validator is a function from a value to a message or `null`. That is
the whole interface, so writing one is a line:
```ts
const noSpaces: Validator = value => (value.includes(' ') ? 'Spaces are not allowed' : null);
```
The library ships the handful every form needs. Each takes the message
to show, so the words on the screen are yours:
| Validator | Fails when |
| ------------------------ | ----------------------------------------------------------- |
| `required(message?)` | the value is blank, empty, `NaN`, an empty list, or `false` |
| `minLength(n, message?)` | a string or list is shorter than `n` |
| `maxLength(n, message?)` | a string or list is longer than `n` |
| `pattern(re, message?)` | a non-empty value does not match |
| `email(message?)` | a non-empty value does not look like an address |
| `range(min, max, m?)` | a number is outside the range |
| `allOf(...checks)` | any of them does; the first message wins |
A field runs its checks in order and shows the first message it gets, so
put the cheap and general ones first. `required` is the exception worth
knowing about: a field whose checks include it reports itself required
to an assistive technology, so the rule is written once rather than as a
validator and a prop.
`pattern` and the checks built on it pass an empty value on purpose.
`pattern` says what a value must look like and `required` says whether
there has to be one, which is what makes a field that is optional but
must be well formed take two checks and no third.
## Checks across the whole form
A rule that reads two fields belongs to the thing that holds both:
```ts
const signUp = form(ctx, fields, {
validate: matches('password', 'again', 'The two passwords do not match')
});
```
A form validator answers `Problems`: a message per field name, and one
for the form itself when the problem belongs to no single field.
```ts
validate: values => (values.from > values.to ? { fields: { to: 'Before the start date' } } : null);
```
A message addressed to a field appears under that field like any other.
`{ form: '...' }` lands on `group.error`, which is shown once the form
has been submitted and is yours to place.
## A validation library
Gesso depends on no schema library and never will. What it fixes is the
shape an adapter fills in, which is one method:
```ts
const schema: Schema = {
check(values) {
const answer = Account.safeParse(values);
if (answer.success) {
return null;
}
const fields: Record = {};
for (const issue of answer.error.issues) {
fields[String(issue.path[0])] ??= issue.message;
}
return { fields } as Problems;
}
};
const signUp = form(ctx, fields, { schema });
```
A schema runs before the form's own validators and beside each field's
own, so adopting one does not mean giving up the two lines of
`required()` a field already had.
## A check that has to ask something
Whether a handle is taken is not a question a validator can answer on
its own:
```ts
handle: field({
initial: '',
validate: [required(), minLength(3)],
validateAsync: name => api.handleAvailable(name).then(free => (free ? null : 'That handle is taken'))
});
```
The check is a `resource` underneath, keyed on the field's value, so it
reports itself in [the same five words every other request does](https://gesso-docs.vercel.app/guide/state-and-services):
| Status | What it means for the field |
| --------- | ----------------------------------------------------------------------------------- |
| `idle` | there is no asynchronous check, or the field's own checks fail so nothing was asked |
| `loading` | a request is out. The field shows nothing, and the form is **not** valid |
| `ready` | the check answered, with or without a message |
| `failed` | the check could not be made. That counts as invalid, and the reason is shown |
Three of those rows carry a decision.
**Nothing is shown while it is loading**, because there is nothing to
say yet. What is true is that the form will not go until it hears back:
`valid` is false, so a button bound to it stays disabled, and a press
that gets through anyway is awaited rather than raced.
**A check that could not be made is a failure, not a pass.** A form must
not quietly accept a rule it never managed to apply, so `failed` blocks
and shows the error, and editing the field asks again.
**Nothing is asked while the field's own checks fail.** There is no
point asking a server whether an empty handle is taken, and the key is
`null` until the cheap checks pass, which is exactly `idle`.
`missing` never occurs. A check that answers "no message" has answered.
## A field the application owns
A field is almost never a component's own state. It is the
application's, it lives on a channel, and a channel view key is
read-only with a command beside it. That is two halves of one binding,
and a field says both out loud:
```tsx
const filters = form(ctx, {
query: field({ value: search.view.query, onChange: search.send.setQuery })
});
```
The form owns nothing here. It reads the key, writes through the
command, and validates a value that lives on another thread exactly as
it validates one of its own. Passing both `value` and `initial` throws
naming the field, for the same reason a control given both `value` and
`defaultValue` does: two owners is a bug rather than a precedence rule
to learn.
## Lists of fields
`fieldArray` is for tags, guests, the lines of an invoice: a list that
grows and shrinks, where each row is an ordinary field.
```tsx
const invoice = form(ctx, {
lines: fieldArray({ initial: [''], each: { validate: [required('Every line needs a name')] } })
});
{row => }
;
```
Each row carries a key minted when it was added and never reused, which
is what keeps the right editor over the right row when one in the middle
is removed. `add`, `remove` and `move` are the three things a list of
inputs needs, and a row that fails takes the caret like any other field.
A field array owns its rows. A list that lives on the far side of the
barrier is added to and removed from by commands, and a helper that
pretended to own it would be inventing a second copy of the
application's state to disagree with the first.
## What a message does
A field's message is not only drawn. It is set as the control's
description, so it is read after the name when focus lands on a field
already marked wrong, and the line under the control is a polite live
region, so it is announced when it appears while the person is still
standing in the field. A message that only appears is a message nobody
hears.
That is the whole reason a form belongs in the component library rather
than in each application: the drawing is easy and the announcing is the
part everybody skips.
## Next
[Light and dark](https://gesso-docs.vercel.app/guide/appearance) is how the theme these controls read
gets into the environment in the first place.
---
# Light and dark
A Gesso application draws into a canvas, so it inherits nothing from the
page around it: no stylesheet reaches it, and `prefers-color-scheme`
changes no colour it paints. The appearance has to be _told_ to it, and
the shell is the only part of an application with a window to ask.
Use the appearance toggle in this site's navigation bar. The canvas
above follows it, and it is following the page's toggle rather than
your operating system's setting, which is the distinction the API exists
for.
## Reading it in a component
The shell reports the appearance and `ShellService` carries it, as an
Observable a component injects. Every example on this site is mounted
inside the root below. That is why the counter on
[your first component](https://gesso-docs.vercel.app/guide/counter), which sets no colours at all,
is legible in both appearances:
```tsx
/**
* The root every live example on this site is mounted inside.
*
* A canvas inherits nothing from the page: no stylesheet reaches it, so
* an example that sets no colours would paint the default theme's black
* text whatever appearance the page is in, which is invisible against a
* dark one. This is the eight lines that fix that for every example at once,
* and it is also the shape an application uses.
*
* `theme` is an environment value: provided here, inherited by
* everything below, and, because it is an ordinary prop that accepts an
* Observable, rebound rather than rebuilt when the appearance changes.
* `lightTheme` and `darkTheme` ship with `gesso-core`; this site maps
* the signal onto a palette of its own instead, which is what any
* application with a brand does. See `brandTheme.ts`.
*
* `textStyle` has to go with it. A theme's palette answers a colour
* *token*, as in `backgroundColor="surface"`, but text that names no colour
* at all takes it from the type scale in the environment, not from the
* theme, so a root that provides only `theme` leaves every unstyled
* line painting the default black.
*/
function ExampleRoot(inputs: Inputs<{ content: UiChild }>, ctx: ComponentContext) {
const theme = ctx
.inject(ShellService)
.colorScheme.pipe(map(scheme => (scheme === 'dark' ? brandDarkTheme : brandLightTheme)));
return (
value.typography.body))}
backgroundColor="background"
width={percent(100)}
height={percent(100)}>
{inputs.content}
);
}
```
`theme` is an environment value: provided at one node and inherited by
everything below it. And because it is an ordinary prop that accepts an
Observable, mapping the scheme onto a theme _is_ the binding. Switching
appearance writes new colours onto the nodes that are already there. No
component function runs again and nothing is rebuilt, which is why a
large screen changes appearance in one frame.
`lightTheme` and `darkTheme` ship with `gesso-core`. They are a
starting point, not a policy: the framework reports which of the two
appearances the platform is in and has no opinion beyond that, so an
application with its own palette maps the same signal onto that
instead.
This site is one of those applications, which is why the pair above is
`brandLightTheme` and `brandDarkTheme` rather than the two that ship
with the package. They answer the same token names in Gesso's own five
colours, so the canvas on this page is painted out of the same palette
as the prose beside it. A palette is an ordinary value in application
code: `src/examples/brandTheme.ts` is the whole of it, and swapping
which one the line above reaches for is the whole of adopting it.
## Telling the shell what to report
By default a shell follows `prefers-color-scheme` and reports what it
says. A host with a control of its own overrides that:
```ts
const app = createApp({
renderWorker: () => new Worker(new URL('./app.render.worker.ts', import.meta.url), { type: 'module' }),
colorScheme: 'dark' // 'auto' (the default), 'light', or 'dark'
});
// …and later, when the reader changes their mind:
app.setColorScheme('light');
```
This page does exactly that: it passes VitePress's current appearance
when the example is created, and calls `setColorScheme` whenever the
toggle moves. Passing it up front rather than correcting it after
mounting is what keeps the first frame from flashing the appearance the
reader did not choose.
`GessoApp` and `createSyncApp(Root)` take the same option and the same
method, so the single-thread configuration behaves identically.
## The other two settings
Light and dark is one axis of an appearance. `UiTheme` carries two
more, and they combine with it rather than forking it:
```ts
const theme = derive([scheme, settings.density, settings.contrast], (dark, density, contrast) => {
const base = dark ? appDarkTheme : appLightTheme;
const packed = withDensity(base, density);
return contrast === 'high' ? withContrast(packed, 'high') : packed;
});
```
`withDensity` scales the spacing scale and nothing else, so a compact
setting tightens every box on the screen and leaves the type alone.
`withContrast` raises every foreground until it clears a 7:1 ratio
against the surface it is drawn on, which is what a person who has
turned the operating system's high contrast setting on is asking for.
Both are described in [themes and the
environment](https://gesso-docs.vercel.app/appearance/themes-and-the-environment).
What the operating system asks for is reported, as the colour scheme
is: `shell.contrast` is `high` while the person has turned up contrast
(`prefers-contrast: more`) or turned on a contrast theme (Windows'
forced colours, which a canvas doesn't get from the browser), and
`standard` otherwise. Following it is one more input to the theme:
```ts
const theme = derive([shell.colorScheme, shell.contrast], (scheme, contrast) =>
withContrast(scheme === 'dark' ? appDarkTheme : appLightTheme, contrast)
);
```
Density has no platform setting to follow, and whether an application
offers its own control for either, and where it remembers it, is the
application's, for the reason the section below gives.
## An app with its own setting
An application that wants its own light/dark/auto control keeps that
choice as application state, on a channel or in a store like any other
preference that outlives a screen, and combines it with what the shell
reports:
```ts
const scheme = computed(() =>
settings.appearance.value === 'auto' ? shell.colorScheme.value : settings.appearance.value
);
```
The framework deliberately does not do this for you. Remembering a
person's choice is application state, and where it is stored, whether
that is a file, a database, or `localStorage` behind a shell request, is
the application's decision.
---
# State and services
A [cell](https://gesso-docs.vercel.app/guide/cells-and-bindings) holds what a component owns.
Everything else has to live somewhere a component can reach: the value
two screens share, the value that outlives the screen that made it, the
value something on another thread is authoritative about.
There are two places for it, and one question picks between them.
| What the value is | Where it goes |
| ---------------------------------------------------- | ------------------------------------------------------------------- |
| One component's own | `internalState` in the body |
| A component's and its children's | The same cell, passed down as a prop |
| A whole subtree's, without prop drilling | An environment value, the way `theme` is provided |
| Shared between screens, and never leaving the thread | A **service**: a class the runtime constructs once and hands out |
| Owned elsewhere, or outliving the screen | A **channel**: a declared barrier with data on the other side of it |
The last two rows are this page. The test between them is not how
important the value is; it is whether it crosses a thread.
Two panels, no props between them. Pressing `+` sends a command to the
basket and the summary is redrawn by the patch that comes back.
Clicking a name writes a service, and the summary reads the same
object.
## A service is a class the runtime hands out
Register it once, where the app is created, and inject it wherever it
is wanted:
```ts
renderRoot(AppRoot).useService(Highlight);
```
```tsx
/**
* Which line the reader is looking at.
*
* View state: it never leaves this thread, nothing outside the screen
* cares about it, and it does not survive a reload. So it is a plain
* class the runtime constructs once and hands to whoever injects it,
* and `internalState` outside a component is only a `BehaviorSubject`
* with a `.value` setter.
*/
export class Highlight {
readonly id = internalState(null);
}
```
`internalState` is not tied to a component. It is a `BehaviorSubject`
with a `.value` setter, so a service can hold one and a binding can
read it directly, which is how the runtime's own services expose state.
A component asks for the class and gets the instance:
```ts
const highlight = ctx.inject(Highlight);
```
A class component uses the decorator form instead, which is the same
registry: `@Inject(Highlight) highlight!: Highlight`.
The runtime registers its own services the same way, so `ctx.inject`
reaches overlays, focus, find, media, animation, the router, and
`ShellService` for the clipboard, URLs and the appearance. Injecting a
class nobody registered throws while the component is being built,
naming the class and listing every service that is registered.
### When a plain service is enough
A service is right when all of these hold:
- The value never leaves the render thread.
- Losing it on a reload is correct rather than a bug.
- Nothing outside the running UI is authoritative about it.
Which selection is active, which overlay is open, which panel is
expanded, a draft the reader has not committed: view state. Putting it
in a service costs nothing and reads as an ordinary object.
A change to a service's own code does not survive a hot replacement
either. The instance does, so the value it holds is kept, but its
methods are still the old code.
[Hot module replacement](https://gesso-docs.vercel.app/tooling/hot-module-replacement) says what to
hand over and what stays stale.
## A channel is the barrier, declared once
When the state crosses a thread, the two sides cannot share an object,
and the thing they share instead is a token: a name, a shape, and the
value every key holds before anything has been sent.
```tsx
/** One line of the basket, already shaped for the screen. */
export interface BasketLine {
readonly id: string;
readonly name: string;
readonly quantity: number;
}
/** What the screen may read. Every key holds plain data. */
export interface BasketView {
readonly lines: readonly BasketLine[];
readonly total: string;
}
/** What the screen may ask for. No return values: the effect comes back as a patch. */
export interface BasketCommands {
add(id: string): void;
remove(id: string): void;
}
/**
* The barrier, declared once and imported by both sides.
*
* A token is a name, a shape and an initial value, and no
* implementation at all, so the module holding it has nothing in it to
* bundle. The initial value is what the screen shows before the first
* patch, which is why a view key is never `undefined`.
*/
export const Basket = channel('docs-basket', { lines: [], total: '$0.00' });
```
The module holding it has no implementation in it, which is the point.
Both threads import the token; only one of them imports the code
behind it, so an app's api client, its repository and its domain
models never reach the render worker.
### The side that owns the data
Whatever produces the values is the application's own business. The
framework sees observables of plain data and nothing else:
```tsx
/**
* The application behind the channel: plain classes and plain RxJS,
* with no framework import and nothing that knows a screen exists.
*
* Formatting `total` here rather than in the view is deliberate. The
* layer holding the numbers is the last one with the whole value in
* hand, and a string costs one small patch where a recomputed number
* would cost the same patch plus a `map` on every binding that reads
* it.
*/
export class BasketModel {
private readonly counts = new BehaviorSubject>>({ brush: 1 });
readonly lines: Observable = this.counts.pipe(
map(counts => CATALOGUE.map(item => ({ id: item.id, name: item.name, quantity: counts[item.id] ?? 0 })))
);
readonly total: Observable = this.counts.pipe(
map(counts => `$${CATALOGUE.reduce((sum, item) => sum + item.price * (counts[item.id] ?? 0), 0).toFixed(2)}`)
);
add(id: string): void {
const counts = this.counts.value;
this.counts.next({ ...counts, [id]: (counts[id] ?? 0) + 1 });
}
remove(id: string): void {
const counts = this.counts.value;
this.counts.next({ ...counts, [id]: Math.max(0, (counts[id] ?? 0) - 1) });
}
}
```
One Observable per view key, one handler per command, and that object
is the entire seam:
```tsx
/**
* What the owning side supplies: one Observable per view key, one
* handler per command. Registering it is the only place the two halves
* of this file meet.
*/
export function basketSource() {
const basket = new BasketModel();
return {
view: { lines: basket.lines, total: basket.total },
commands: {
add: (id: string) => basket.add(id),
remove: (id: string) => basket.remove(id)
}
};
}
```
Above that line there is no framework import: plain classes and plain
RxJS, testable with bare vitest. The framework defines the barrier and
has no opinion at all about what is behind it. An api layer, a
repository, a domain model and a view model is one arrangement; a
single subject is another; nothing here can tell the difference.
### The side that reads it
`ctx.channel(token)` returns the replica, which runs none of the
application's logic. It holds the latest value of each view key and
forwards commands:
```tsx
/**
* The second screen. It reads the same two view keys and the same
* service as the first, and knows where neither of them lives.
*
* `basket.view.total` is an `InputCell`, so it binds like any prop.
* `items` is derived from `lines` and is therefore not stored
* anywhere, and `looking` is one expression over a view key and a
* service.
*/
function Summary(_inputs: Inputs<{}>, ctx: ComponentContext) {
const basket = ctx.channel(Basket);
const highlight = ctx.inject(Highlight);
const items = basket.view.lines.pipe(map(lines => lines.reduce((sum, line) => sum + line.quantity, 0)));
const looking = combineLatest([basket.view.lines, highlight.id]).pipe(
map(([lines, id]) => lines.find(line => line.id === id)?.name ?? 'nothing yet')
);
return (
`${count} in the basket`))} fontSize={12} color="textMuted" />
`Looking at ${name}`))} fontSize={12} color="textMuted" />
);
}
```
Every view key is an `InputCell`, exactly like a prop. A component
reads it, binds it, and cannot write it, and it makes no difference to
the component whether the value came from a parent or from across a
barrier. The class form is `@Channel(Basket) basket!: ChannelReplica`.
To change something, send a command:
```tsx
/**
* One line, holding both kinds of state at once.
*
* The name button writes the service directly, because the value stays
* here. The two step buttons send a command, because the quantity
* belongs to whoever owns the basket.
*/
function Line(inputs: Inputs<{ line: BasketLine }>, ctx: ComponentContext) {
const basket = ctx.channel(Basket);
const highlight = ctx.inject(Highlight);
const background = combineLatest([inputs.line, highlight.id]).pipe(
map(([line, id]) => (line.id === id ? 'controlBackground' : 'transparent'))
);
return (
`Look at ${line.name}`))}
onClick={() => (highlight.id.value = inputs.line.value.id)}
flex={1}
// A list row, so its name starts at the left like the others rather than centring.
x="start"
padding={6}
borderRadius={6}
backgroundColor={background}
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
line.name))} fontSize={13} color="text" />
String(line.quantity)))}
fontSize={13}
color="textMuted"
width={18}
textAlign="center"
/>
`One fewer ${line.name}`))}
glyph="−"
onPress={() => basket.send.remove(inputs.line.value.id)}
/>
`One more ${line.name}`))}
glyph="+"
onPress={() => basket.send.add(inputs.line.value.id)}
/>
);
}
```
### Registering it
Where the data lives is decided at registration, in one place, by the
option passed:
| Registration | Where the data lives |
| --------------------------------- | ---------------------------------------------- |
| `.useChannel(Basket)` | The application worker the shell spawned |
| `.useChannel(Basket, { worker })` | A worker of this channel's own |
| `.useChannel(Basket, { source })` | This thread, over a `MessageChannel` to itself |
```ts
/**
* The render worker behind ` `.
*
* One service and one channel, which is the whole taxonomy in two
* lines. The channel is registered with a `source`, so its data lives
* on this thread; naming a `worker` instead moves it, and nothing in
* `StateExample.tsx` changes.
*/
renderRoot(exampleRoot(createComponent(StateScreen, {})))
.useService(Highlight)
.useChannel(Basket, { source: basketSource() });
```
A `source` registration still crosses a real port, and is diffed,
patched and plain-data checked exactly like a worker's. That is what
makes the choice a one-line change later: the code above never learns
which it got.
The first row needs an application worker to exist. A single-thread app
has none, and so does a worker-hosted app started without
`appLogicWorker`, and a channel registered there with neither a
`worker` nor a `source` throws at startup saying which of the three to
pass.
## What a command costs, and what it does not do
A command is fire and forget. It takes as many arguments as it needs,
each is structured-cloned onto the owning thread, and it returns
nothing. There is no synchronous answer to be had across a thread, so
the effect arrives as a patch on the view keys it changed.
The spec beside this example measures both halves of that. After a
click on `+`, a frame drawn in the same turn still shows the old total,
because the message has not been delivered yet. The new total appears
once the round trip is done. A click on a name, which writes a service,
is on screen in the very next frame.
So a value written and read back in the same handler has to be a cell
or a service. Nothing across a barrier answers that fast, and a command
that looks like it did would be lying about the thread model.
## Limits worth knowing before the first channel
- **Only plain data crosses.** Primitives, arrays and plain objects. A
`Date`, a `Map`, a `Set` or a class instance compares by reference,
so it would report a change on every update and rebuild the subtree
bound to it forever. The first value of each key is checked and the
channel reports an error naming the key and the path inside it.
Flatten it in the layer that owns it. A `SharedArrayBuffer` is the
exception: it is shared memory, compared by identity, and crosses
without being copied.
- **Declare keys finely.** A key holding a large array is re-diffed
whenever it changes. The differ trims a common prefix and suffix, so
an append or an edit in place stays small, while a re-sort degrades
to replacing the middle. Two keys that change for different reasons
are two keys.
- **There is no undefined window, and no implied loading state.** The
token's initial value is what the screen draws until the first patch,
so a channel that is still loading says so in its own shape, with a
`status` key, rather than leaving the view to infer it from an
absence.
- **A service is per runtime and per thread.** There is exactly one
instance, it is reachable only from components in that runtime, and
it goes away with the app. Nothing about it is shared with another
thread.
- **Channels are registered before the runtime starts.** Registering
one afterwards throws, and so does asking for a channel that was
never registered.
This page's example registers its channel with a `source`, so its data
lives in the render worker beside the components, and everything above
was measured in that configuration. The two-worker arrangement changes
the registration line and nothing else in these files, which is a
property of the API rather than something this page measured.
## Next
[The action log](https://gesso-docs.vercel.app/tooling/the-action-log) records every command and
every patch crossing a channel, which is the fastest way to see the
round trip above as a timeline.
---
# Workers
The worker configuration is the one to build on, and it is two files
rather than one: [installation](https://gesso-docs.vercel.app/guide/installation) sets those up. This
page is about what happens after that. Where your code ends up, what the
main thread is still doing on its behalf, and which habits stop working
once a component can no longer see the page it is drawn on.
## What runs where
| Thread | Owns |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Shell** (main) | The `` element, input forwarding, the hidden textarea an IME types into, the off-screen DOM a screen reader reads, resize and device pixel ratio, the display's refresh, the url |
| **Render worker** | Components, cells, the retained node graph, layout, hit-testing, focus, gestures, text measurement, paint, and the runtime services |
| **Application worker** | Your api, persistence, domain model and view models, published as channels |
Everything you write as a component runs in the render worker. So does
every layout pass and every draw call, so a long task on the main thread
cannot make a frame late; that is the whole of the arrangement's
argument.
The shell is deliberately as small as it can be. It forwards raw
coordinates and does not know what they hit: hit-testing, focus and
gesture recognition all happen in the worker, on the tree that is there.
What is left on the main thread is what genuinely cannot run anywhere
else, and the list is short enough to read in full further down.
The application worker is optional and separate. The shell spawns it,
creates one `MessageChannel` between it and the render worker, and then
holds neither end, so a patch from a view model never crosses the main
thread. State and channels are their own subject; what matters here is
that the data path does not pass through the thread this design exists
to keep out of the way.
## What the split buys
The same component, twice. On the left it is in a render worker; on the
right it is mounted with `mountSync`, so it runs where this page's
JavaScript runs. Block the main thread and watch which one keeps its
frame:
Each canvas times itself and draws its own worst frame gap, because a
reading that had to cross to the main thread could not be trusted while
the main thread is the thing being blocked.
Three measurements behind that, all taken in Chrome:
- **The main thread blocked for 2000 ms.** The worker route's worst
frame gap was 101 ms; the same application on the single-thread route
went to 2098 ms, which is the block itself.
- **The shell busy-looped for 5000 ms, with a click delivered during
it.** The render worker reported the click as 2818 ms late, and its
worst frame gap stayed at 105 ms against a 106 ms baseline taken
before the block. A blocked shell costs input latency and nothing
else: the person waits, and the frame clock never notices.
- **A 1500 ms burn inside a data worker.** The render thread's worst
frame gap stayed at 106 ms.
Read those as differences, not as frame times. Frames here are
demand-driven, so a screen with nothing to redraw leaves gaps of its own
and a baseline of 106 ms is a screen that was idle, not a screen that
was struggling. The claim is that the number does not move when another
thread is buried.
The input latency in the second measurement is the honest cost. Nothing
about this arrangement makes a blocked main thread harmless; it makes it
stop being a rendering problem and start being a routing delay, which is
why heavy work belongs in a worker of its own rather than on the shell.
## Moving an app in
A component class cannot cross `postMessage`, so the root has to be
named inside the worker that renders it. That single constraint is why
an application has three files rather than two, and it is the only part
of the move that is not mechanical.
```ts
// main.ts: the whole of the main thread's own code
createApp({
renderWorker: () => new Worker(new URL('./app.render.worker.ts', import.meta.url), { type: 'module' }),
appLogicWorker: () => new Worker(new URL('./app.logic.worker.ts', import.meta.url), { type: 'module' })
}).mount('#app');
```
```ts
// app.render.worker.ts: everything the person sees
renderRoot(App).useChannel(Catalog).useService(Selection).useRoutes({ routes: ROUTES });
```
Both worker constructors are written out literally because a bundler
only emits a chunk for a worker it can see constructed; a URL held in a
variable produces no chunk. `renderRoot` returns the application, and
everything registered on it is registered inside the worker:
| Call | For |
| ------------------- | ---------------------------------------------------------------------------------------------------------- |
| `useChannel(token)` | A channel, served by the application worker the shell spawned, or by a worker or source named here |
| `useService(Class)` | A runtime service: a plain class this thread constructs once and hands to whatever injects it |
| `useRoutes(routes)` | The routing table, which holds component classes and so could never have been given to the shell |
| `reload(root)` | A new root, for hot module replacement; the channels, the services and their state survive the replacement |
The shell side takes options rather than registrations, because it has
nothing of yours to hold: `renderer` chooses the backend, `onFrame` and
`onError` are where metrics and worker exceptions arrive, `onInspect`
feeds the node inspector, `colorScheme` overrides the platform's
appearance for a host with its own control, `history` says how the url
is kept, and `accessibility` can switch off the off-screen mirror for a
measurement. [Devtools](https://gesso-docs.vercel.app/tooling/devtools) shows those callbacks wired
to something that uses them.
An error thrown inside a worker is invisible to the page by default, so
`onError` is the option to pass first. The worker reports what it caught
while handling a message, what a frame threw, what the renderer refused
and what a channel rejected, with a `source` saying which.
## What the shell still owns
Everything on the canvas below runs in a render worker, and three of the
things it does needed the main thread to do them:
```tsx
/**
* A screen in the render worker, and the three things it still asks the
* shell for.
*
* **Keys.** The field is drawn on the canvas and holds no DOM focus of
* its own. What gives it characters is a hidden textarea on the main
* thread, which the shell focuses while the worker reports a focused
* editable and which reports composition, so an IME works here.
*
* **The clipboard.** A worker has none. `ShellService.copyText` hands
* the request to the runtime, which posts it to the shell, which does
* the write. Every reach for the outside world takes that shape: the
* component asks, and something on the other thread answers.
*
* **The appearance.** `prefers-color-scheme` is a media query, and a
* media query needs a window. The shell watches it and reports what it
* says; `colorScheme` is where the application hears about it, and it
* is what this site's light and dark toggle drives.
*/
export function ShellNeeds(_inputs: Inputs<{}>, ctx: ComponentContext) {
const shell = ctx.inject(ShellService);
const message = internalState('Typed into a canvas in a worker');
const copies = internalState(0);
return (
(message.value = next)}
description="Every keystroke crossed a thread to get here."
/>
{
shell.copyText(message.value);
copies.value++;
}}
padding={8}
paddingLeft={12}
paddingRight={12}
borderRadius={6}
borderWidth={1}
borderColor="controlBorder"
backgroundColor="controlBackground"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
(count === 0 ? 'no request yet' : `${count} request${count === 1 ? '' : 's'} to the shell`))
)}
fontSize={12}
color="textMuted"
/>
`the shell reports ${scheme}`))}
fontSize={12}
color="textMuted"
/>
);
}
```
The line above the field is `typeof document`, evaluated once where the
component was built. A component that reached for the page would not
fail at some awkward later moment; it would fail there.
That leaves the shell with this, and nothing else:
| The shell does | Because |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Creates the canvas and transfers it | An `OffscreenCanvas` has to come from an element, and only the page has elements |
| Forwards pointer, wheel and key events | The listeners are on a DOM element; every event is stamped with the browser's own `timeStamp` |
| Holds a hidden textarea | Composition, dead keys and a soft keyboard are a DOM input's, and the caret is in the worker |
| Mirrors the semantics tree into off-screen DOM | The platform's accessibility layer reads elements, and a canvas is one element |
| Observes the host's size and the pixel ratio | `ResizeObserver` and `devicePixelRatio` are the window's |
| Forwards display refreshes, where it must | A dedicated worker has its own frame callback and uses it; this is the fallback for where it does not |
| Watches `prefers-color-scheme` and reduced motion | A media query needs a window; each is reported once at start and again on every change |
| Watches whether the window is in use | Focus and visibility are the window's; `ShellService.windowActive` is true while it is shown and focused |
| Sets the cursor and `touch-action` on the canvas | The worker decides what the pointer is over; only the DOM can show it |
| Writes the clipboard and opens urls | `ShellService` requests them, and the request is answered on the thread that can perform it |
| Reports the url and performs history moves | The address bar is the window's; the routes never leave the worker |
Two defaults are cancelled by the shell without asking, because the
worker's answer could not come back in time to cancel anything: Tab,
which would move focus out of the canvas, and Ctrl/Cmd+A, which would
select the page around an application selecting its own text. The wheel
is decided from what the worker last reported about the scroll chain
under the pointer, which is a bet that can swallow one notch at a
container's edge, and it is the reason a canvas does not simply eat
every scroll.
## What changes for you
**There is no DOM.** No `document`, no `window`, no `localStorage`, and
no library that reaches for one. A component that needs something from
the page asks the shell for it, the way the copy button does above.
This is worth knowing before you pick a dependency, not after.
**Only plain data crosses.** Primitives, arrays and plain objects.
A `Date`, a `Map`, a class instance or a domain object compares by
reference across the barrier, which reports "changed" every time it is
evaluated, so view models flatten to plain shapes before they publish.
The one exception is a `SharedArrayBuffer`, which is shared rather than
copied, so two threads can read one large document without sending it
([Channels and the barrier](https://gesso-docs.vercel.app/structure/channels-and-the-barrier#only-plain-data-crosses)).
Nothing else ever crosses at all: elements, components, observables and
nodes are built in the render worker and stay there.
**State goes where its lifetime is.** A value one component owns is
`internalState`. A value a subtree needs is a prop, or an environment
key for something that changes rarely. View state the whole render
thread shares is a runtime service, registered with `useService`.
Anything that survives a reload, or that another screen cares about, is
a channel and lives in the application worker. [State and
services](https://gesso-docs.vercel.app/guide/state-and-services) walks that ladder rung by rung. The
rule to hold onto is that a hover highlight must never round-trip to
another thread.
**Fonts are the page's, not the worker's.** A family is a string handed
to the renderer, and nothing in these packages loads a font. The
examples on this site ask for system families for that reason. A custom
face in the worker configuration is untested here, so treat it as
something to verify in your own project rather than as something that
works.
## When one thread is the right answer
`mountSync` mounts the identical tree on the calling thread, and both
configurations run the same runtime, so a screen written for one runs
unchanged on the other:
```ts
createSyncApp(App).mountSync('#app');
```
Reach for it in tests, in headless rendering, and where
`OffscreenCanvas` is not available. It is not the default for an
interactive application, because everything the framework does is then
competing with everything else on the main thread, which is what the
right-hand canvas above is demonstrating. Mounting the worker
configuration without `OffscreenCanvas` throws, and the message names
`mountSync` as the way out.
## Limits
**Where this was checked.** Every number on this page was measured in
Chrome, on the playground's own routes, by blocking a thread on purpose
and reading what each thread said about itself. No other browser has run
any of it, and the desktop webviews the framework is aimed at, WKWebView
and WebView2, have not been measured at all.
**A 5000 ms busy loop is a mechanism, not a magnitude.** It proves that
a blocked shell costs latency rather than frames. What a real
application layer costs the thread it runs on is a different question,
and it is the one that would decide where to put yours.
**The example above is checked without a browser.** Its spec mounts the
same component with `gesso-testing`, in a runtime with no DOM, and
asserts the four claims the page makes about it: that the component runs
where there is no `document`, that typed text reaches it through the
editing path a shell forwards on, that the copy button issues a
clipboard request instead of performing one, and that the appearance
line follows what the shell reported. What a spec cannot check is the
paint, and this canvas was not watched in a browser while the page was
written, so treat it as the least verified thing here.
## Next
[Devtools](https://gesso-docs.vercel.app/tooling/devtools) is how you see into a worker at all: an
error overlay that draws a worker's exceptions where the application is,
a node inspector, and a frame profiler fed by the metrics the worker
reports.
---
# Testing
A Gesso component is a function that returns a tree, and the tree is not
the DOM, so there is nothing here for jsdom to do. `gesso-testing`
mounts the real runtime over a recording canvas and hands back queries,
events and layout answers. No browser, no canvas double to write, no
headless Chrome to start, and no setup file: vitest's default node
environment is the one these run in.
This page has no live canvas on it, because the thing it describes is a
file rather than a screen. Everything quoted below comes from
`TestingExample.spec.ts` in this site's own examples folder, which runs
in this repository's suite. The page's code is the spec.
## The component under test
A quantity row: two stepper buttons, a checkbox, and a line that says
what the order now is.
```tsx
/** The width the count sits in, so the row does not move when 9 becomes 10. */
const COUNT_WIDTH = 36;
const MAX_TICKETS = 6;
/**
* A quantity row: two steppers, a checkbox, and a line that says what
* the order now is.
*
* Nothing here is written for a test. The two buttons carry a `label`
* because `-` and `+` are not names a person can act on, the checkbox
* is a real control with a keyboard of its own, and the summary is
* derived from both cells rather than written out twice. Those three
* properties are also what makes the spec beside this file short.
*/
export function TicketRow(_inputs: Inputs<{}>, _context: ComponentContext) {
const tickets = internalState(1);
const wrapped = internalState(false);
const summary = combineLatest([tickets, wrapped]).pipe(
map(([count, gift]) => `${count} ticket${count === 1 ? '' : 's'}${gift ? ', gift wrapped' : ''}`)
);
const step = (by: number) => () => {
tickets.value = Math.min(MAX_TICKETS, Math.max(0, tickets.value + by));
};
return (
count === 0))}
padding={6}
borderRadius={6}
backgroundColor="controlBackground"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
count === MAX_TICKETS))}
padding={6}
borderRadius={6}
backgroundColor="controlBackground"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
(wrapped.value = next)} />
);
}
```
## Mounting
Four imports and one call.
```ts
import { describe, expect, it } from 'vitest';
import { createComponent } from 'gesso-framework';
import { renderTest } from 'gesso-testing';
import 'gesso-testing/matchers';
import { TicketRow } from './TestingExample';
```
```ts
/**
* One mount, shared by every test below.
*
* `renderTest` builds the runtime over a recording canvas, measures
* text with the deterministic measurer, and runs the first frame, so
* the tree is built, laid out and described before the first query.
*/
const mount = () => renderTest(createComponent(TicketRow, {}), { width: 420, height: 220 });
```
`renderTest` takes the same options as the runtime, minus the three it
decides itself, plus `autoFrame`, `allowFrameErrors` and an `onCreate`
hook. Those three are
decided rather than left to the caller because getting any of them wrong
makes a test that passes for the wrong reason:
- **The clock is manual.** Frames happen when the spec says so, so an
assertion never races a scheduler.
- **Text is measured by `CharacterCountTextMeasurer`,** not by the
canvas double. Every glyph is 0.6 em wide, the font is 0.8 em above
the baseline and 0.2 em below it, and a line is 1.2 em, so a 14 px
label is 16.8 px tall on every machine. A canvas double's
`measureText` answers the same width whatever the font size, which
would make a heading and its caption the same size.
- **A first frame has already run** when the call returns, so the tree
is built, laid out and described before the first query.
- **A frame that throws fails the test.** An application abandons such a
frame and keeps drawing, so the user sees the last good picture; a
test that did the same would pass on a frame that never ran. The
error is thrown from the `frame()` or `settle()` that ran the frame.
Pass `allowFrameErrors: true` for a test about that recovery, and
listen with `runtime.onFrameError`.
Pass `textMeasurer` if you want different metrics, and `autoFrame:
false` if the spec needs to be holding the result when the first frame
happens.
`gesso-testing` imports no test runner, so `renderTest` works under
vitest, under `node:test`, or in a plain script. The matchers are the
exception: `expect.extend` is a side effect on a global, so they live
behind their own entry and importing them is a decision you make.
`vitest` is an optional peer dependency of the package for exactly that
reason.
## Frames are yours
Nothing repaints on its own. `ui.frame()` runs the pending frame and
advances the clock 16 ms; `ui.frame(200)` sets the time instead. The
pattern is always the same: send an event, run a frame, assert.
For anything that arrives later than the next frame, a channel patch, a
resolved image, a component that awaited something, use `await
ui.settle()`. It runs a macrotask and a frame in a loop until nothing is
pending, and throws rather than spinning forever if the tree keeps
asking for another frame. The `findBy…` queries are the same loop with a
query in it. Both count frames rather than milliseconds, because the
clock here is manual and there is no wall clock to time out against: "a
hundred frames and it is still not there" reproduces in a way that "two
seconds" does not.
## Queries are the semantics tree
`getByRole` and `getByLabel` read `runtime.semanticsTree()`, the same map
the accessibility mirror writes into the off-screen DOM for a screen
reader. There is no second definition of what a control is, which makes
the property run both ways: a component that is awkward to find in a
spec is exactly a component that is awkward to find with an assistive
technology, and an untestable component is an inaccessible one.
```ts
it('is reachable by role and name, and nothing else is announced', () => {
const ui = mount();
expect(ui.getByRole('button', { name: 'One more ticket' })).toHaveSemantics({ role: 'button' });
expect(ui.getByRole('checkbox', { name: 'Gift wrap this order' })).toHaveSemantics({ states: [] });
// The glyph is not the name. A query for it finds nothing, which is
// the answer a screen reader would give too.
expect(ui.queryByRole('button', { name: '+' })).toBeNull();
// `getByText` reads what a node draws, so it finds the summary line
// that has no role of its own.
expect(ui.getByText('1 ticket')).toBeDefined();
});
```
`getByText` is deliberately separate and reads the text a node draws,
because the two differ. The `` inside a `` is claimed as
the button's accessible name and gets no record of its own, so
`getByLabel('Save')` finds the button and `getByText('Save')` finds the
node that draws.
| Query | Finds |
| ------------------------ | ---------------------------------------------------------- |
| `getByRole(role, opts)` | By role, with `name`, `states` and `disabled` to narrow it |
| `getByLabel(name)` | By accessible name, whatever the role |
| `getByText(text)` | By the text a node actually draws |
| `allNodes()`, `textOf()` | The escape hatch: every node, or every string, in order |
Each of the first three has four variants, and the difference between
them is what happens when the count is not one:
| Variant | No match | One match | Many matches |
| ----------- | ------------------- | --------- | ------------ |
| `getBy…` | throws | the node | throws |
| `queryBy…` | `null` | the node | throws |
| `getAllBy…` | throws | `[node]` | every match |
| `findBy…` | frames, then throws | the node | throws |
`getBy…` throwing on two matches is the point of it, not a rough edge. A
query that quietly returns the first of several is a test that will one
day assert about a different node than its author thinks. A name or a
`{ name: … }` option is what disambiguates.
A string argument matches exactly, after runs of whitespace are
collapsed. A regular expression is tested against the same collapsed
string, which is what to reach for when only part of the text is stable.
## Pointer and keyboard
`fireEvent` comes off the render rather than being a free function,
because a Gesso runtime is not a global: two can be mounted in one file
at once. Everything on it goes through the same controllers a real
pointer and a real keyboard go through, which is the whole point. A spec
that called a component's handler directly would pass while the
component was unreachable by mouse, by keyboard and by screen reader.
```ts
it('counts up when the stepper is clicked', () => {
const ui = mount();
ui.fireEvent.click(ui.getByRole('button', { name: 'One more ticket' }));
ui.frame();
expect(ui.getByText('2 tickets')).toBeDefined();
});
```
`click(node)` dispatches to a node without a hit test, because a node is
what a query returns and whether the pixel at (x, y) lands on it is the
hit tester's business. Use `pointerDown` / `pointerMove` / `pointerUp`
when the coordinate path is what you are testing.
Keys go to whatever has focus, so a keyboard test focuses first:
```ts
it('ticks the checkbox from the keyboard alone', () => {
const ui = mount();
const gift = ui.getByRole('checkbox', { name: 'Gift wrap this order' });
ui.fireEvent.focus(gift);
ui.fireEvent.press(' ');
ui.frame();
expect(gift).toHaveFocus();
expect(gift).toHaveSemantics({ states: ['checked'] });
expect(ui.getByText('1 ticket, gift wrapped')).toBeDefined();
});
```
| Call | What it sends |
| ---------------------------------------------- | --------------------------------------------- |
| `click(node)`, `pressDown`, `pressUp` | A click, or a press without the release |
| `pan(node, x, y)` | A press and move: grabbing a thumb or divider |
| `pointerDown/Move/Up(x, y)`, `wheel(…)` | The coordinate path, through the hit tester |
| `press(key)`, `keyDown`, `keyUp` | A key at whatever has focus |
| `focus(node)`, `blur()`, `tab()`, `shiftTab()` | Focus, and the tab order |
| `type(text)`, `paste(text)` | An insertion into the focused editable |
| `beforeInput(type, data)`, `composition*` | An edit or a composition, as the proxy sends |
| `copy()`, `cut()` | What a copy or a cut would take |
All of them take modifiers where a real event would carry them:
`ui.fireEvent.press('a', { meta: true })`.
### Disabled is inert, not grey
A disabled node and its subtree take no pointer event and no key, which
is worth asserting rather than assuming:
```ts
it('stops at zero, and is announced as unavailable rather than going quiet', () => {
const ui = mount();
const fewer = ui.getByRole('button', { name: 'One fewer ticket' });
ui.fireEvent.click(fewer);
ui.frame();
expect(ui.getByText('0 tickets')).toBeDefined();
expect(fewer).toHaveSemantics({ disabled: true });
// Disabled means inert, not merely grey: the click does not reach
// the handler, so the count cannot go negative.
ui.fireEvent.click(fewer);
ui.frame();
expect(ui.getByText('0 tickets')).toBeDefined();
});
```
## Layout, and why the number is wrong
`ui.getLayout(node)` gives a node's border box in layout-root
coordinates, and `toHaveBox` asserts on the fields you name and says
nothing about the rest, so a test does not break because a sibling above
it grew by a pixel.
```ts
it('holds the count in a fixed width, so the row does not move as the number grows', () => {
const ui = mount();
const more = ui.getByRole('button', { name: 'One more ticket' });
const before = ui.getLayout(more).x;
expect(ui.getByText('1')).toHaveBox({ width: 36 });
for (let click = 0; click < 5; click++) {
ui.fireEvent.click(more);
ui.frame();
}
expect(ui.getByText('6')).toHaveBox({ width: 36 });
expect(ui.getLayout(more).x).toBe(before);
});
```
The matchers exist for what they print, not for what they compare:
`expect(ui.getLayout(node)).toEqual({ … })` already compares numbers.
Change that `36` to `24` and the failure carries the layout engine's own
explanation of the box next to the number that surprised you:
```text
Expected 'root:0:0:0:2' to have width 24, but its box is
{"x":124.4,"y":22,"width":36,"height":16.8}.
Why:
text 'root:0:0:0:2' · 36 × 16.8 at (124.4, 22)
width 36 width: 36 (explicit) → 36; flex item of row 'root:0:0:0':
kept its base 36 (flexGrow 0, so free space goes to others)
height 16.8 text needs 16.8 → 16.8
constraints from row 'root:0:0:0': width [0, ∞) · height [0, 28.8]
after own size props: width 36 (tight) · height [0, 28.8]
padding 0 · margin 0 · content box 36 × 16.8
relayout: not a boundary · content stays inside · a change here is laid
out from column 'root:0:0' (2 levels up)
state: measured · placed · position static
```
(Long lines are wrapped for this page; the explanation itself prints
one line per fact.) That is the same
answer the [node inspector](https://gesso-docs.vercel.app/tooling/inspecting-a-node) gives on a
canvas, arriving in a terminal. `ui.explainText(node)` prints it on
demand without a failing assertion, and `ui.explain(node)` returns it
as data.
| Matcher | Asserts |
| -------------------------- | ------------------------------------------- |
| `toHaveBox(partial)` | The border box, on the fields given |
| `toHaveText(string)` | The text the node itself draws |
| `toHaveSemantics(partial)` | Role, name, states, disabled, value |
| `toHaveFocus()` | The focus manager currently holds this node |
## When a query misses
A failed query prints two things before it throws: every record in the
semantics tree, as role and accessible name, and then the node tree.
`ui.debug()` prints the second of those on demand. Ask this row for a
`switch` and the whole of it comes back:
```text
Nothing matches role "switch".
The semantics tree has:
(no role) · "Tickets"
button · "One fewer ticket"
(no role) · "1"
button · "One more ticket"
checkbox · "Gift wrap this order"
(no role) · "1 ticket"
The node tree is:
box#root:0 [0,0 420×220]
column#root:0:0 [0,0 420×220]
row#root:0:0:0 [16,16 388×28.8]
text#root:0:0:0:0 name="Tickets" [16,22 72×16.8]
button#root:0:0:0:1 role=button name="One fewer ticket" [96,16 20.4×28.8]
text#root:0:0:0:1:0 text="-" [102,22 8.4×16.8]
text#root:0:0:0:2 name="1" [124.4,22 36×16.8]
button#root:0:0:0:3 role=button name="One more ticket" [168.4,16 20.4×28.8]
text#root:0:0:0:3:0 text="+" [174.4,22 8.4×16.8]
fragment#root:0:0:component:1 [0,0 0×0]
row#root:0:0:component:1:0 role=checkbox name="Gift wrap this order" [16,56.8 388×26]
box#root:0:0:component:1:0:0 [20,60.8 18×18]
text#root:0:0:component:1:0:0:0 [29,62 0×15.6]
text#root:0:0:component:1:0:1 text="Gift wrap this order" [46,61.4 168×16.8]
text#root:0:0:2 name="1 ticket" [16,94.8 388×14.4]
fragment#root:0:component:1 [0,0 0×0]
box#root:0:component:1:0 [0,0 420×220]
fragment#root:0:component:1:0:fragment:0 [0,0 0×0]
```
Every node is printed, including the ones with no semantics record,
because a `row` that exists only for layout is exactly the thing you
need to see is not in the tree an assistive technology reads. The answer
to "why did `getByRole('switch')` find nothing" is usually on the line
showing the node came out a `checkbox`.
The last three lines are the overlay layer. Every runtime's
layout root is a stack holding the application root and a layer for
dialogs, menus and tooltips to portal into, so it is there whether or
not anything has opened.
`ui.getSemantics(node)` returns one record and throws, with the tree,
when the node has none. That throw is nearly always the finding rather
than an inconvenience: nothing gives the node a role or a label, so no
assistive technology can see it.
## What else is on a render
| Member | What it is |
| ------------------------------------- | ------------------------------------------------- |
| `runtime` | The runtime itself, for anything not wrapped here |
| `frames` | The metrics for every frame that has run |
| `draws`, `clearDraws()` | The recorded canvas calls since the last clear |
| `semanticsTree()`, `querySemantics()` | The whole mirror, and one record or `null` |
| `unmount()` | Stops the runtime and releases what it holds |
## The wiring behind a channel
A component reads a channel; an application worker serves one. The
classes behind the worker, a catalogue, a queue, are plain code with
their own specs, and `serveForTest` covers what those specs cannot: the
lines between the classes and the barrier. It takes the same data
`serveChannels` takes and serves it over a real patch stream with no
worker, handing back the replicas a screen would bind to.
```ts
import { serveForTest } from 'gesso-testing';
const catalogue = new Catalogue(offlineApi());
const queue = new Queue(catalogue);
const served = serveForTest(transitionsChannels(catalogue, queue));
const player = served.get(Queue);
player.send.play({ playlistId: '2' });
await served.settle(() => player.view.current.value !== null);
expect(player.view.playlistId.value).toBe('2');
expect(served.errors).toEqual([]);
served.dispose();
```
`settle` waits for a condition to hold, or for the ports to drain when
given none. `errors` collects what a served channel reported: a command
nobody declared, a view key that is not plain data. The arrangement it
suggests is the one worth adopting: the worker file itself is four lines
that call a function returning the served channels, and the function is
what the spec imports.
## Where this stops
`renderTest` gives you the runtime, and stops exactly at the browser.
- **Nothing is painted.** The canvas is a recording context: `ui.draws`
is a list of the Canvas2D calls a frame made, so a spec can assert
that something was drawn and with which fill, but no pixels exist and
there is nothing to compare an image against. This repository checks
that separately with `pnpm screenshots`, which opens every playground
route in headless Chrome and diffs the canvas against a committed
baseline.
- **The renderer is Canvas2D.** WebGPU is never exercised here. Parity
between the two backends is its own gate, `pnpm parity:webgpu`, which
needs a headless Chrome with a WebGPU adapter and fails rather than
passes on a machine without one.
- **The metrics are not a font's.** A width from the deterministic
measurer is what the layout engine computes from 0.6 em glyphs, not
what Inter will produce in Chrome. Assert relationships (this got
taller, these two are the same width, the row did not move) rather
than pixel counts that only hold under this measurer.
- **No IME.** `type()` and `paste()` deliver an insertion the way the
editing proxy's `beforeinput` would. A composition can be driven
with `compositionStart`, `compositionUpdate` and `compositionEnd`,
but a real input method and
the browser's own composition events are not here. See
[text editing and IME](https://gesso-docs.vercel.app/interaction/text-editing-and-ime).
- **Still no screen reader.** Querying the semantics tree is querying
what the mirror writes, which is not the same thing as VoiceOver or
NVDA. This repository's `pnpm check:a11y` goes one step further and
reads Chrome's own computed accessibility tree, because an element
with the right attributes can still be ignored by the platform, and
that is still not a screen reader either. It fails a control that Tab
never reaches, unless the route lists it in `outOfTabOrder` with the
keyboard way to the same thing; the report prints each one.
The discipline that follows from all of that: a passing suite is
evidence that the tree, the events and the layout are right, and it is
not evidence that a screen renders. When you change something visual,
open it.
## Next
[Devtools](https://gesso-docs.vercel.app/tooling/devtools) is the other half of this. The node
inspector answers the same "why is this box wrong" question on a running
canvas that `toHaveBox` answers in a terminal.
---
# Flex in full
[Layout basics](https://gesso-docs.vercel.app/guide/layout-basics) covers the shape of the model:
``, `` and ``, the `x` and `y` props, typed lengths,
and the fact that the cross axis stretches by default. This page is the
rest of flexbox: what decides an item's main size, what happens when the
items do not fit, and where the model parts company with CSS.
## The element is the axis
CSS has one `display: flex` and a `flex-direction` to point it. Gesso
has two elements instead:
| Element | Main axis | Cross axis |
| ---------- | ---------- | ---------- |
| `` | Horizontal | Vertical |
| `` | Vertical | Horizontal |
The axis is the tag, not a property, so a screen's structure reads off
its markup. `direction` is still a property, and on a row or a column
it is read for one thing: a value ending in `-reverse`
(`'row-reverse'`, `'column-reverse'`) runs the main axis backwards, and
alignment, item order and auto margins all mirror with it. Setting
`direction="column"` on a `` does nothing; write a ``.
A `` is the third container that lays its children out in a
line, and it is the exception on both counts: its axis does come from
`direction`, defaulting to a column, and its items never flex. A
viewport exists in order to overflow, so a scroll container hands each
child the size it asked for and lets the content run past the edge.
## Four items, four rules
The row below is 520 px wide. Press **Width** to narrow it to 400 and
then 300, and **Wrap** to turn `flexWrap` on:
```tsx
/**
* One row, four items, and a different sizing rule on each.
*
* `fontSize` and `color` are set on the row rather than on each item:
* the typography properties are inherited, so a container is the right
* place for them and the items are left saying only what makes them
* different.
*
* - `flex` is the shorthand: grow n, shrink 1, basis 0. Basis 0 is
* what makes the two shares exact, so the second item is twice the
* first whatever either of them says.
* - `flexShrink={0}` opts out of shrinking, so that item keeps its
* 96 px at every width and the others give up the space instead.
* - `flexBasis` is a starting main size that is not a width: this item
* begins at 140 px and shrinks from there.
*
* None of them shrinks below its own longest word, which is why the
* narrowest width overflows the row rather than crushing its contents.
*/
function Bar(inputs: Inputs<{ width: number; wrap: UiFlexWrap }>, _ctx: ComponentContext) {
return (
);
}
```
At 520 px the two `flex` items split the leftover exactly one to two. At
300 px there is no leftover: `fixed 96` refuses to shrink, `basis 140`
gives up what it can, and the items still do not fit, so the row
overflows rather than crushing them. Turn wrapping on and the last item
takes a line of its own.
## Main size: basis, then grow or shrink
| Prop | Default | What it decides |
| ------------ | ------- | ------------------------------------------------------------------------------------ |
| `flexBasis` | unset | The main size the item starts at. Unset means the size it measures at. |
| `flexGrow` | `0` | Share of the leftover space this item takes. |
| `flexShrink` | `1` | Share of the overflow this item gives back, weighted by its basis. |
| `flex` | unset | The shorthand: `flex={n}` is grow _n_, shrink 1, basis 0, exactly as CSS's `flex: n` |
The order matters more than the names. Every item is first measured on
an unbounded main axis, which is its max-content size and its flex base
when `flexBasis` is unset. Those bases are what the container adds up to
decide whether there is space over or space short. Only then does one
factor apply: everything grows, or everything shrinks, never both in the
same pass.
That is why `flex={n}` reads so differently from `flexGrow={n}`. The
shorthand sets a basis of zero, so the item brings nothing to the sum
and its share is a share of the whole leftover: two items at `flex={1}`
and `flex={2}` come out exactly one to two whatever they contain.
`flexGrow={n}` on its own leaves the basis at the item's measured size,
so the growth is added on top of content that differs per item, and the
ratio you get is not the ratio you wrote.
A basis taken from content is also read again whenever that content
changes, however deep the change is: rows arriving in a list inside a
`flexGrow={1}` region change the region's basis, and with it how the
column shares its height with a header beside it, so the layout runs
from the column rather than stopping inside. A region meant to fill
what's left, such as an application's main pane, wants `flex={1}`. Its
basis is zero, nothing inside it is read, and the header keeps its
height however long the list gets. A line's only item that grows and
shrinks to fill a line of definite size, with a minimum of its own
(`minWidth={0}`), is the same: no base could change its size, so its
content isn't read either.
Shrinking is weighted rather than even: an item's share of the overflow
is scaled by its basis, so a wide item gives up more pixels than a
narrow one at the same factor. `flexShrink={0}` opts out entirely, which
is what keeps the third item in the example at 96 px however narrow the
row gets.
Clamping is resolved in a loop rather than in one pass. An item whose
`minWidth`, `maxWidth` or automatic minimum fought the distribution is
frozen at the clamp, and the space it could not take is offered to the
items that can still move, until nothing is left to redistribute.
## Nothing shrinks below its content
An item's automatic minimum is the smaller of its declared main size and
its min-content size: the longest word for text, and for a container
whatever its own children insist on.
[Layout basics](https://gesso-docs.vercel.app/guide/layout-basics) states the rule; what it means for
a row is that a row can overflow. Once the items' minimums add up to
more than the container has, shrinking stops and the content runs past
the padding edge, which is what CSS does too.
Three ways out, in the order worth trying:
- **Wrap.** `flexWrap="wrap"` turns the overflow into a second line.
- **Let something clip.** A ``, or text with `maxLines` or
`textOverflow="ellipsis"`, has no automatic minimum by design, so it
is free to shrink and its own content scrolls or truncates inside it.
- **Overrule it.** An explicit `minWidth` or `minHeight` wins over the
automatic one.
## Wrapping
`flexWrap` takes `'nowrap'` (the default), `'wrap'` or `'wrap-reverse'`.
Items go onto a line while their sizes and the gaps between them fit,
and an item that fits nowhere gets a line to itself. Each line then
resolves its own grow and shrink separately, so the same item can be
shrunk on a crowded line and sit at its full basis on a line of its own.
A wrapping row is as tall as all its lines, and that height is also its
minimum: in a column, a sibling that grows can't squeeze it back to one
line, just as in CSS.
The gaps split by role rather than by name:
| Prop | On a row | On a column |
| ----------- | ----------------------- | ----------------------- |
| `columnGap` | Between items | Between wrapped lines |
| `rowGap` | Between wrapped lines | Between items |
| `gap` | Both, unless overridden | Both, unless overridden |
`alignContent` distributes a wrapping container's lines across the cross
axis: `'stretch'` (the default), `'start'`, `'center'`, `'end'`,
`'space-between'`, `'space-around'` or `'space-evenly'`. It has
something to do only when the container's cross size is larger than its
lines need; stretch, the default, grows the lines to fill it.
```tsx
```
## Alignment, and words that do not apply
`x` and `y` place children, `selfX` and `selfY` let one child override
its parent, and [Layout basics](https://gesso-docs.vercel.app/guide/layout-basics) covers what each
value does. Three things that page does not say:
- **A word that does not apply to an axis is ignored.**
`'space-between'` and its siblings are main-axis distributions, so
`y="space-between"` on a row changes nothing and the cross axis keeps
its default of `'stretch'`. Nothing throws; the engine simply has no
meaning for it there.
- **`'baseline'` is a row's word.** A column treats it as `'start'`,
because there is no shared baseline to align to down a column.
- **Auto margins are resolved first.** `margin={auto}` on the main axis
absorbs the free space before `x` or `y` ever sees it, so an item with
an auto margin beats the container's distribution rather than
negotiating with it.
## Where this differs from CSS
| CSS | Gesso |
| ------------------------------------------ | ------------------------------------------------------------------------ |
| `display: flex` plus `flex-direction` | `` and ``; `direction` is read only for a `-reverse` suffix |
| `justify-content`, `align-items` | `x` and `y`, named for the screen rather than for the axis |
| `align-self` | `selfX` and `selfY` |
| `flex: 1 1 auto` parsed from a string | `flexGrow`, `flexShrink`, `flexBasis`; `flex={n}` is the one shorthand |
| `order` | No such property. Put the children in the order you want them |
| `gap: 5%` | `gap`, `rowGap` and `columnGap` are pixels |
| `width: 50%` | `percent(50)`; a string where a length belongs throws at layout |
| Items flex inside an overflowing container | Items in a `` never flex |
| `align-items: baseline` down a column | A column treats `'baseline'` as `'start'` |
## What the numbers were checked against
The engine is compared box for box against headless Chrome, on a couple
of hundred generated fixtures that run as part of the ordinary test
suite. Flex carries most of them: grow and shrink weightings,
redistribution after a clamp, wrapping and `alignContent`, reverse and
right-to-left rows, auto margins, percentages and aspect ratios. Chrome
152 generated the current expectations, and it is the only browser they
were taken from.
One disagreement is recorded rather than fixed: a container that
shrink-wraps its items counts their `flexBasis` toward its own size,
where Chrome sizes it from item content and ignores the basis. Browsers
do not agree with each other on that case either.
None of this changes with the renderer. Boxes are computed once by the
layout engine, and Canvas2D and WebGPU both read the result; both also
measure text through the same measurer, so a line breaks in the same
place and an item is the same width either way.
## Next
[Grid](https://gesso-docs.vercel.app/layout/grid) is the two-dimensional half: tracks that several
rows share, spans, and the point at which nesting rows inside columns
stops being the right answer.
---
# Grid
A `` lays its children into the cells of tracks you declare. One
container decides both axes, which is the whole difference from a
`` of ``s: the tracks belong to the grid, so every row of
cells is measured against the same widths instead of each arriving at
its own and hoping they agree.
## One shared track
The table below is two tracks wide. The first is `auto`, so it is as
wide as the widest label in the grid, not the widest label in its own
row. Press the button to lengthen the middle label and watch every
value move, including the two rows that did not change:
```tsx
/**
* Three rows of label and value, and a note across the bottom.
*
* The point is the first column. It is one `auto` track, sized to the
* widest label in the whole grid, so every value starts on the same
* line. Three rows would each size their own label, and keeping them
* aligned would mean picking a width and hoping.
*
* `fr(1)` gives the second track what is left. `columnSpan` puts the
* note across both tracks without a container of its own, which is the
* other thing nested rows cannot do.
*/
function Specs(inputs: Inputs<{ middle: string }>, _ctx: ComponentContext) {
return (
);
}
```
Three rows of `` could not do that. Each would size its own label
from its own content, and lining them up would mean choosing a width by
hand and revisiting it whenever the copy changed.
## Tracks
`columns` and `rows` take arrays of track sizes. Five things are legal
in one, and each answers a different question:
| Track size | What the track gets |
| -------------------- | --------------------------------------------------------------------- |
| `120` | Exactly 120 px |
| `percent(25)` | A quarter of the grid's content box on that axis, when it is definite |
| `auto` | As much as the largest item in it needs |
| `fr(1)` | A share of what is left once the other tracks have taken theirs |
| `minmax(100, fr(1))` | A share, but never less than 100 px |
```tsx
import { auto, fr, minmax, percent, repeat } from 'gesso-core';
```
`repeat(count, ...sizes)` builds an array rather than being a value of
its own: `repeat(3, fr(1))` is three equal tracks, and
`repeat(2, 120, fr(1))` repeats the pair, giving four tracks. The count
is an integer you supply.
Two behaviours are worth knowing before you reach for `fr`:
- **`fr` has a floor.** A flexible track is treated as
`minmax(auto, fr)`, so an item that spans it alone still raises the
track to its min-content size. An `fr(1)` column never gets narrower
than the longest word in it, which is usually what you wanted and
occasionally a surprise. Use `minmax(0, fr(1))` when you genuinely
want the track to be allowed to go below its content.
- **`fr` needs a definite size to divide.** Given no definite width, the
grid sizes flexible tracks from the largest share any item demands
rather than from free space, because there is no free space to share.
## Implicit tracks
Items that run past the tracks you declared get new ones. `autoRows` and
`autoColumns` size them, defaulting to `auto`:
```tsx
```
`autoFlow` decides which axis fills first: `'row'` (the default) fills
each row left to right and adds rows at the bottom, `'column'` fills
each column top to bottom and adds columns at the end.
## Placing an item
Auto-placement walks a cursor forward through the cells, so children
land in the order they are written. Any item can opt out and say where
it goes:
| Prop | On the item |
| ------------ | ------------------------------------- |
| `column` | The 1-based column line it starts at |
| `row` | The 1-based row line it starts at |
| `columnSpan` | How many columns it covers, default 1 |
| `rowSpan` | How many rows it covers, default 1 |
```tsx
```
Lines are 1-based and positive: `column={1}` is the first column, and a
value that is not a positive integer throws and names the property.
Items with both lines given are placed first, then items pinned to one
line, then everything else, so an explicitly placed item claims its
cells before auto-placement starts filling around it.
The cursor only ever moves forward. An item too wide for what is left of
a row starts the next one and leaves the cells it skipped empty, which
is what makes a span shift its neighbours down without either of them
saying anything. The note in the example is a plain `columnSpan={2}` and
needs no container of its own.
## Gaps, alignment and distribution
`gap` sets both axes; `columnGap` and `rowGap` set one each, in pixels.
Two pairs of props do the aligning, and the split is the one that trips
people up:
- **`x` and `y` align an item inside its cell.** Both default to
`'stretch'`, so an item with no size of its own fills its cell.
`selfX` and `selfY` override the grid for one item.
- **`justifyContent` and `alignContent` distribute the tracks
themselves**, in the space left when the grid is larger than its
tracks: `justifyContent` moves the columns, `alignContent` the rows.
Both take `'stretch'` (the default), `'start'`, `'center'`, `'end'`,
`'space-between'`, `'space-around'` and `'space-evenly'`. Stretch
grows the `auto` tracks into the leftover; the other values leave the
tracks alone and move them.
```tsx
```
Note that `justifyContent` is a grid property. A `` distributes its
children with `x`, not with `justifyContent`; see
[Flex in full](https://gesso-docs.vercel.app/layout/flex).
## Nesting one grid in another's tracks
A grid inside a grid normally declares tracks of its own, which puts it
back in the position of guessing. `subgrid="columns"` takes them from
the span it occupies in its parent instead:
```tsx
```
The inner grid's cells contribute to the outer grid's track sizes and
are then laid out in the tracks that result, so a row component can line
up with a header it never sees. Only the column axis is available, and
the reason is in the error you get for anything else: a virtualized
table's parent cannot see the rows that are not mounted, so its row
tracks are not something a row could share. A subgrid that finds itself
somewhere other than inside a grid falls back to its own `columns` and
behaves as an ordinary grid.
One difference from CSS here: a subgrid's own horizontal padding and
margins are not distributed into the parent's tracks.
## When to reach for grid
Grid earns its keep when something has to line up across a boundary that
a single container does not own:
- **Cells aligned down a column across several rows.** The example
above. This is the case nesting cannot do without hard-coded widths.
- **An item that covers more than one cell.** A header across the full
width, a hero tile two columns wide, a sidebar spanning three rows.
In nested rows and columns each of those needs a container invented
for it.
- **A fixed shape.** A dashboard of twelve tiles, a form of label and
field pairs, a calendar. The tracks say the shape once, and adding a
child does not mean adding markup.
A `` or a `` is still the right answer for a single axis
with nothing to align across it: a toolbar, a button pair, a stack of
cards. So is a bag of items that should flow onto as many lines as they
need, which is [`flexWrap`](https://gesso-docs.vercel.app/layout/flex) rather than a grid, because
the tracks here are the ones you declare.
## What is not here
The grid algorithm is a subset of CSS Grid, and the missing parts have
answers rather than workarounds:
| Not supported | What to do instead |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| Dense packing | Place the items that would have backfilled, with `column` and `row` |
| Negative line numbers | Count from the start: the track list is yours, so its length is known |
| Named lines and areas | Use line numbers, or name them yourself as constants beside the track array |
| `repeat(auto-fill, ...)` | Work out the count from the width you have and pass it to `repeat(count, ...)`, or use a wrapping row for a bag of items |
| `subgrid` on rows | Give the inner grid explicit `rows` |
## What the numbers were checked against
The grid cases in the layout conformance suite are compared box for box
against headless Chrome: fixed, percentage, `auto`, `fr` and `minmax`
tracks, implicit tracks in both flow directions, explicit placement,
spans that wrap onto the next row, alignment inside cells, track
distribution, and margins and padding around it all. Chrome 152
generated the current expectations, and it is the only browser they were
taken from.
Layout does not change with the renderer. Boxes are computed once by the
layout engine and both Canvas2D and WebGPU read the result, so a track
is the same width whichever one is drawing.
## Next
[Flex in full](https://gesso-docs.vercel.app/layout/flex) is the one-dimensional half, and the one to
read when a screen turns out not to need tracks after all.
---
# Custom layouts
A masonry wall, a radial dial, a timeline and a tag cloud have one thing
in common: none of them is expressible in flex or in grid, and all of
them are a short function over the children's sizes. The `layout` prop
is where that function goes.
```tsx
{items.map(item => (
))}
```
There is no `` element. A custom layout is a value, so a
container becomes a masonry the way it becomes a scroller: by naming the
behaviour it should have. It works on any container, and it replaces
whatever that container would otherwise have done with its children.
## The protocol
A layout is an object with a name and one method:
```ts
interface UiLayoutProtocol {
readonly name: string;
layout(children: readonly UiLayoutChild[], constraints: Constraints, context: UiLayoutContext): Size;
explain?(children: readonly UiLayoutChild[], size: Size, context: UiLayoutContext): readonly string[];
}
```
`Constraints` is the same class the engine hands every other node, and
`Size` is what a measurement returns. That is the point of the shape:
a custom layout is another container rather than a hole in the pass, and
the size it returns is clamped by the node's own `width`, `minWidth` and
the rest exactly as a row's content size is.
Each child arrives as a handle with four things on it:
| On the handle | What it is |
| ------------- | ---------------------------------------------------------------- |
| `index` | Its place among the container's in-flow children, from zero. |
| `data` | Whatever the element declared as `layoutData`: a weight, a lane. |
| `measure(c)` | Measures the child under those constraints and returns its size. |
| `place(s, t)` | Puts its border box at a point in the container's content box. |
`size` and `position` read back what the last call to each returned.
A whole masonry is twenty lines of that:
```ts
export const masonry: UiLayoutProtocol = {
name: 'masonry',
layout(children, constraints) {
const available = isFinite(constraints.maxWidth) ? constraints.maxWidth : 0;
const width = (available - GAP * (COLUMNS - 1)) / COLUMNS;
const heights = Array.from({ length: COLUMNS }, () => 0);
for (const child of children) {
let shortest = 0;
for (let column = 1; column < COLUMNS; column++) {
if (heights[column] < heights[shortest]) {
shortest = column;
}
}
const size = child.measure(new Constraints(width, width, 0, Infinity));
child.place(shortest * (width + GAP), heights[shortest]);
heights[shortest] += size.height + GAP;
}
return { width: available, height: Math.max(0, ...heights) - GAP };
}
};
```
The running version, which also decides its own column count from the
width it is given, is `apps/playground/src/examples/layout/masonry.ts`,
and the page it is on is the playground's **Layout** example.
## Hold the protocol still
`layout` is an ordinary property, so its value is compared with
`Object.is` like every other. A fresh object per render marks the node
dirty on every frame. Build it once at module scope, or memoise it on
the parameters it closes over:
```ts
const cache = new Map();
export function masonry(options: MasonryOptions): UiLayoutProtocol {
const key = `${options.columns}/${options.gap}`;
let protocol = cache.get(key);
if (protocol === undefined) {
protocol = build(options);
cache.set(key, protocol);
}
return protocol;
}
```
## What the engine will not let you do
A protocol is application code running inside the layout pass, so the
handles are the whole of what it can reach. There is no node on them, so
a layout cannot read another subtree's geometry mid-pass or write a
property that would dirty one. Three further rules are enforced rather
than asked for, and each one throws with the protocol's name in the
message:
- **A child may be measured at most twice in one call.** Two is what the
engine's own flex needs. A protocol asking for a third is nearly
always measuring inside a loop over its siblings, which is what turns
a linear pass into a quadratic one, so the work is at worst twice the
number of children however the loop is written.
- **A handle is valid only for the call it was given to.** Keeping one
and placing a child from a timer later would write a box that nothing
had invalidated.
- **A size or a position that is not a finite number is refused**, with
the child's index, rather than propagating a `NaN` through the tree.
## Twice a pass, and no state between
`layout` is called twice over a node the pass did not memoise: once
while measuring, when the size it returns is what the container reports
to its parent and `place` does nothing, and once while placing, when the
constraints are the resolved content box and the `place` calls assign
the boxes. Both calls see the same children in the same order.
Write it as a function of its arguments. A protocol that keeps state
between the two calls is wrong the first time a memo hit skips the
measurement, which is most frames.
## Margins, and which way is start
A custom layout places border boxes. `margin` on a child is not applied,
because only the protocol knows what the space between two of its own
children means, and a margin the engine added behind its back would move
boxes it had already placed. Use the protocol's own gap.
`place(start, top)` measures `start` from the edge the reading starts
at, so under `textDirection="rtl"` the engine mirrors the coordinate and
a layout written once works both ways round. A protocol that needs to
know which way it is being mirrored reads `context.direction`. See
[right to left](https://gesso-docs.vercel.app/layout/right-to-left).
## An explain of its own
The engine can say why a box is the size it is because it decided;
for a custom layout it did not. `explain` is where a protocol says the
things the boxes do not:
```ts
explain(children, size) {
return [
`${count} columns of ${width} px, ${gap} px apart`,
`${children.length} items, ${counts.join(' / ')} per column`,
`column ${tallest + 1} is the tallest at ${size.height} px`
];
}
```
`formatExplanation` prints those under the node's own two axes, and the
playground's inspector shows them for whatever the pointer is over:
```
layout 'masonry' over 12 children
3 columns of 281.7 px, 16 px apart
12 items, 5 / 4 / 3 per column
column 1 is the tallest at 701.6 px; the shortest ends 120 px above the bottom
```
A child of a custom layout gets an explanation too, and it names the
layout that decided it: `the 'masonry' layout on box 'wall' measured it
at 281.67`.
`explain` is called after a pass and never during one, and its handles
refuse both `measure` and `place`. A question that moved boxes would be
a second layout nothing had asked for. Read `child.size` and
`child.position` instead.
See [asking the engine why](https://gesso-docs.vercel.app/layout/explain) for the rest of what an
explanation carries.
---
# Layouts that change with the room they have
A screen that differs at 400 px and at 1400 px asks one of two
questions. Either its properties change, in which case use
`breakpoint`, or its children genuinely differ, in which case use
`Responsive`. Both measure **the container**, not the window.
That distinction is the whole reason neither of these is a media query.
`observeMediaQuery` needs a window, the runtime that would branch on it
is usually in a worker, and the question a component actually has is
"how much room do I have", which is not the window's width in any layout
with a sidebar in it.
## Properties that change: `breakpoint`
```tsx
```
`at` is the widths, ascending, at which the properties change. `props`
is what to write in each band, keyed by the width that opens it; the
band below the first breakpoint is keyed `0`.
Bands are applied cumulatively from the narrowest up, so a band names
only what it changes: the column above keeps `gap: 8` at 1000 px because
the 900 band did not say otherwise.
The first frame is already right. The band is written when the node's
box is first known, which is after the frame's layout has run, and the
runtime lays the node out again before it paints, so a page mounted at
1400 px is drawn with the 900 band's padding on the frame it appears,
not with its declared padding and then again a frame later. The same
holds when a resize crosses a width: the frame the new size is drawn on
has the new band.
The element's own values are still what the first layout pass measures
with. Give it the narrowest band's values as its declared props, so a
container laid out where no runtime settles it (a headless layout, a
spec driving layout by hand) starts from the right band.
## Children that differ: `Responsive`
```tsx
Responsive({ at: [900], width: percent(100) }, size =>
size.width >= 900 ? [ ,
] : [
]
);
```
`build` is called once per band entered, with the size the container had
when it entered that band, and its result replaces the children.
`Responsive` takes every prop a `` takes, plus `as` to be a row
or a box instead.
`at` is required, and that is deliberate. Children rebuilt on every
pixel of a resize would allocate a subtree per frame of a drag. Naming
the widths that matter is what makes the rebuild happen once per band:
dragging a window edge from 1000 px to 1399 px does no work at all.
The first build happens before any layout, when the size is zero, so
`build` has to return something sensible for a container whose room is
not yet known. Zero picks the narrowest arm, which is the right guess
and also what a phone gets. It is not what gets drawn: the first layout
reports the real size, `build` is called again with it, even when it is
in the same band as zero, and that arm is laid out before the frame
paints. So the narrow arm is never on screen at 1400 px, and a `build`
that reads `size.width` is never left with the zero.
Within a band, `size` is the size the container had when it entered it,
not its size now, so a width `build` branches on belongs in `at`:
`size.width < 600` inside `at={[1024]}` is answered once, at the first
measurement, and not again as the container narrows past 600.
A rebuilt arm is a new subtree, so whatever lived in the old one goes
with it: a scroll position, focus, a field's draft, and a dialog
declared there, which closes as its component unmounts. When the arms
differ only in how the same parts are arranged (a sidebar beside the
page or in place of it), keep one tree and change properties: a
`breakpoint`, or a [split pane](https://gesso-docs.vercel.app/components/split-pane)'s `show`, which
hides a pane without unmounting it.
## What a band costs
Two numbers, both pinned by
`packages/core/src/layout/ContainerQuery.budget.spec.ts`:
- **Crossing a band costs one extra layout pass**, over the container's
own subtree, in the same frame. The modifier hears the new box,
writes its properties, and the node is laid out again before anything
paints. There is no way around that and no reason to want one. The
frame's metrics count it: `layoutPasses` is 2 on that frame.
- **A resize inside a band costs nothing at all.** The modifier hears
every width and writes on none of them, so nothing is dirtied and the
frame's pass is the only pass.
That second number is the one worth watching in your own code: a
container query that recomputed on every width would lay the page out
twice per frame of a window drag, and nobody would notice until the drag
felt heavy.
## Listeners that change layout
`breakpoint`, `sizeContainer` (which `Responsive` attaches) and any
modifier of your own on `host.onLayout` hear a box after layout, so
anything they write that changes layout makes the frame's boxes stale.
The runtime does what a browser does after a `ResizeObserver` callback:
it lays out again, only what was written, tells the listeners whose
boxes then changed, and repeats until they write nothing more that lays
out. Only then does the frame paint. A frame whose listeners wrote
nothing that lays out, which is nearly every frame, runs one pass and
pays one look at what is dirty.
The loop is bounded at **8 layout passes a frame**. A browser bounds the
same loop by tree depth; a count is simpler to reason about and far more
than a real chain needs (a breakpoint inside a `Responsive` inside a
`Responsive` is four). Listeners that never settle, such as one that
widens its node whenever it is narrow and narrows it whenever it is
wide, stop at the bound: the frame paints what it has, the rest waits
for the next frame, and a warning naming the problem appears once in
the console.
## The size, for anything else
A container that declares itself one publishes its content size to its
subtree through `UiEnvironmentKeys.containerSize`, so a descendant that
wants the number rather than a branch can read it without a prop
threaded down:
```ts
const room = ctx.environment(UiEnvironmentKeys.containerSize);
const columns = room.changes.pipe(map(size => Math.max(1, Math.floor(size.width / 240))));
```
What is provided is a **source** whose identity never changes, not a
size. An environment value is a snapshot that a subtree caches, so
putting a number in one would rebuild the environment of everything
under the container on every pixel of a drag. The source is set once;
what changes is what it reports, and only the code that asked to hear
about it does any work.
`Responsive` provides one for you. To provide one by hand, build a
`UiContainerSizeSource`, put it on the node's `containerSize` prop, and
attach `sizeContainer` with the same object.
The size reported is the **content** box, padding excluded, because the
room a child has is the room inside the padding.
## In an application
Segue's home screen shows four shelves under 720 px, six under 1180, and
all eight above it. A shelf is a row that scrolls sideways, so eight of
them stacked in a window the width of a phone is a wall to scroll past
rather than a page to take in.
```tsx
const SHELF_BREAKPOINTS = [720, 1180];
Responsive({ width: percent(100), gap: 4, at: SHELF_BREAKPOINTS }, size => [
,
...shelvesFor(size.width).map(id => )
]);
```
The playground's **Layout** example has the same thing at a smaller
scale, with a panel that splits and rejoins at 820 px of its own content
box, which is not 820 px of window.
---
# Insets
Three quite different things end up on the same four numbers, and that
is the point of having them: the platform's safe area (a notch, a home
indicator, a window's rounded corner), the soft keyboard, and whatever
the application itself is floating over its own content.
A screen that keeps its last row clear of all three should not have to
know which of the three it is clearing. Before this, every scrolling
screen in Segue knew about exactly one of them by name, and about the
height of the bar that caused it.
## The registry
An application provides one on its root:
```tsx
const insets = new UiInsetRegistry();
```
Per application rather than per module, for the same reason the theme
is: an inset is "what is over the content in this window", and a runtime
with two windows in it has one of these per window.
## Publishing
Anything drawn over the content instead of in it says so:
```tsx
```
It publishes its own measured height on a horizontal edge and its width
on a vertical one; pass `extent` when the node's own box is not the
answer. The contribution is republished whenever the box changes, so a
bar that grows moves the content with it, and retracted when the
modifier detaches, so a bar inside a `Presence` gives the room back as
it leaves.
Hold the modifier list still. A modifier's arguments are compared by
identity, so an inline `[publishInset({ edge: 'bottom' })]` detaches and
re-attaches on every render:
```ts
const PUBLISHES_ITS_ROOM = [publishInset({ edge: 'bottom' })];
```
## Reading
A scrolling page keeps clear of whatever is in the registry:
```tsx
```
That is forty pixels plus however much the bars, the keyboard and the
platform's safe area are currently taking at the bottom, and it follows
all three as they change. Only the edges named are written, so it
composes with an element that sets `paddingX` itself.
The edges are physical rather than logical, because these are physical
facts: a soft keyboard is at the bottom of the screen in every language,
and a notch does not move when the reading does.
## Contributions compose by maximum, not by sum
Two things over the same edge overlap far more often than they stack. A
bar drawn across the home indicator already covers the safe area under
it, and a soft keyboard that pushes a bar up covers the bar too. Adding
them would push the content clear of a strip nothing is occupying, and
nobody would notice until the keyboard was open on a phone.
So each edge takes the largest contribution on it. A publisher whose bar
genuinely sits on top of another one publishes the total it occupies,
because it is the only thing that knows.
## The platform's own insets
The safe area and the soft keyboard come from `visualViewport` and from
`env(safe-area-inset-*)`, both of which need a window. That makes them
shell-side, and the framework's shell does the reading for you: on
mount, `GessoApp` and `WorkerApp` both call `observeViewportInsets`,
and what crosses the thread boundary is four numbers in a
`viewportInsets` message. The runtime publishes them into the registry
the root provides, as one more contributor beside your bars, so the
`insetPadding` above keeps clear of the keyboard with no further code.
The raw numbers are also on `ShellService`, as a read-only cell for the
application that provides its registry somewhere other than the root,
or wants the platform's contribution apart from its own bars':
```ts
const platform = ctx.inject(ShellService).viewportInsets;
```
`observeViewportInsets` reports once immediately as well as on change,
for the same reason `observeMediaQuery` does: a keyboard that is already
open, and a phone whose notch has been there all along, send no event.
Where there is no `visualViewport` it reports zeroes once and stops,
which is the honest answer on a desktop window with no notch.
The safe area is read off four custom properties on the document
element, because `env()` is only legal in CSS. A page that wants them
declares them in its own stylesheet:
```css
:root {
--gesso-safe-area-top: env(safe-area-inset-top, 0px);
--gesso-safe-area-right: env(safe-area-inset-right, 0px);
--gesso-safe-area-bottom: env(safe-area-inset-bottom, 0px);
--gesso-safe-area-left: env(safe-area-inset-left, 0px);
}
```
An application that has not declared them gets zeroes rather than a
failure.
Nothing in `observeViewportInsets` reads a Gesso object, which is the
thread model's rule: the shell holds what only it can hold and
posts plain data, and every decision about the data is taken on the far
side. Whether a real notch or a real keyboard reports what the reader
expects has not been checked on a device; the route is covered by specs
that drive a fake `visualViewport`.
## In an application
Segue's now-playing bar publishes the eighty-eight pixels it occupies,
and seven screens keep clear of it with `pageInset(base)`. None of them
names the bar's height and none of them asks whether anything is
playing, which is what all seven did before.
The playground's **Layout** example is the same thing with a switch on
it: hide the bar and the page's `paddingBottom` goes from 112 to 40,
which the layout inspector shows as `set by insetPadding`.
---
# Right to left
An inset named on one horizontal edge is nearly always logical rather
than physical: the space before a leading icon, the indent of a nested
row, the gutter a list keeps against the side it starts at. Written as
`paddingLeft`, all three are wrong the moment the application runs in
Arabic, and nothing marks them as the edge that should have moved.
## Logical edges
```tsx
```
`paddingStart` is the edge the reading starts at: the left under
`textDirection="ltr"`, the right under `"rtl"`. `marginStart` and
`marginEnd` are the same outside the box.
This is the pair the documentation teaches for a single horizontal edge.
`paddingLeft` and `paddingRight` stay, and are still the answer for an
edge that is genuinely physical: a gutter kept clear of an overlay
scrollbar sits on the same side in both directions.
Resolution is most specific wins, as everywhere else in the box model:
**side, then logical side, then axis, then the shorthand.** A physical
name beats the logical one that landed on the same edge, because it is
the more specific statement of the two. So
```tsx
```
is four on the left whichever way it reads. See
[layout basics](https://gesso-docs.vercel.app/guide/layout-basics) for `paddingX` and the rest of the
box model for why `padding` is a single number.
There is no `paddingBlockStart`. The vertical axis does not reverse in
any writing mode Gesso supports, so `paddingTop` says what it means.
## Putting a subtree into right to left
The direction is a field of a text style, and a text style is what the
theme's typography is made of. So one value at the root mirrors
everything under it:
```ts
function readingRightToLeft(theme: UiTheme): UiTheme {
const typography = Object.fromEntries(
Object.entries(theme.typography).map(([role, style]) => [role, { ...style, textDirection: 'rtl' }])
);
return { ...theme, typography };
}
```
Every role, not only the one the root provides. An element that names a
role takes that role's style whole, direction included, so a theme whose
body reads one way and whose headings read the other would mirror a page
in pieces.
`textDirection` on an element mirrors that element and what is under
it, and it outlasts a type role named below it. A role is a size, a
weight and a line, not a reading direction, as `font` in CSS leaves
`direction` alone, so a label that names `bodySmall` inside a box set to
read right to left keeps reading right to left. A role brings its own
direction only where nothing above set one, which is what lets a theme
whose roles all read right to left mirror a page from its root.
## What mirrors
Everything the box model decides:
| Under `rtl` | What happens |
| ------------------------------ | ------------------------------------------------------------------------- |
| A ``'s main axis | Runs right to left. Order, alignment and auto margins all mirror. |
| A ``'s `x` / `selfX` | `start` is the right edge, `end` the left. |
| A wrapping ``'s lines | Stack right to left, and `wrap-reverse` cancels it. |
| A ``'s `x` / `selfX` | Same as a column's: a stack's start edge is the right one. |
| A ``'s columns | The first track is the right-hand one, and cell alignment goes with it. |
| `paddingStart` / `marginStart` | Resolve to the right edge. |
| A custom layout's `place` | Its `start` coordinate is measured from the right. |
| An overlay scrollbar | The vertical bar hangs on the left, and so does the band that reveals it. |
| A scroll container | Starts at its right edge, and scrolls left with a negative `scrollX`. |
A scroll container's offset follows CSS, where `scrollLeft` is never
positive in a right-to-left box. Its content starts at the right edge
and overflows to the left, so `scrollX` runs from minus the overflow up
to `0`: `0` shows the start edge, which is where a container nobody has
scrolled sits, and `-200` is 200 pixels toward the end. A wheel, a
swipe, a dragged thumb and a row revealed by the keyboard all move in
that range, and the horizontal thumb starts at the right end of its
track. Text fields are the exception: a field scrolls its own text to
follow the caret, from `0` up, in either direction.
Two things do not, and should not:
- **`left`, `right`, `top`, `bottom` and `inset`** are physical, as they
are in CSS. A node pinned to `left={0}` is on the left in both
directions.
- **A stretched child is symmetric**, except when its own maximum cuts
it short of the box; the leftover is then at the start edge, which
moves with the reading.
All twenty of the box-model cases are graded against Chrome, case by
case, by `packages/core/src/layout/conformance`. Scrolling is not a
box-model case there; its right-to-left behaviour is pinned by
`packages/core/src/layout/LayoutEngine.scrollRtl.spec.ts`.
## What does not mirror yet
This is the box half of internationalisation. The text half is a
separate workstream: message formatting with plurals, a locale cell, and
`Intl`-backed dates and numbers are not here yet, and neither is an
icon that knows it should be flipped.
The playground's **Layout** example has a **Mirror** switch, which
swaps the theme for its mirrored twin at runtime. Everything in the
table above moves; nothing on the page is written twice.
---
# Overflow and scrolling
[Layout basics](https://gesso-docs.vercel.app/guide/layout-basics) ends with every box at the size
its content and its constraints agreed on. This page is about the case
where they do not agree: content bigger than the box it was given.
Nothing is cut off by default. A child paints outside its parent, as
CSS's `overflow: visible` does, and one property changes that.
## `overflow`, on any container
| Value | What the container does |
| ----------------------- | ---------------------------------------------------- |
| `visible` (the default) | Children may paint outside the box |
| `hidden` | Children are clipped to it, following `borderRadius` |
| `scroll` | Clips, and becomes a scroll container |
| `auto` | The same as `scroll` |
One property covers both axes; there is no `overflowX` or `overflowY`.
Clipping is not only about paint. The hit tester rejects a point
outside a clipping node before it tests that node's children, so a
child cut off by the clip is not clickable either, which is the answer
you want and the one that is easy to get wrong by clipping in the
renderer alone.
`auto` and `scroll` behave alike here because scrollbars are overlays:
a bar appears on an axis whose content actually overflows, and takes no
layout space on any axis. CSS distinguishes the two by whether a gutter
is reserved, and there is no gutter to reserve.
## Two ways to have a scroll container
`` and `overflow="scroll"` both scroll, and the difference
between them is a flex difference worth knowing before you pick one.
- **`` is a window over content.** It measures its children
with the scrolling axis unbounded, so each child keeps the size it
asked for and the container scrolls to reach the rest.
- **`` is a flex column that clips and
scrolls.** Its children are still flex items, so a child with a
definite size and no content-derived minimum shrinks to fit the
column instead of overflowing it.
`flexShrink={0}` is what stops that. Without it the boxes below are
squeezed into 180 pixels between them, and there is nothing to scroll:
```tsx
```
Either way the container needs a definite size on the scrolling axis.
A `` given a loose bound takes its content's height, and
content that fits its container has nothing to scroll. Its own
`maxHeight` is such a bound: a list in a popup with `maxHeight={300}` is
as tall as its rows until they reach 300, and scrolls after that. A `height`, or
`flex={1}` inside a parent that has one, is what gives it a viewport.
A scroll container scrolls its main axis, which is vertical unless
`direction` says otherwise. `direction="row"` makes a horizontal one,
and a `` is horizontal already.
## A list that scrolls
```tsx
/**
* A window over more rows than it can show.
*
* `scrollY` is an ordinary property, so it is bound like any other:
* the cell drives the list, and writing the cell moves it. That is
* the whole of programmatic scrolling, and it is also how a list
* comes back where it was left after a route change, because the
* offset is applied in the same layout that first places the rows.
*
* `scrollPosition` is the other direction. A wheel, a scrollbar
* drag, a finger and a focus reveal all write the offset from inside
* the runtime, so the cell would go stale without something reporting
* back; `onChange` carries the offset the engine settled on, which is
* the clamped one and not necessarily the one that was asked for.
*
* Nothing here sets `overscrollBehavior`. Its default chains a wheel
* this list cannot use out to the page, so reaching the last row
* hands the article back its scrolling instead of trapping it.
*/
const list = (
(at.value = offset.y) })]}
borderRadius={10}
borderWidth={1}
borderColor="border"
backgroundColor="surface"
role="list"
label="Cities">
{CITIES.map(city => (
))}
);
```
Use the wheel over the list, and use it again once the list is at the
bottom: the second wheel scrolls this page, because the list does not
keep what it cannot use. That is `overscrollBehavior`, below.
## Where a container is scrolled to
`scrollX` and `scrollY` are ordinary properties, in logical pixels,
measured from the content's start. Ordinary means bindable, which is
the whole of programmatic scrolling: write the cell the property is
bound to, and the container is laid out at the new offset on the next
frame. A list rebuilt after a route change is laid out where it was
left, before it paints, for the same reason.
The engine clamps the offset to the container's **content extent**,
which it measures from the children it placed plus the container's own
padding. A container that reads right to left measures that extent from
its right edge, and its `scrollX` runs from minus the overflow up to
`0` rather than from `0` up; see
[Right to left](https://gesso-docs.vercel.app/layout/right-to-left). Two things follow:
- An offset written against content that has since shrunk comes back
into range on its own, rather than leaving a list scrolled past its
last row.
- The property and the truth can differ. A wheel writes the offset
unclamped, so `scrollY` may name a place the list never went. What
the container settled on is what layout keeps, and it is what
`scrollPosition` reports.
## Being told that it scrolled
A wheel is not the only thing that moves a list. A scrollbar drag, a
finger, and the reveal that keeps a newly focused row on screen all
write the offset from inside the runtime, so an application that wants
to know where a list is asks for the offset rather than listening for
an event:
```tsx
(at.value = offset.y) })]}>
```
`scrollPosition` takes `onChange`, called with the effective `{ x, y }`
whenever it changes, and `onSettled`, called once when it stops
changing. `onChange` fires on every frame of a smoothed scroll, because
that is what happened; a route persisting where its list was wants
`onSettled` instead of writing a value on every frame that nobody reads
until later.
There is no `onScroll` event, deliberately. Half the causes above are
not input at all, so an event from the input stack would stay silent
for them; and the runtime already has exactly one mechanism for "this
node's box changed on this frame", which is what the modifier is built
on.
An `` reports through it too. Its offset is how far it
has scrolled its own text, by the wheel, its scrollbar, the caret
following typing, or the clamp when text is deleted.
## Scrolling two things together
What `onChange` reports arrives after the frame is laid out, so a
second container whose `scrollY` is bound to it is painted a frame
behind. For something that has to line up row for row, such as a line
number gutter beside a field or a row header beside a grid, name the
leader with `scrollWith` instead:
```tsx
{/* one row per line, in the field's font and line height */}
field.next(node)} multiline height={420} textWrap="none" />
```
The follower takes the leader's offset in the same layout pass that
settles it, so the two are painted in step whatever moved the leader.
`scrollWithAxis` is `both` by default. The follower's own `scrollX` and
`scrollY` are ignored on a followed axis, it shows no scrollbar, and a
wheel over it scrolls the leader. An `overflow="hidden"` box that
follows is scrolled all the same, the way script scrolls one in CSS.
The offset is clamped to the follower's own range, so give it content
as tall as the leader's.
## The wheel
A wheel is dispatched to the node under the pointer first, so a handler
can take it; `preventDefault()` on that event opts the whole subtree out
of automatic scrolling. Otherwise the runtime walks up from that node
and gives the delta to the first scroll container with room for it in
the direction of travel. Room means room to move, not room for the
whole delta, so a fast flick clamps at the end of a list rather than
jumping out to the page.
Two properties steer what happens at the ends of that walk:
| Prop | Values | What it decides |
| -------------------- | ------------------------------- | ----------------------------------------------------------------------- |
| `scrollBehavior` | `'instant'`, `'smooth'` | Whether a notched wheel animates this container or jumps it |
| `overscrollBehavior` | `'auto'` (default), `'contain'` | Whether a delta this container cannot use chains outwards or stops here |
`'contain'` is what an application filling the viewport wants on its
root, so that nothing outside it moves. The default is what a canvas
embedded in a document wants, and it is why the list above hands this
page its scrolling back.
An application that is the whole page also wants the page itself to
keep still. A scroll handed back to a page that can't scroll is still a
gesture to the browser, and on a Mac it stretches the whole page,
showing white behind it, or takes a sideways swipe as Back. One line of
CSS on the page says not to, and it covers the moment before the first
frame, too:
```css
html,
body {
overscroll-behavior: none;
}
```
The `create-gesso-app` templates have it.
Smoothing is applied only where it helps: a wheel with detents, which
delivers one large jump per notch and nothing in between. A trackpad
already sends a fine-grained inertial stream, and animating that would
lay one inertia curve over another, so a device that looks precise is
left alone. The evidence read is the delta's unit and the legacy
`wheelDeltaY` field, and only a positive answer smooths.
One event can't always tell. Chrome on a Mac reports a trackpad's
`wheelDeltaY` as three times its pixel delta, so a step of 40 pixels
reads as a whole detent, and a flick passing through that speed had
single steps smoothed among instant ones, which reads as stutter. A
mouse wheel never sends an event that doesn't look notched, so once one
arrives the wheel is taken to be precise for as long as events keep
coming (400 ms after each), which covers a gesture and its momentum.
A trackpad also sends on its own clock rather than the display's, so at
a steady speed a frame gets two of its steps, now and then one or three,
and on a 120 Hz display sometimes none. Applied as they come, a steady
flick moves the page unevenly, which reads as judder; evening it out by
waiting for steps puts the page a frame behind the hand. The runtime
does what a browser does for a native page instead: each frame puts the
page where the input will have reached when the frame is shown, the
steps so far plus their velocity times the time since the last one,
read from the last few steps only and never more than a frame and a
half ahead. The steps come out even and the page keeps up. A gesture's
first step goes as it is. A frame never moves against the way the
latest step went, so a prediction that ran ahead as a flick slowed
holds until the steps catch up, and one left ahead when they stop stays
there, a frame's travel on, rather than pulling back, which the eye
reads as a spring. Against an edge the steps are judged from where they
have really taken the container, so the list stops at the edge and
stays. Every step counts: several arriving between two frames add up.
A trackpad's steps can be a fraction of a pixel, and the offset keeps
them exactly, so they add up. What's drawn lands on a whole device pixel,
as a browser draws scrolled content: text drawn between pixels
rasterises differently each frame and shimmers as a flick slows.
`scrollBehavior="instant"` opts a container out; under a reduced-motion
preference the animation snaps, which lands the same offset in one
frame.
That classifier was checked in a browser against a real mouse, which is
the only place it could be, and the first version of it turned out to
be wrong on a second monitor whose scaled deltas were a pixel short of
a whole detent. The trackpad half was found by reading what Chrome
reports and is covered by specs; it has yet to be watched on a Mac
trackpad.
A wheel's delta is only a distance when the browser reports it in
pixels. Chrome does; Firefox reports lines, three to a notch, and page
mode means a screenful. The runtime converts both, so a notch moves a
container about as far on either. The conversion is covered by specs;
the browser it has been watched in is Chrome.
## Scrollbars
A container whose content overflows draws a thin overlay thumb along
the end edge of each overflowing axis, sized by the ratio of viewport
to content and placed by the offset. It shows while the container is
scrolling and fades out afterwards.
The bars are interactive, and their geometry lives in one place, so what
is drawn is exactly what the hit tester grabs: a press on the thumb
drags it, a press on the track beside a visible thumb pages one
viewport towards the pointer, and coming near a bar while the list is
idle reveals it so there is something to grab. A hidden bar takes no
press: clicks go through to the content under it.
Both renderers draw them from the same geometry, and a parity spec runs
the same trees through Canvas2D and WebGPU and compares the resulting
draws, including a scrolled list with a sticky header. WebGPU is used
only where the browser has it and falls back to Canvas2D otherwise, so
Canvas2D is the one every reader of this page is looking at unless they
asked for the other.
## Fingers
A finger produces no wheel events, so touch scrolling is its own path:
a pan on the root that no one else claimed scrolls the container under
it, the content following the finger, and a release above a threshold
coasts a projected distance through the same smooth path a wheel uses.
When the innermost container reaches its end the next one out takes
over, as chaining does for the wheel.
A widget that owns its own drag keeps it by calling `stopPropagation()`
on the pan, which is how a slider or a split pane avoids scrolling the
list it sits in. Touch scrolling is touch only: a mouse drag inside a
scroll container is how text is selected, and a pen is used for exactly
that precision.
**This was not verified on a real touchscreen.** The behaviour is
covered by specs and was exercised through Chrome's touch emulation,
which reports the right pointer type but cannot say how a flick feels
in a hand.
## Keys
A bare scroll container does not answer the arrow keys. What scrolls
from the keyboard is focus: moving focus to a node that is off screen
scrolls every scroll container above it just enough to bring the node
inside, eight pixels from the nearest edge, and does nothing when it is
already visible. A node bigger than its container can't be shown whole,
so it's brought in by its start, unless it already fills the view, as
a browser's `focus()` does: a page that opens with its first section
focused stays at its top. A section that starts below some padding is
still brought in eight pixels from the edge, so a page that focuses it
only to have it read out says `autoFocus({ preventScroll: true })` or
`focus(node, { preventScroll: true })` and stays where it is. Clicking a
control does not reveal it, because a
person can already see what they pressed and moving it would pull it
out from under the pointer resting on it.
A component that owns a list of rows maps the keys itself.
`LazyList` and `DataTable` in `gesso-components` answer to the arrows,
`PageUp` and `PageDown`, `Home` and `End` by moving the selection,
which then reveals itself through the same mechanism. See
[using components](https://gesso-docs.vercel.app/guide/using-components).
A highlight that moves without focus has to ask. A combobox's arrows
walk its list while the caret stays in the field, so nothing is
focused to reveal; it calls `ScrollService.scrollIntoView(node)`, which
scrolls the containers above the node the same way, by the same
amount, and leaves a visible node where it is:
```tsx
const scroll = ctx.inject(ScrollService);
ctx.effect(active, index => {
const row = rows.get(index);
if (row !== undefined) {
scroll.scrollIntoView(row);
}
});
```
## What the main thread is left doing
In a browser, scrolling a page is the compositor's job and the main
thread mostly hears about it afterwards. Here, layout, the scroll
chain, the offsets, the sticky shifts and the scrollbars are all in the
render worker, and the shell around it is left with one genuinely hard
decision: whether to call `preventDefault()` on the DOM wheel event.
It has to answer synchronously, inside the handler, and the runtime is
a `postMessage` away. Preventing every wheel makes the canvas a scroll
trap in the page around it; preventing none makes one wheel scroll two
things. So the worker pushes the answer ahead of the event: after any
frame that changed it, it reports which way the pointer's scroll chain
can still move, and the shell reads that from a cache when a wheel
arrives. The same report says whether anything in the tree scrolls at
all, which is what the canvas's `touch-action` is set from, since that
is latched when a finger lands and there is no position to ask about
yet.
The cost is that the answer can be one frame old: just after a
container reaches its end, one notch may be swallowed; just after it
leaves its end, one may reach the page. That is the same bet a browser
makes when it scrolls on the compositor thread.
**Single-thread configuration differs here, and only here.** With the
runtime on the main thread the wheel handler asks the controller
directly and prevents the default from the real answer, with nothing
cached and nothing stale. Everything else on this page behaves the
same in both configurations.
## Next
[Sticky positioning](https://gesso-docs.vercel.app/layout/sticky) is the other half of a scroll
container: a header that holds at its edge while its rows travel under
it.
---
# Sticky positioning
A sticky node is in flow, exactly where its row or column put it, until
scrolling would carry it past the edge it named. Then it is held there,
and released again when the group it belongs to scrolls away.
It is one property and one inset:
```tsx
```
`position` also takes `static` (the default), `relative` and
`absolute`. This page is only about `sticky`, and it assumes
[overflow and scrolling](https://gesso-docs.vercel.app/layout/overflow-and-scrolling), since a
sticky node with nothing to stick inside it does nothing at all.
## Headers that hold, and then leave
Scroll the list, and watch the group headers hand the top edge to one
another:
```tsx
/**
* One group of rows under a header that stays with them.
*
* `position="sticky"` with `top={0}` keeps the header at the top edge
* of the nearest scroll container above it, which here is the list and
* not the page. The header is in flow the whole time: it takes its
* space among the rows, and the shift that holds it at the edge is
* applied after layout rather than by it.
*
* What bounds the shift is this `column`. A sticky node never leaves
* its own parent's box, so when the group scrolls away the header goes
* with it and the next group's header takes the edge. That is the
* difference between a sticky header and a fixed one, and it is the
* reason each group's header lives inside the group.
*
* The header is a heading, and stays one while it is stuck: the
* accessibility mirror reports the rectangle it is seen in, so a screen
* reader's cursor lands on the header where the eye finds it.
*/
function Region(
inputs: Inputs<{ title: string; places: readonly string[]; onPick: (place: string) => void }>,
_ctx: ComponentContext
) {
return (
{inputs.places.value.map(place => (
inputs.onPick.value(place)}
height={36}
paddingLeft={10}
// A list row, so the place name starts at the left rather than centring.
x="start"
y="center"
backgroundColor="background"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
))}
);
}
```
## What it sticks to
**The nearest scroll container above it**, meaning a `` or
any node with `overflow` set to `scroll` or `auto`. Not the window, and
not the page the canvas is embedded in: an application's root is a
scroll container only if you made it one, and a sticky node with no
scroll ancestor above it simply stays in flow.
**At the inset it named.** `top`, `right`, `bottom` and `left` are how
far inside the container's edge the node is held, so `top={0}` means
flush and `top={8}` leaves eight pixels of the content showing above it.
A node that names no inset never sticks, because there is no edge to
hold it at. One inset per axis: with both `top` and `bottom` set, `top`
wins, and `left` wins over `right`. Naming one on each axis is how a
table's corner cell stays put while both the header row and the label
column do.
## What lets it go
**Its parent's box.** A sticky node is never shifted beyond the box of
the element that contains it, so when that element scrolls away the node
goes with it and the next one takes the edge. That is the whole
difference between a sticky header and a fixed one, and it is why each
group's header in the example lives inside the group rather than beside
it:
```tsx
```
The `column` is the bound, and the header travels inside it. A sticky
node whose parent _is_ the scroll container is bounded by the scrollable
extent instead, so that one holds at the edge for the whole list.
## It is not a layout change
Sticking is applied after layout, not by it. The node's layout box is
its flow box, unshifted, and the shift is added when the node is
painted and undone when it is hit tested. Three consequences, and they
are the ones that catch people out:
- **Nothing re-measures when a list scrolls.** The offsets are
recomputed on scroll-only frames, which measure nothing and place
nothing but the overlays anchored to what moved, so a sticky header
costs a translation per frame rather than a layout.
- **Geometry read from layout is the flow position.** That is the
answer a layout animation needs, since a node whose list scrolled has
not moved. Where the node is _seen_ is a different question, and the
accessibility mirror is given that one: a screen reader's cursor
lands on the header where the eye finds it.
- **It is still a real node.** The pointer meets it where it is drawn,
so a hover, a press and a click land on the header rather than on the
row passing behind it.
## What it covers
Within one `zIndex`, positioned children paint after their in-flow
siblings, which is CSS's stacking rule and what lets a sticky header
paint over the rows travelling under it. Hit testing walks that order in
reverse, so a press at the container's top edge meets the header rather
than the row behind it. A sibling with a higher `zIndex` still paints
over the header, since the ordering is by `zIndex` first.
A sticky node is also a containing block for absolutely positioned
descendants, as in CSS.
**It carries anchored overlays with it.** An overlay anchored to a
sticky node, or to anything inside one, is placed against where the
node is held rather than against the box it left behind, so a menu
opened from a button in a sticky toolbar stays under its button while
the list travels underneath, and rides back down with the toolbar when
the group lets it go. The flip reads the held position too: the overlay
takes the side that has room where the anchor is seen. See
[positioning and overlays](https://gesso-docs.vercel.app/layout/positioning-and-overlays) for the
rest of anchored placement.
## Everything that scrolls, scrolls it
The offsets are recomputed from the scroll offset the container settled
on, and every way of moving a list writes that same offset: a wheel, a
smoothed notch mid-animation, a scrollbar drag, a finger, and the reveal
that brings a focused row on screen. There is no separate path for a
sticky node to fall out of step with.
## What was checked
The engine's sticky cases were compared against Chrome: the same trees
in a browser, with the same `scrollTop`, agreeing with Gesso's visible
boxes to within a tenth of a pixel, for `top`, `bottom` and `left`
insets and for nested scroll containers. Engine specs cover an overlay
anchored to a sticky header, and one anchored to a button inside such a
header, following it to the scrollport edge and back when it lets go. A
parity spec runs a scrolled list with a sticky header through both
renderers and compares the draws, and the same tree was checked by
screenshot on the WebGPU worker route in Chromium. No other browser has
been measured.
## Next
Back to [overflow and scrolling](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) for the
container a sticky node lives in, or on to
[light and dark](https://gesso-docs.vercel.app/guide/appearance) for where the colours these
examples inherit come from.
---
# Positioning and overlays
Most of a screen is laid out by rows, columns and grids. The parts that
float over it are not: a menu, a popover, a dialog and a toast all have
to escape the box that opened them and be placed against something
else. That is what `position` and the overlay layer are for.
The panel below is anchored to the button in the list. Move the button
towards the bottom edge and watch which side the panel takes; expand
the notice at the top of the list and watch the panel follow a button
that nothing scrolled:
```tsx
/**
* A panel anchored to a button, and a button the reader can send to
* the bottom edge.
*
* Two ideas are on screen at once.
*
* - **The panel is not a child of the list.** It is an overlay entry,
* rendered by the layer the runtime mounts above the whole app, and
* it is placed by naming the button's node as its `anchor`.
* - **The engine, not the component, decides which side it goes on.**
* `bottom-start` is a request. With no room under the button the
* panel flips above it, and it is shifted along the button so that
* it stays inside the viewport rather than hanging off the right
* edge.
*
* The button can be moved two ways, and the panel follows both: the
* list scrolls under it, or the notice above it grows and pushes it
* down the list. Nothing in the component re-places the panel; the
* engine does it on the frame the button's box changed.
*
* The anchor is a `UiNode`, taken from the button's `ref` and read
* inside `show()` rather than captured earlier: a ref fires after the
* component's body has run, so anything reading it sooner reads null.
* Passing the same node as `environment` is what keeps the panel in
* this example's theme, since the layer it renders in is nowhere near
* the tree that opened it.
*/
export function Anchored(_inputs: Inputs<{}>, ctx: ComponentContext) {
const panel = useOverlay(ctx, 'docs-position');
const at = internalState(ANCHOR_HIGH);
const notice = internalState(NOTICE_SMALL);
let anchor: UiNode | null = null;
const show = (): void => {
panel.show(
,
{ anchor, placement: 'bottom-start', offset: 8, environment: anchor }
);
};
ctx.onMount(show);
const toggle = (): void => {
if (panel.isOpen()) {
panel.hide();
} else {
show();
}
};
return (
(at.value = ANCHOR_LOW)} />
(at.value = ANCHOR_HIGH)} />
(height === NOTICE_SMALL ? 'Expand the notice' : 'Collapse the notice')))}
onPress={() => (notice.value = notice.value === NOTICE_SMALL ? NOTICE_LARGE : NOTICE_SMALL)}
/>
(at.value = offset.y) })]}
borderWidth={1}
borderColor="border"
borderRadius={8}
backgroundColor="surface">
{Array.from({ length: ROWS }, (_unused, index) =>
index === ANCHOR_ROW ? (
{
anchor = node;
}}
label="Details"
onClick={toggle}
width={120}
height={24}
x="center"
y="center"
borderRadius={6}
borderWidth={1}
borderColor="border"
backgroundColor="background"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
) : (
)
)}
);
}
```
## Four positions
`position` takes the same four values CSS does, and reads the same way:
| Value | What it does |
| ---------- | ------------------------------------------------------------------------------------------- |
| `static` | The default. The node is laid out in flow and the edge props are ignored. |
| `relative` | In flow, then shifted by its edge offsets. Siblings do not move. |
| `absolute` | Out of flow. It contributes nothing to its parent's size and is placed against a block. |
| `sticky` | In flow until scrolling would carry it past an edge it named. See [sticky](https://gesso-docs.vercel.app/layout/sticky). |
`top`, `right`, `bottom` and `left` are the offsets, and `inset` sets
all four at once. Any of them can be a percentage or a number.
## What an absolute box is placed against
**Its containing block: the nearest ancestor whose `position` is
`relative`, `absolute` or `sticky`, and the layout root when there is
none.** The example's list is the containing block for nothing, because
nothing inside it is absolute; the panel's containing block is the
overlay layer, which covers the viewport, and that is why the panel
flips at the edge of the canvas rather than at the edge of the list.
Three rules decide the box itself, and they are CSS's:
- **Both edges on an axis, and no explicit size, is a tight box.** The
node is stretched between them.
- **Otherwise the node is its own size and one edge places it.** With
both edges set and a size given as well, `left` wins over `right` and
`top` over `bottom`.
- **Margins apply.** They are subtracted from the space between the
edges, as in CSS.
With no edge set at all, the node sits at the containing block's
origin. CSS would put it where it would have been in flow, its static
position; that is the one place this deliberately stops short.
Absolutes are placed after the flow, not measured with it, because a
containing block's own box is only final once its in-flow children have
been laid out. That is the same order the CSS specification gives, and
it is why a percentage offset resolves against a block whose size is
already known.
**A block that changes size places its absolutes again, wherever they
are below it.** An absolute is placed when its parent is, and its
parent can be some way under its containing block: a panel pinned to
the window's corner from inside a column of the app. When only the
block changes, as the window does on a resize, the column keeps its box
and is not placed again, so the engine keeps the block each absolute
was placed against and, after any pass in which a positioned node or the
root took a new box, places again the ones whose block is not that box
any more. Until it did, such a panel stayed at the corner of the window
it was first laid out in.
## Stacks align their children
A `` (exported as `Stack` in the factory API, which says what the
children do) puts every child at the same origin. It takes the same `x`
and `y` alignment props a row or a column does, and a child can
override with `selfX` and `selfY`:
```tsx
```
`stretch` skips a child that has an explicit size on that axis, and a
stretched child is measured tight, so text inside one wraps at the
stack's width rather than at its own.
A box's children start at its top left. A `` is a stack too,
and its children start in its middle, both ways, as an HTML button's
label does: a label in a 30 pixel high button sits halfway down it
without an alignment prop. `x` and `y` override that as they do on a
box, so a row that should span the button takes `x="stretch"`.
## zIndex is decided in layout
Within one `zIndex`, positioned children paint after their in-flow
siblings, which is what lets an overlay cover the content it opened
over. `zIndex` reorders siblings: higher paints later and is hit first,
and ties keep tree order.
The ordering is recorded during layout rather than sorted at paint
time. When any child of a node carries a non-zero `zIndex`, the paint
order is written onto the parent's layout record, and both renderers
and the hit tester read that one list, the hit tester in reverse. Paint
and hit testing therefore cannot disagree about who is on top.
## Anchored placement
An absolute node given an `anchor`, which is a `UiNode`, is placed
beside that node instead of by its edges. These props say how:
| Prop | Type | What it does |
| -------------- | ------------------------- | ----------------------------------------------------------------- |
| `anchor` | `UiNode` | The node to sit beside. Edge offsets are ignored while it is set. |
| `placement` | see below | The side, and the alignment along it. Default `bottom`. |
| `anchorOffset` | `number` | The gap between the two boxes. Default 0. |
| `anchorRect` | `{ x, y, width, height }` | A part of the anchor to sit beside instead of all of it. |
`anchorRect` is in the anchor's own coordinates, from the top left of
its border box. The node is placed against that rectangle exactly as
it would be against the whole anchor, flip and shift included, and it
follows the anchor in the same way. It is what puts a list under the
word being typed in a paragraph as wide as the page: see
[putting something at the caret](https://gesso-docs.vercel.app/components/text-input#putting-something-at-the-caret).
`placement` is a side, optionally followed by an alignment:
`'top'`, `'bottom'`, `'left'`, `'right'`, each also available as
`-start` and `-end`. A bare side centres the node on the anchor;
`-start` lines up their leading edges and `-end` their trailing ones.
Two adjustments then run, and they are Floating UI's defaults without
the DOM:
- **Flip.** Two things have to be true: the node does not fit on the
side it asked for, and the opposite side has more room than that
side. A node that fits stays put, and a node that fits on neither
side goes to whichever has more room.
- **Shift.** Along the other axis the node is clamped to the containing
block, so it slides along the anchor rather than hanging off the
edge. The example's panel is always shifted, because its button sits
against the right of a list that reaches the canvas edge.
Both are decided in the layout engine rather than in a pass afterwards,
which is what makes them frame-exact. There is never a frame in which
the panel is beside where the button used to be.
**An anchor that moves takes its overlays with it, whatever moved it.**
The engine keeps an index of which anchored nodes each anchor carries.
A box that comes out somewhere new names its overlays, and they are
placed again at the end of the frame, once every box in the pass is
final: it does not matter whether the anchor moved because the list
scrolled, because its own offsets changed, or because something above
it in the flow grew. A [sticky](https://gesso-docs.vercel.app/layout/sticky) anchor moves without
its box moving at all, so the shift is watched the same way: a header
that takes the top edge of its list takes its overlays with it, and
hands them back when it lets go. A frame in which no box moved and no
sticky node shifted asks the index nothing, so an idle overlay costs
nothing to keep open. A full layout is the one pass that settles every
anchored node rather than only the ones the index named: it places the
whole tree anyway, and the offsets a scrolled list ends up with are
known only once it is over.
**The anchor and the overlay may be under different scroll
containers.** Layout boxes are pre-scroll and unshifted, so before
comparing the two the engine sums the scroll offsets and the sticky
shifts above each of them and brings both into the same visible space.
Placement therefore reads where a node is seen, and so does the flip:
an overlay whose anchor is held at the edge of a list takes the side
that has room beside the held position, not beside the flow position
the list scrolled away from. Scrolling and sticking are the two changes
that move an anchor without changing any box, so a scroll frame hands
the anchored nodes their fresh placement directly, and a pass in which
a sticky node's shift changed hands it to the overlays that node
carries. Neither measures anything.
If the anchored panel in your own screen stops following its anchor,
the usual cause is that the anchor never reached the prop. A `ref`
fires after the component's body has run, so a `UiNode` written into a
plain field and passed straight to a child was `null` at the moment the
child read it. Read the anchor inside the handler that opens the
overlay, as the example does, or hold it in a cell so the prop follows
it.
## The overlay layer
Every runtime registers an `OverlayService` and mounts an
`OverlayLayer` above the application root. The layer is one absolutely
positioned box covering the viewport at `zIndex` 1000, and it is
`hitTestable: false`, so with nothing open it is invisible to input.
The service is injected and called; components do not build overlay
nodes themselves. `useOverlay` from `gesso-components` is the
per-instance handle around it: an id of its own, an `open` Observable
to bind to, and a close on unmount, so an overlay cannot outlive the
component that opened it.
```tsx
const panel = useOverlay(ctx, 'details');
panel.show(content, { anchor, placement: 'bottom-start', offset: 8, environment: anchor });
panel.hide();
```
An entry takes either an anchor or its own placement in the viewport:
| Option | What it does |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `anchor` | The node to be placed beside, with `placement` and `offset`. |
| `anchorRect` | A part of the anchor to be placed beside; a plain value or an Observable, which moves it open. |
| `placement` | The side of the anchor, and the alignment along it. `start` and `end` follow reading direction. |
| `top`, `right`, … | Edge offsets against the viewport, for an entry with no anchor. |
| `start`, `end` | Edge offsets from the viewport's reading edges: `start` is the right under right-to-left text. |
| `center` | `'x'`, `'y'` or `'both'`: keep an unanchored entry in the middle of the viewport on that axis. |
| `dismissOnOutsidePress` | Put a backdrop underneath that closes the entry. |
| `modal` | Put the backdrop underneath even when it doesn't close the entry, so the page takes no press. |
| `scrim` | Dim the page under the backdrop with the theme's `scrim`. On by default for a modal entry. |
| `environment` | A node whose theme, text style, colour and reading direction the entry keeps. Default the anchor. |
| `zIndex` | Order among open entries. Later entries paint on top by default. |
Four of those are worth spelling out.
**`start` and `end` are the reading edges.** The engine's `placement`
above is physical, but an entry's is not: along the top or the bottom,
`-start` lines the entry up with the anchor's start edge, its right
under `textDirection="rtl"`, and the sides `start` and `end` (with
`start-start`, `end-end` and the rest) open beside the anchor toward
those edges. `left` and `right` stay physical. The layer resolves them
in the direction of the entry's `environment`, or of its anchor without
one, which is the node its theme and text style come from too: the
content reads one way and opens toward the same side, and both follow
that tree if its direction changes while the entry is open.
**A backdrop closes on the wheel as well as on a press.** A backdrop
necessarily swallows the wheel, and a menu that swallowed scrolling
would feel stuck, so scrolling away closes it, which is what native
menus do. An entry without a backdrop, a popover or a tooltip, stays
open instead and follows its anchor through the scroll.
**A modal entry covers the whole page.** The layer is above the
application root, so an entry and its backdrop are drawn over
everything in the app, whatever `zIndex` a panel in it has: a `zIndex`
orders a node among its siblings, and the app root is the layer's
sibling. Hit testing follows the same order, so a press beside a modal
lands on its backdrop and never on a panel drawn under it. `modal`
gives an entry that doesn't close on an outside press that backdrop
anyway; `Dialog` sets it, so a dialog that can't be dismissed still
keeps the page from presses, as a browser's modal `` does.
A modal entry's backdrop is also dimmed with the theme's `scrim`, which
fades in as it opens; `scrim: false` keeps it clear. A backdrop that is
only there to close a menu is never dimmed.
**An entry inherits nothing from the tree that opened it.** The layer
is mounted above the application root, so the content is nowhere near
the component that asked for it and none of the scoped values reach it:
a menu opened inside a dark panel comes out light. `environment` is the
fix, and the node to pass is usually the trigger. This was not found by
a test; it was found by opening the screen.
The entry keeps following that node while it is open. A dialog open
when the system turns dark, or when a theme the person picked arrives a
moment after they opened it, turns dark with the page behind it rather
than staying light until it closes.
## When to position, and when not to
Reach for `position` and the overlay layer when you are building
something the library does not have. Everything the library does have
is already built on them, and it carries the parts that placement does
not: focus discipline, dismissal, and a keyboard.
| You want | Use |
| ------------------------- | -------------------------------------------------------- |
| A badge on a corner | `position: 'absolute'` inside a relative box |
| A menu from a button | `Menu` from `gesso-components` |
| A context menu | `Menu` with `at={{ x, y }}` instead of an anchor |
| A choice from a long list | `Select` |
| A modal | `Dialog`, which traps focus and returns it to the opener |
| A hint on hover or focus | `Tooltip`, which wraps the element it describes |
| A notification | `Toast` |
| Anything else that floats | `useOverlay` directly |
A `Dialog` traps the keyboard inside itself and hands it back to
whatever opened it on close. Escape closes the topmost overlay and
nothing underneath, and it needs no registry to do it: every overlay
that takes the keyboard traps focus, so the key cannot reach a dialog
below. `Tooltip` and `Toast` never take focus at all, since a tooltip
that could be tabbed into is a trap with no way out.
## What was checked
Twelve positioning cases and eleven stack cases in the layout
conformance suite are compared box for box against headless Chrome,
with the case root made `position: relative` so the containing block is
the same on both sides, and they agree to within a tenth of a pixel.
Between them they cover the tight inset, a single edge, both edges on
one axis, margins, the containing block skipping static ancestors, an
absolute inside a flex child, stack alignment, `selfX` and `selfY`, and
one case that wraps real text between two edges.
Anchored placement has no CSS equivalent that Chrome ships unflagged,
so it is covered by engine specs instead: the side, the alignment, the
offset, a flip on each axis, a shift, following a scroll, following an
anchor pushed down by a sibling that grew, and following an anchor
whose own offsets changed, and one places a node beside a part of its
anchor and follows it through a scroll. Four cover sticky anchors: an overlay that
follows a header to the scrollport edge and back when it lets go, one
anchored to a button inside such a header, one whose anchor is held in
a new place by a layout frame that moved no box at all, and one that
flips above its anchor because the held position leaves no room below.
One more lays out a list that is already scrolled and asserts that the
overlay starts beside where its anchor is seen rather than beside the
flow position. Five specs count the placements the frame made: following an anchor
costs the overlay's own placement and nothing else, a layout frame that
leaves every box where it was places no overlay, and neither does a
scroll frame that changes no sticky shift. The spec beside the example above asserts
what this page claims about it, that the panel starts under its button,
is shifted back inside the canvas, flips above the button when the
scroll leaves no room below, returns underneath when the button moves
back, and stays against the button when the notice above it grows.
Layout does not change with the renderer: boxes are computed once and
both Canvas2D and WebGPU read the result. Chrome is the only browser
any of these numbers were taken from.
## Next
[Virtualization](https://gesso-docs.vercel.app/layout/virtualization) is the other way a screen
stops paying for what it is not showing: a list of any length that
mounts only the rows in view.
---
# Virtualization
A `` measures and places every child it has. That is the
right answer for a settings panel and the wrong one for a log: ten
thousand rows means ten thousand nodes, ten thousand measurements, and
a frame that costs whatever the data costs.
A lazy list inverts that. Only the rows in the viewport exist, and the
rest of the list is two spacer boxes.
Scroll the list below, reverse it, and make it longer. None of those
change how much of it there is:
```tsx
/**
* Fifty thousand rows, of which about a dozen exist.
*
* `LazyColumn` is a `ScrollView` whose children are produced by a
* window: a spacer standing in for the rows above, the rows in view
* plus an overscan band, and a spacer for the rows below. Every other
* ScrollView property still applies, which is why this one takes a
* height, a background and a bound `scrollY` like any other.
*
* Three things about it are worth reading off the code.
*
* - **`renderRow` is a function of the index, not a list of children.**
* It is called once per mounted row, so nothing is built for a row
* nobody can see. That is the whole of the saving.
* - **`count` is bound.** The `lazySource` modifier feeds the window
* the count, so "Add 10,000" changes a cell and the list follows,
* dropping the measurements of rows that no longer exist.
* - **`revision` says the indices mean something new.** Reversing the
* order does not move a row anywhere; it changes what row 12 *is*.
* The mounted rows are rendered again in place, keys unchanged, so
* the list does not jump.
*/
export function Feed(_inputs: Inputs<{}>, _ctx: ComponentContext) {
const count = internalState(STARTING_ROWS);
const newestFirst = internalState(false);
const at = internalState(0);
const renderRow = (index: number) => {
const ordinal = newestFirst.value ? count.value - 1 - index : index;
return (
);
};
const list = LazyColumn(
{
width: percent(100),
height: 220,
count,
revision: newestFirst,
estimatedExtent: ROW,
scrollY: at,
modifiers: [scrollPosition({ onChange: offset => (at.value = offset.y) })],
padding: 4,
borderWidth: 1,
borderColor: 'border',
borderRadius: 8,
backgroundColor: 'surface'
},
renderRow
);
const caption = combineLatest([count, at]).pipe(
map(
([rows, offset]) => `${rows.toLocaleString('en-US')} rows, ${Math.round(offset).toLocaleString('en-US')} px down`
)
);
return (
{list}
(newestFirst.value = !newestFirst.value)} />
(count.value += 10000)} />
);
}
```
## What is actually there
`LazyColumn` is a `ScrollView` whose children come from a window. On
any frame the children are:
1. a lead spacer, as tall as the rows above the window,
2. one keyed wrapper per mounted row,
3. a trail spacer, as tall as the rows below it.
At the size this page embeds, that is eleven wrappers and two boxes,
for fifty thousand rows. The spec beside the example pins the exact
window: eight rows fit in the 212 pixels inside the list's padding, and
the overscan band adds three more past the bottom edge.
The wrappers are keyed, so a row that stays in view keeps its node and
its component across a scroll; a row that leaves is released through
the same disposal path any removed child takes. The graph builder
reconciles them like any other observable children, so nothing here is
a special case in the renderer.
`LazyRow` is the same thing along the horizontal axis. `LazyGrid` is
the vertical one with shared column tracks: every mounted row is a
`Grid` with `subgrid: 'columns'`, so its cells line up with a header
that is rendered once and can be made `position: 'sticky'`. Rows and
header have to be items of a single grid, because tracks cannot be
sized across grids that cannot see one another.
## The estimate, and the corrections
Nothing knows how tall a row is until it has been mounted and measured,
so the window starts from `estimatedExtent` and corrects itself:
- Every measured row whose extent differs from the estimate contributes
one correction entry, kept sorted by index.
- The offset of row _i_ is `i × estimate` plus the corrections before
_i_, and the first visible row is a binary search over that.
- So a fifty thousand row list costs memory and time proportional to
the rows somebody has actually looked at, never to the count.
Two consequences follow, and both are the behaviour of every
estimate-based virtualizer.
**The scroll range is an estimate too, and it settles.** The example's
list reports a content height of fifty thousand rows at the estimate
plus its own padding before anything has been scrolled, which is what
gives it a scrollbar at all. Give `estimatedExtent` the height your
rows actually are and there is very little settling to see.
**Dragging a scrollbar far into rows nobody has seen lands on
estimates**, which then correct as those rows are measured. When a
correction changes the size of the content _above_ the first mounted
row, the window reports the difference and the scroll offset is moved
by it, so the row under the reader's eye does not jump. In practice
that only happens scrolling upward into unmeasured territory.
## The count and the revision
Two values the window cannot work out for itself are bound rather than
fixed at construction, and both arrive through the same `lazySource`
modifier the list attaches to its own node.
`count` is how many rows there are. Hand it an Observable and the list
follows: measurements past the new end are dropped, because a shorter
list that kept corrections for rows it no longer has would claim scroll
range that is not there.
`revision` is any value that changes when what an index _means_
changes: a sort, a filter, a page of data arriving. The mounted rows
are rendered again and their measurements forgotten, but the indices
and the keys do not move, so the rows are reconciled in place rather
than rebuilt and the list stays exactly where it was. The example
passes the same cell it reads inside the row renderer, which is the
usual shape.
A modifier rather than a subscription taken inside `LazyColumn`,
because a modifier's lifetime is exactly its node's, and because
modifiers attach after an element's props and before its children are
reconciled: a count that arrives with the tree is already in the window
when the first children are read from it.
## What it costs per frame
The runtime runs a [`virtualize` phase](https://gesso-docs.vercel.app/tooling/frames-and-phases)
before the frame's dirty set is snapshotted. For every scroll container
carrying a window it reports the container's current scroll offset,
which is the property a wheel has just written rather than last frame's
record, its viewport, and the measured extent of each mounted wrapper.
Because that happens before collection, **rows a scroll reveals are
mounted, laid out and painted on the same frame as the scroll.** The
estimate stands in for their size for that one frame and is corrected
on the next. The phase reports zero when no lazy list exists, so an
application without one pays nothing and the profiler says so.
What is left over is a per-row wrapper node, which is the price of
measuring and keying a row without touching its own props.
## What does not survive
**A row that scrolls out of view is gone.** Its nodes are disposed and
its component is unmounted. The row renderer is called again from
scratch when that index comes back, so anything the row held that has
to outlive it belongs in the application's state, not in the row. This
is true of every virtualized list; it is worth saying because a plain
`` does not behave that way.
**Only the mounted rows are in the semantics tree.** What they say
about the list has to be the whole list even so, and that is what the
`LazyList` component adds on top of this: real `posInSet` and `setSize`
values, so a screen reader is told the list has fifty thousand rows and
not eleven.
## Which one to use
| You have | Use |
| ----------------------------------- | -------------------------------------------------------- |
| A long sequence and your own chrome | `LazyColumn` or `LazyRow` |
| A long sequence, selectable, keyed | [`LazyList`](https://gesso-docs.vercel.app/components/lazy-list) |
| Rows with fields that line up | [`DataTable`](https://gesso-docs.vercel.app/components/data-table) |
| Nested data | [`Tree`](https://gesso-docs.vercel.app/components/tree) |
| Fewer rows than fill two screens | A plain [``](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) |
The engine primitive is the right thing when the list is a piece of
your own layout and you are drawing the rows anyway. The components are
the right thing when it is a control: they carry the roles, the
keyboard and the selection, and they cost you the same window
underneath.
A lazy list is still a scroll container, so everything on
[overflow and scrolling](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) applies to it:
the wheel, the chaining, the overlay scrollbars and the touch pan.
## What was checked
The window's arithmetic is pinned by the engine's own specs: the
initial window from the estimate, movement with a scroll, stable keys,
no emission without a change, sparse corrections, index lookup after
corrections, anchoring, empty and end-of-list cases, the row axis and
custom keys.
The spec beside the example above asserts what this page claims about
it: that fifty thousand rows mount exactly the eleven wrappers the
viewport and the overscan account for, that the scroll range spans
every row before any of them has been measured, that a wheel twenty
thousand pixels down mounts a different window of the same size with
none of the original rows left in it, that a changed count is followed
into the scroll range, and that reversing the order redraws the mounted
rows without moving the list.
Those numbers come from the test text measurer rather than from a
browser, which is what makes them exact. The row heights in the example
are explicit, so the window is the same size either way; a list whose
rows size themselves from their text would mount a window of a
different size in a browser, since the rows would be a different
height.
## Next
[Asking the engine why](https://gesso-docs.vercel.app/layout/explain) is what to reach for when a
box, in a lazy list or anywhere else, comes out a size you did not
expect.
---
# Asking the engine why
A box comes out the wrong size and the record says what happened, not
why. The reasons live in four different places: the flex resolution in
the parent, the stretch at placement, the node's own clamp while it was
measured, and the relayout flags decided a frame earlier. `explain`
collects them.
The title in the card below is not there. It is still a node, still in
the tree, still measured, and it is zero pixels wide:
```tsx
/**
* A title that is not there, and the one property that decided it.
*
* The row is 240 wide and the badge is 240 wide and refuses to shrink,
* so there is nothing left for the title. It is still a node, still in
* the tree, still measured: it is simply zero pixels across, which is
* what `engine.explain` says about it in so many words.
*
* `measure` is how the number gets on screen. It reports the node's box
* whenever the box moves, which is the `ResizeObserver` a canvas does
* not have, and it is the only thing here that is about the example
* rather than about the bug.
*/
export function Vanished(_inputs: Inputs<{}>, _ctx: ComponentContext) {
const rigid = internalState(true);
const titleBox = new BehaviorSubject(EMPTY);
const action = rigid.pipe(map(fixed => (fixed ? 'Let the badge shrink' : 'Make the badge rigid again')));
return (
(fixed ? 0 : 1)))}
paddingLeft={8}
y="center"
backgroundColor="controlBackground"
borderRadius={6}>
`The title is ${Math.round(box.width)} px wide.`))}
fontSize={12}
color="textMuted"
/>
(rigid.value = !rigid.value)}
padding={8}
borderRadius={6}
borderWidth={1}
borderColor="border"
backgroundColor="background"
cursor="pointer"
modifiers={[HOVER_CONTROL]}>
);
}
```
## The answer
`runtime.explain(node)` returns a `LayoutExplanation`, and
`formatExplanation` prints it, one fact per line. Asked about that
title, mounted in the 420 by 260 viewport its spec uses, it says this:
```text
text 'root:0:0:0:1' · 0 × 28 at (330, 79.6)
width 0 flex item of row 'root:0:0:0': base 124.8 from its max-content width; shrank to 0 (flexShrink 1: the items' base sizes exceed the content box of row 'root:0:0:0', so they give up space); its automatic minimum is 0: a scroll container or clipped text has none, so it may shrink to nothing; its content would need 70.2; the extra 70.2 is clipped
height 28 stretched across row 'root:0:0:0': 28; its content would need 31.2; the extra 3.2 is clipped
constraints from row 'root:0:0:0': width 0 (tight) · height 28 (tight)
padding 0 · margin 0 · content box 0 × 28
relayout: not a boundary · content matters to the parent · a change here is laid out from row 'root:0:0:0' (1 level up)
state: measured · placed · position static
```
Read the width line and the bug is over. The title is a flex item; it
wanted 124.8 pixels; the row had nothing left to give it; and the
reason it went all the way to zero rather than stopping somewhere is
that clipped text has no automatic minimum. Press the button in the
example and the badge starts giving up space of its own, so the same
sentence comes back with a number in it instead of a zero.
Those numbers come from the test text measurer, which is deliberate:
they are exact, and the spec beside the example pins this readout line
for line, so a change of wording in the engine has to reach this page.
In a browser the same tree produces the same sentences with real font
metrics in them.
## What is in one
| Field | What it holds |
| ----------------- | ---------------------------------------------------------------------------------------- |
| `box` | The border box, in layout-root coordinates, before scrolling |
| `content` | What the content asked for on each axis, before any clamp |
| `measured` | The size the node reported to its parent |
| `constraints` | What the parent handed down on the last measurement |
| `effective` | Those constraints after the node's own width, height, minimum and maximum |
| `width`, `height` | Per axis: the `decidedBy` rule, and `reasons`, the sentences in the order they ran |
| `relayout` | Whether the node is a boundary, and the ancestor a change here is laid out from |
| `state` | The dirty flags, the `position`, whether it clips, and whether the last pass measured it |
| `scroll` | For a scroll container, or a field that scrolls its own text |
| `sources` | Per property, which modifier is writing it and what the element declared |
`decidedBy` is the single rule that fixed the measured size on that
axis, so a tool can group or colour by it:
| Rule | Meaning |
| -------------- | ----------------------------------------------------------- |
| `viewport` | The layout root filling bounded constraints |
| `explicit` | The node's own `width` or `height` |
| `flex` | Flexed by a row or column: grew, shrank, or held at a clamp |
| `stretch` | Stretched across a container's cross axis or a grid area |
| `inset` | An absolute node with both edges of the axis set |
| `parent` | A tight size from the parent for some other reason |
| `content` | What the content needed |
| `min`, `max` | Content, then raised by a minimum or capped by a maximum |
| `aspect-ratio` | Derived from the other axis |
A node with no layout record gets a reason instead of an explanation:
it is not under the layout root, or it is a fragment, or it was added
after the last pass.
## It reads; it does not recompute
Everything above is built from the record and the node's properties
after layout has run. Nothing is laid out a second time to answer the
question, which is what makes `explain` safe to call from a hover
handler sixty times a second.
A handful of facts are recorded where the decision was made, because
they cannot be recovered afterwards: the content's intrinsic size
before the node's own clamp, and, for a flex item, the base, the
minimum, the maximum, and whether that minimum was CSS's automatic one
or an explicit one. Everything else is derived on the spot, including
the relayout root, which is the same walk a dirty mark takes without
marking anything.
## In a test
`gesso-testing` exposes both forms on a mounted tree:
```ts
const ui = renderTest(createComponent(Card, {}));
ui.explain(node); // the LayoutExplanation
ui.explainText(node); // the same, printed
```
The matchers are where it earns its keep. A `toHaveBox` that misses
prints the explanation under the numbers, so the answer is already on
screen when you go looking for it:
```text
Expected 'root:0:0:0:1' to have width 120, but its box is {"x":330,"y":79.6,"width":0,"height":28}.
Why:
text 'root:0:0:0:1' · 0 × 28 at (330, 79.6)
…
```
That is the whole explanation, not a summary of it, and the spec beside
the example asserts as much. Import the matchers to get it:
```ts
import 'gesso-testing/matchers';
```
## Under the pointer
The same text is what the node inspector paints over a running
application, on the frame's own canvas, so it works unchanged inside a
render worker where there is no DOM. Hover a node and the panel shows
the explanation along with what the element declared, what a modifier
is writing over it, and which component rendered it. See
[inspecting a node](https://gesso-docs.vercel.app/tooling/inspecting-a-node) for the rest of that
report.
Turning the inspector on sets the engine's trace flag, which is what
fills in `state.measuredLastPass`; turning it off clears it, so an
application nobody is inspecting pays nothing for the facility.
## When a modifier is the answer
A modifier can write the property whose value you are asking about,
and a node that says `width 240` while the element says 200 is the most
confusing thing in this system. So the explanation names the writer:
the axis a modifier changed carries it as its last reason, and
`sources` carries every other property the same way. Nothing is
computed for that; the override cascade already records who wrote what,
in the order the element listed them.
## A property the engine does not know
`widht: 200` used to be silent. It was stored on the node and read by
nothing, so the box came out at its content width with no message
anywhere.
Element props are now checked against the property registry as the
graph is built, and an unregistered one throws, naming the closest
registered name when there is one: `widht` suggests `width`, `colour`
suggests `color`, and an adjacent transposition counts as a single
edit. A prop starting with `on` whose value is not a function is
rejected too, where before it was stored and ignored.
The practical consequence is that ad-hoc data cannot be smuggled onto a
node through a prop any more. Register a property definition for it, or
keep it somewhere other than the tree.
## What was checked
`LayoutEngine.explain`'s own specs cover each `decidedBy` rule against
a real tree: the shrink to nothing, the automatic minimum that stops a
text short of it, an explicit size and the clamp applied to it, content
against a raised minimum and a capped maximum, a stretched cross axis
and a grown main axis. The testing library's specs cover the matcher
handing back the explanation, and the inspector's cover the painting.
The spec beside the example on this page pins the printed readout line
for line, the sentences changing when the cause changes, the number the
example draws on its own canvas, and the failure message a missed
`toHaveBox` produces.
## Next
[Inspecting a node](https://gesso-docs.vercel.app/tooling/inspecting-a-node) is this answer on a
running application, next to everything else a node can be asked.
---
# Pointer and keyboard
A canvas is one DOM element, so the browser can say that something was
pressed and where, and nothing more. Everything after that is the
framework's: which node was under the point, whether it wants the
press, what the press did to hover and focus, and where the next key
goes.
The conversion happens once, at the edge. Browser events become
framework events at the boundary and application code never sees a
`PointerEvent`, a `KeyboardEvent` or a `deltaMode` again.
## One event model
Every event an application can listen for, whether it came from the
platform or was synthesized by the framework, travels through the same
dispatcher. A handler is a prop named `on` followed by the event in
PascalCase:
| Group | Events |
| -------- | --------------------------------------------------------------------------------------------------------- |
| Pointer | `onPointerDown`, `onPointerUp`, `onPointerMove`, `onPointerCancel` |
| Boundary | `onPointerEnter`, `onPointerLeave` |
| Wheel | `onWheel` |
| Keyboard | `onKeyDown`, `onKeyUp` |
| Focus | `onFocus`, `onBlur` |
| Editing | `onBeforeInput`, `onInput` |
| Gestures | `onClick`, `onLongPress`, `onDragStart`, `onDragMove`, `onDragEnd`, `onPanStart`, `onPanMove`, `onPanEnd` |
There is no separate gesture pipeline. A click, a long press, a pan and
a drag are synthesized from the press sequence and dispatched exactly
as a `pointerdown` is, so one mental model covers all of them and a
container can intercept a gesture the same way it intercepts a press.
A prop whose name matches `on` plus a capital letter but is not on that
list is an error naming the prop, rather than a handler that silently
never fires.
## What a pointer event carries
- **`x` and `y`**, in canvas pixels, measured from the top left of the
drawing surface. These are the same coordinates layout works in, so a
point can be compared with a node's box without conversion.
- **`buttons`**, the pressed-button bitmask the DOM reports.
- **`modifiers`**, an object of four booleans: `ctrl`, `shift`, `alt`,
`meta`. Every event that could plausibly be modified carries one,
including wheel and keyboard events.
- **`pointer`**, the device: `{ id, kind }`, where `kind` is `'mouse'`,
`'pen'` or `'touch'` and `id` is the DOM's `pointerId`, which is what
separates two simultaneous contacts. A `pointerType` the framework
does not recognise reads as a mouse, because a device behaving like
the default is better than one whose events are dropped.
`target` is the node the hit test found; `currentTarget` is the node
whose listener is running. The difference between them is the whole of
bubbling, and the example below is built on it.
## Who gets the press
```tsx
/**
* One handler, on the panel, and two children that answer to the hit
* tester differently.
*
* The panel reports whether the press landed on the panel itself or
* on something inside it, which is the difference between
* `event.target` (the node the hit test found) and
* `event.currentTarget` (the node whose listener is running). A press
* on the first chip lands on the label inside it and bubbles up to
* here. A press on the second lands on the panel, because
* `pointerEvents="none"` takes that box and everything under it out
* of hit testing entirely, and the panel is the next thing under the
* point.
*
* `onPointerEnter` and `onPointerLeave` are target-only, as the DOM's
* are: they fire on the boundary of the panel's subtree, so moving
* between the two chips does not leave and re-enter it.
*/
const panel = (
(hover.value = 'the panel')}
onPointerLeave={() => (hover.value = 'nothing')}
onPointerDown={event => {
const what = event.target === event.currentTarget ? 'the panel' : 'a child of it';
press.value = `${what} at ${Math.round(event.x)}, ${Math.round(event.y)}`;
}}>
);
```
Hit testing walks the retained tree in the inverse of paint order:
last child first, because it painted on top, and descendants before the
node itself, because children paint over their parent's background. The
first box the point falls inside wins. Along the way the walk carries
the things that make a box's screen position differ from its layout
record:
- A clipping container rejects a point outside its box before testing
anything inside it, so a child cut off by
[`overflow`](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) is not clickable either.
- A scroll container shifts the point by its offset before testing
descendants, because their records are in pre-scroll content
coordinates.
- Every node applies the inverse of its own transform, so a rotated or
scaled box is tested in its own space rather than by its bounding
rectangle. A degenerate transform, one scaled to nothing, draws
nothing and is hit by nothing.
Four props decide whether a node takes part at all:
| Prop | Effect |
| -------------------------------- | ---------------------------------------------------------------------- |
| `disabled` | The node and its whole subtree are inert: no hit, no focus, no keys |
| `pointerEvents="none"` | The node and its whole subtree are skipped by hit testing |
| `hitTestable={false}` | The node itself cannot be a target; its children still can |
| `visible={false}`, `opacity={0}` | The subtree is skipped, matching the renderer, which paints none of it |
`hitTestable` is the one worth remembering: a decorative wrapper that
must not swallow presses meant for what is drawn inside it sets that,
not `pointerEvents`. Unlike CSS, a child's `pointerEvents="auto"` does
not bring it back under a `"none"` ancestor, so an overlay holding a
toolbar is `hitTestable={false}` with the toolbar left as it is.
## Capture, click and cancel
A press is owned by the node it started on. From `pointerdown` until
the release, every move and the up are routed to that node even when
the pointer has left its box, so a widget keeps its own drag.
A `Click` is synthesized on release when three things hold: the press
travelled no more than four pixels (ten for a finger, which never holds
as still as a mouse), the `pointerdown` was not `preventDefault()`ed,
and no gesture claimed the press. A `pointercancel` ends the press and
never produces a click.
The recognizer resolves each press into at most one gesture. Moving
more than eight pixels (twelve for a finger) before the hold time makes
it a **Pan**; holding still for 500 ms makes it a **LongPress**, and
moving after that makes it a **Drag**. A tap produces no gesture events
at all, which is why click synthesis lives outside the recognizer.
A press is also owned by one contact. While a press is in flight, a
second finger's moves and releases are ignored, so a second contact
cannot drag what the first one is holding.
## Propagation
Dispatch mirrors the DOM: capture from the root down to the target's
parent, then the target's own listeners, then bubble back up to the
root.
| Call | What it does |
| ---------------------------- | ------------------------------------------------------------ |
| `stopPropagation()` | Ends the current phase and skips every remaining phase |
| `stopImmediatePropagation()` | Also skips the rest of the listeners on the current node |
| `preventDefault()` | Cancels the framework's own default behaviour for that event |
`preventDefault` is advisory, and what it cancels depends on the event:
on a `pointerdown` it suppresses click synthesis and focus on press; on
a `pointermove` it stops the move being read as a gesture; on a wheel it
stops the automatic scroll; on a `keydown` for Tab it stops focus
moving. This is documented per consumer rather than inherited from a
browser, because there is no browser default here to mirror.
`Focus`, `Blur`, `PointerEnter` and `PointerLeave` do not bubble. They
are delivered to the target only, exactly as the DOM's `focus` and
`mouseenter` are.
A listener that throws is reported and the dispatch continues. One
broken `onClick` must not stop an event reaching the rest of the tree.
## Hover and press state
While nothing is pressed, each move hit-tests and, when the hovered
node changes, `PointerLeave` and `PointerEnter` fire on the boundary
between the two ancestor chains. The lowest common ancestor is in both
chains and never fires, which is why moving between two chips inside
one panel does not leave the panel.
Hover is state, not paint. A `` publishes `hovered` and
`pressed` as `visualState`, and nothing draws a colour from that on its
own, because what a hovered control looks like belongs to the
application. The examples on this site share two `interactive`
modifiers that say what a hovered and a pressed control look like here,
and both are declared once at module scope rather than per call site, so
the two colours are settled in one file. Writing the same options inline
would work too: a modifier's arguments are compared by value, so an
`interactive({ ... })` in the render is the same modifier on the render
after it and keeps the pointer state it is holding.
The cursor is resolved the way CSS inherits it: the node's own
`cursor`, else the nearest ancestor's, so a button sets
`cursor="pointer"` once and its label inherits it. An editable shows
the I-beam without being asked.
A finger's hover is dropped when it lifts, since the finger is no
longer anywhere. A mouse keeps its hover, which is what a mouse user
expects. Without that rule a tap leaves the tapped node lit for good.
## Keys
```tsx
/**
* Three tab stops, in the order they are written.
*
* The two buttons are focusable because a `` is; the box is
* focusable because it says so. Nothing else here is a tab stop, and
* the panel above takes presses without ever taking focus.
*
* The arrow keys are handled on the box, which only ever sees them
* while it holds focus: a key is dispatched to the focused node and
* bubbles from there, so the button beside it hears the same press
* and the box does not.
*/
const stops = (
(focus.value = 'First')}
onBlur={() => (focus.value = 'nothing')}>
(focus.value = 'Second')}
onBlur={() => (focus.value = 'nothing')}>
(focus.value = 'the stepper')}
onBlur={() => (focus.value = 'nothing')}
onKeyDown={event => {
if (event.key === 'ArrowRight') {
steps.value++;
} else if (event.key === 'ArrowLeft') {
steps.value--;
}
}}>
`steps: ${count}`))} fontSize={12} color="text" />
);
```
A key is dispatched to the focused node and bubbles from there. With
nothing focused it goes to the application's root element, the one
`renderRoot` or `createApp` was given, so a listener there answers
Escape or Space whether or not anything has focus, and a listener
partway down the tree hears nothing.
After the application's listeners, and only if none of them called
`preventDefault()`, the runtime applies its own defaults in this order:
1. The focused editable's editing keys. See
[text editing and IME](https://gesso-docs.vercel.app/interaction/text-editing-and-ime).
2. Find: the platform's find shortcut opens a session and Escape closes
one.
3. The canvas text selection: copy, select all, or clear.
4. Tab moves focus to the next focusable node, Shift+Tab to the
previous.
A key one of those handles is marked default-prevented, which is how
the shell learns to cancel the browser's own default for it.
Keys reach the application through its canvas, which has DOM focus
only once something put it there. A page that is all application
loads with focus on the page's body, so without help its shortcuts do
nothing until the first click. `createApp({ pageKeys: true })` says the
application is the page: a key pressed while nothing on the page has
focus goes to it, and the canvas takes focus. The templates
`create-gesso-app` writes turn it on. An application embedded in a page
with other things on it leaves it off, since a key pressed on that page
isn't its.
## Focus
A node is focusable when it is a `` or an ``, or
when it says `focusable` explicitly. `focusable={false}` opts a button
out; nothing inert is ever focusable; the graph root never is.
Tab order is document order, parents before children, and it wraps at
both ends. There is no `tabIndex`, so the way to change the order is to
change the tree.
Pressing focuses **the nearest focusable node at or above the press**.
The press usually lands on a label or a box inside a control, and
walking up is what makes clicking a tab select it and leave the arrow
keys working. A press with nothing focusable above it leaves focus
where it was: pressing the background is not a request to blur.
Moving focus by keyboard scrolls the focused node into view; moving it
by pointer does not. A person who clicked something can already see it,
and scrolling would pull it out from under the pointer still resting on
it. Moving it from code scrolls as the keyboard does, unless the call
says `{ preventScroll: true }`, as `element.focus()` takes in a browser:
see [focus and traps](https://gesso-docs.vercel.app/interaction/focus-and-traps#autofocus).
Components reach focus through `FocusService`, injected like any other
service. It offers `focus`, `blur`, `focusNext`, `focusPrevious`,
`trap` and `releaseTrap`, and publishes `focused` and `trapped` as
state a control can bind to.
`trap(node)` confines focus to a subtree until `releaseTrap()`: the
traversal collects only that subtree, and focusing anything outside it
is refused, so both halves of a modal dialog come from one rule. Traps
nest, each release restores the control that opened it, and a trapped
subtree that simply unmounts hands the keyboard back without having to
sequence its own teardown.
## What the shell still does
In the worker configuration the tree, the layout, the hit tester and
every controller above are in the render worker. The main thread keeps
the canvas and the plumbing, and its input job is small but not empty:
- **Listen and forward.** Pointer, wheel and key events are converted
to plain messages and posted. Each carries the DOM event's own
timestamp, so input latency is measured from when the person acted
rather than from when the worker got round to it.
- **Capture the contact.** `setPointerCapture` on the press, so a drag
that leaves the canvas is still delivered. A finger captures
implicitly and a mouse does not; capturing both makes them behave
alike.
- **Cancel the two defaults that are always wrong.** Tab must not move
focus out of the canvas, and the select-all shortcut must not select
the page around an app selecting its own text. Both have to be
cancelled synchronously, and the worker's answer cannot arrive in
time, so the shell cancels them itself. The find shortcut is the same
problem and is a flag, off by default: taking it from an app with no
find of its own would leave the person with neither.
- **Answer for the wheel from a cache**, which
[overflow and scrolling](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) covers in
full.
- **Mirror the cursor** onto the canvas element, since the runtime
knows which node is hovered and only the DOM can show a cursor.
- **Prepare the surface**: `touch-action`, `user-select` and two
`-webkit-` properties that stop a long press raising the iOS callout
menu and a tap flashing a grey box.
- **Carry an assistive technology's input in.** An off-screen DOM tree
mirrors the semantics, and a press or a focus move made there arrives
as an action for the runtime to apply, rather than as a pointer event
at a coordinate.
Single-thread configuration differs in one place: with the runtime on
the main thread, the platform adapter asks the controllers directly and
cancels the browser's default from the real answer, with nothing
cached. Everything else on this page behaves the same either way.
## Limits
**Touch was not verified on a physical device.** Every behaviour on
this page that depends on `pointerType` is covered by unit tests, and
was exercised in Chrome on Linux with synthetic touch pointers against
a real render-worker shell: a drag scrolled a container and coasted,
the same drag from a mouse pointer did not, and hover cleared on
release. No phone or tablet was available, so the iOS-specific surface
settings above are inert where they were tested, and how a flick feels
in a hand is unmeasured.
**Two-finger gestures are not here.** The controller knows which
contact owns a press, which is the bookkeeping a pinch recognizer would
need, and stops there. Nothing synthesizes Pinch or Rotate.
**Hit targets are exact rectangles.** A tap two pixels outside a small
control does nothing, where a browser would apply touch adjustment.
Size controls for a finger rather than relying on slop.
**Nothing reads `visualViewport`.** When a soft keyboard covers the
bottom of the window, the runtime does not know, and a caret can sit
behind it.
## Next
[Text editing and IME](https://gesso-docs.vercel.app/interaction/text-editing-and-ime) is what
happens after a key reaches a field, and
[TextInput](https://gesso-docs.vercel.app/components/text-input) is the component built on it.
---
# Touch and gestures
A finger is not a small mouse. It arrives through the same Pointer
Events, so a touchscreen was never silent, but almost everything worth
doing with it follows from knowing which of the two it was: a tap moves
further than a click, a hover ends when the contact lifts, and nothing
at all scrolls a container, because a finger produces no wheel.
The device therefore reaches the application. Every pointer event
carries `pointer`, a `{ id, kind }` where `kind` is `'mouse'`, `'pen'`
or `'touch'` and `id` is the DOM's `pointerId`. A `pointerType` the
framework does not recognise reads as a mouse, on the grounds that a
device behaving like the default is better than one whose events are
dropped.
The recognizer copies the device onto the gestures it synthesizes too,
so a listener that has to size a drag handle for a finger learns it from
the `DragStart` rather than from a raw press it never saw.
## One press, one gesture
```tsx
/**
* One box, four handlers, and at most one of them fires per press.
*
* The recognizer resolves each press into a single gesture. A press
* that moves more than eight pixels (twelve from a finger) before the
* hold time is a **Pan**; one that holds still for 500 ms is a
* **LongPress**, and moving after that makes it a **Drag**. A tap
* claims no gesture at all, which is why `onClick` is the one that
* fires for it.
*/
const pad = (
(gesture.value = 'a tap: a Click, and no gesture')}
onPanStart={() => (gesture.value = 'a pan: it moved before the hold time')}
onLongPress={() => (gesture.value = 'a long press: it held still for 500 ms')}
onDragStart={() => (gesture.value = 'a drag: a long press, then a move')}>
);
```
Every press resolves to at most one gesture, and the box above reports
which one it was. There is no separate gesture pipeline: a Pan, a
LongPress and a Drag are synthesized from the press sequence and
dispatched through the dispatcher that carries a `pointerdown`, so a
container intercepts a gesture exactly the way it intercepts a press.
| The press | What it becomes |
| ---------------------------------------- | -------------------------------------------- |
| Moves past the slop before the hold time | `PanStart`, `PanMove`, `PanEnd` |
| Holds still for 500 ms | `LongPress`, once |
| Holds still, then moves | `DragStart`, `DragMove`, `DragEnd` |
| Neither, and is released | No gesture at all, and a synthesized `Click` |
Click synthesis lives outside the recognizer, which is why a tap
produces no gesture events: a `Click` is dispatched on release when the
press did not travel beyond the click allowance, the `pointerdown` was
not `preventDefault()`ed, and no gesture claimed the press. A pan
therefore never ends in a click, and a row the finger scrolled off is
not activated.
Two thresholds are widened for a finger, in opposite directions:
| Threshold | Mouse or pen | Finger |
| --------------- | ------------ | ------ |
| Click allowance | 4 px | 10 px |
| Gesture slop | 8 px | 12 px |
| Long press hold | 500 ms | 500 ms |
The click allowance is widened because a tap is not a click held still:
the contact point moves as the finger flattens and lifts, and four
pixels is routinely exceeded by a tap the person considers stationary.
At the narrow allowance roughly every other tap did nothing, silently,
because `PointerUp` fired and no `Click` followed it. The gesture slop
is widened for the opposite reason: at eight pixels a deliberate long
press wanders into a Pan before the hold time elapses, which put
`LongPress` out of a finger's reach entirely. The gesture slop is read
once at the press, so the threshold cannot change under a gesture
already being measured against it.
The two are measured differently, which is worth knowing when a
threshold looks off by a pixel: the click allowance is compared per
axis against the press point, and the gesture slop is a straight-line
distance from it.
## A drag is a long press first
```tsx
/**
* Two tiles, and the difference between them is one option.
*
* `draggable()` listens for a Pan, so the first tile follows the
* pointer from the moment it moves. `start: 'longPress'` listens for
* a Drag instead, so the second one sits still until the press has
* been held, which is the behaviour a list that reorders on a long
* press wants and the wrong one for a card.
*
* Both modifiers are built in the component body, which runs once, so
* their arguments keep their identity across every later frame. The
* modifiers page says what happens when they do not.
*/
const panTile = draggable({ offset: panned, dragging: { opacity: 0.85 } });
const holdTile = draggable({ start: 'longPress', offset: held, dragging: { opacity: 0.85 } });
const tile = (label: string, movement: ReturnType) => (
);
```
This is the one thing on this page most likely to cost an afternoon. In
this input model a **Drag is a long press followed by a move**, and a
plain press-and-move is a **Pan**. A divider, a slider thumb, a card the
pointer picks up immediately: all of those are Pans. Listening for
`onDragMove` on one of them produces a control that sits still for half
a second and then works.
`draggable()` defaults to the Pan for that reason, and
`draggable({ start: 'longPress' })` selects the other, which is what a
list that reorders on a hold wants. The option is fixed when the
modifier attaches, because that is when its listeners are registered;
changing it later warns rather than half applying.
The movement is written as a translation on `transform`, through the
[override cascade](https://gesso-docs.vercel.app/interaction/modifiers), so the node's layout does
not change: its box stays where it was, its neighbours do not shuffle,
and a tile dragged back to where it started has no transform at all
rather than an identity one.
A gesture a widget owns has to stop propagating. That is not
bookkeeping: it is the whole opt-out mechanism for the next section.
## Two contacts: pinch and rotate
A second finger landing takes the gesture over. Whatever the first was
doing is **ended where it stands** rather than abandoned, so a
`draggable` in mid-drag gets a real `PanEnd` and puts the card down, and
the press stops synthesizing a `Click`, because a person who has put a
second finger down is no longer doing the thing they started.
From then until a contact lifts, the pair produces `PinchStart`,
`PinchMove` and `PinchEnd`, dispatched through the same dispatcher as
everything else. Each one carries:
| Field | What it is |
| ----------------------------- | ----------------------------------------------------------- |
| `scale`, `rotation` | Cumulative from the moment the second contact landed |
| `scaleDelta`, `rotationDelta` | The change since the previous event |
| `x`, `y` | The midpoint between the contacts, which a zoom holds still |
| `translateX`, `translateY` | How far that midpoint moved since the previous event |
Rotation is in degrees, which is what the `transform` property takes, so
neither end of the handoff converts. The gesture is claimed only once
the contacts have moved five per cent apart or four degrees round: two
fingers land a few milliseconds apart and never perfectly still, and a
gesture claimed on the first move reports a scale of 1.02 for what was
going to be a two-finger pan.
A third contact is ignored until the hand comes off. It is not a second
pinch, which is what every platform does with it.
The framework itself does nothing with a pinch. There is no
framework-owned zoom and nothing listening at the root, so a pinch that
nothing listens for costs one dispatch that returns immediately. The
zoom lives in [`pinchable`](https://gesso-docs.vercel.app/interaction/modifiers), a modifier an
element opts into, on the same terms as every other behaviour:
```ts
```
It holds the point under the fingers still, which is the difference
between a photo viewer that feels like one and a viewer that scales
about its own centre and slides what you were looking at off the edge.
Ctrl with the wheel zooms it too, because that is what a trackpad pinch
reaches a browser as and it is the only way a mouse can zoom at all.
## Asking for a menu
A finger has no second button, so a held finger is how a touchscreen
asks for the commands that apply to something. A `ContextMenu` event
follows the `LongPress` at the same point, unless a listener called
`preventDefault()` on the LongPress, which is how a node that means to
be picked up rather than interrogated says so. A mouse raises nothing on
a hold: it has a secondary button, and a press of that button alone
dispatches `ContextMenu` directly and establishes no press at all, so
nothing is dragged, nothing is focused and no `Click` follows the
release.
The browser's own menu is suppressed on the canvas for the reason the
iOS callout is: it would land directly on top of the event the
application is about to receive.
```ts
(menuAt.value = at) })]}>
```
`Menu` has taken an `at` point and left the trigger to the caller since
it was written. This is the trigger.
## The end of a gesture carries its speed
`PanEnd` and `DragEnd` are `UiGestureEvent`s, and their `velocityX` and
`velocityY` are the speed the contact left at, in **pixels per second**,
which is the unit `UiSpringOptions.velocity` takes. That is the whole reason
they are there: a spring handed a velocity of zero starts from rest, so
a card thrown across the screen used to stop dead the instant the finger
left it. `draggable` passes the same pair to `onEnd`.
The speed is measured over the last 100 ms rather than over the whole
gesture, because a long slow drag that ends in a flick averages out to
nothing across the whole of it, and the flick is the part the person
meant. The samples live in a fixed ring, so a pointer move allocates
nothing.
## A finger scrolls, and a mouse does not
```tsx
/**
* A finger pans this; a mouse does not.
*
* Nothing here asks for that. The list is an ordinary scroll
* container, and the runtime's touch scroller listens for pans at the
* root: a pan from a `touch` pointer that nothing on the way up
* claimed scrolls whatever container it is over. A mouse drag inside
* a scroll container is how text is selected, so it is left alone.
*/
const list = (
(at.value = offset.y) })]}
borderRadius={10}
borderWidth={1}
borderColor="border"
backgroundColor="surface"
role="list"
label="Notes">
{NOTES.map(name => (
))}
);
```
Nothing in that list asks to be panned. The runtime's touch scroller
listens for pans **at the root, in the bubble phase**, so a pan reaches
it only if nothing between the pressed node and the root claimed it. A
widget that owns its drag keeps it by calling `stopPropagation()`, with
no new API, no `preventDefault` convention and no list of exceptions
inside the scroller.
- **The content follows the finger.** Each move scrolls by the travel,
inverted, so dragging upwards moves the content up and the offset
down.
- **The container is resolved on every move**, not at the start, so a
list that has reached its end hands the rest of the gesture to the
page it sits in without the finger being lifted.
- **Both axes at once.** Each axis is asked separately whether the
container has room, so a surface that overflows in both directions is
dragged diagonally and goes on taking the axis that still has room
when the other is spent. This used to be decided from the container's
flex direction, which meant a spreadsheet's viewport, one `ScrollView`
over content wider and taller than itself, could not be dragged
sideways at all. The wheel had the same bug and the same fix, on the
same shared state, so the two paths cannot disagree about which
container takes a gesture.
- **A flick coasts.** The distance is projected from the release speed
and handed to the same smooth path a programmatic scroll uses, so one
animator owns the offset. Speed is measured over the last 100 ms,
because a long slow drag ending in a flick averages out to nothing
across the whole gesture. Each axis passes the fling threshold on its
own, so a diagonal throw coasts on both and a vertical one does not
drift sideways by whatever the thumb happened to do.
- **Scrolling reveals the scrollbars.** The usual reveal is driven by
hovering near the bar, and a finger never hovers, so without this a
touchscreen would never see where it is in the content.
Touch only, deliberately. A mouse drag inside a scroll container is how
text is selected and a pen is used for exactly that precision, so
reading either as a scroll would take something away. The spec beside
the example drives the identical drag twice, once from a `touch`
pointer and once from a mouse, and only one of them moves the list.
Overscroll does not rubber-band. The layout engine clamps the offset to
the content on every pass, so an overscrolled offset cannot survive a
frame.
## Hover, and who owns a press
A finger's hover is dropped when it lifts, because the finger is no
longer anywhere. Without that rule a tap leaves the tapped node lit for
good, and the next tap moves the stuck highlight rather than clearing
it. A mouse keeps its hover, which is what a mouse user expects.
A press belongs to the contact that started it. While one is in flight
a second finger's moves and releases are ignored, so a second contact
cannot drag what the first one is holding. Being refused the press is
not the same as being unheard, though: every contact is reported to the
recognizer, which is what the pinch below is assembled from.
## What the shell prepares
The canvas starts life with a document's defaults, and an application
wants none of them. One call sets all of it before any event arrives:
| Setting | What it stops |
| ----------------------------- | ----------------------------------------------------------- |
| `touch-action: none` | The browser panning or zooming the page under the app |
| `user-select: none` | The document's own selection fighting the canvas selection |
| `-webkit-touch-callout: none` | A long press raising the iOS callout menu over a LongPress |
| `-webkit-tap-highlight-color` | A tap flashing a grey box over whatever the browser guessed |
The shell also calls `setPointerCapture` on the press. A finger captures
implicitly and a mouse does not, so capturing both is what makes a drag
that leaves the canvas behave the same either way. A contact that ended
between the event and the call is not a reason to lose the press, so
the failure is swallowed.
One more thing is the shell's, and only in the worker configuration: a
phone raises its soft keyboard for a focus a person's gesture caused and
for no other. The runtime's answer to a press arrives a frame later,
inside a message handler rather than a gesture, so the shell re-takes
focus from the `pointerup` of the press that started editing. It does
that only for a press that started editing, because asking again while
the keyboard is up makes it blink. The single-threaded configuration
needs none of this and does not do it, since there the answer runs
inside the `pointerdown`'s own call stack.
## Limits
**None of this was verified on a physical touchscreen.** No phone or
tablet was available. The behaviours above are covered by unit specs for
the scroller, the pointer controller, the recognizer and the editing
proxy, and by the spec beside the example on this page. Chrome implements
neither `-webkit-` property, so the two iOS settings are inert where they
were tested, and the soft keyboard behaviour is an iOS one that cannot be
observed off iOS.
The part a browser _can_ answer now runs every build. `pnpm check:touch`
drives real `pointerType: 'touch'` contacts through the DevTools protocol
at a `ScrollView` over content wider and taller than itself, and asserts
that a sideways drag moves the content sideways, that a diagonal one
moves both axes, and that the axis with room left goes on taking the
gesture once the other is spent. That covers everything between a real
contact and the events the unit specs synthesise: whether they arrive,
whether `touch-action` lets the page keep the gesture, and whether the
recognizer's slop and the platform's agree. What it is not is a hand on
glass.
**The pinch was verified in Chrome and not on a hand.** Two synthetic
`pointerType: 'touch'` contacts spread apart over a real render-worker
shell reported a scale of exactly 2.00, and Ctrl with the wheel zoomed
about the point under the cursor. Neither is evidence about how a pinch
feels between two fingers on glass.
**Hit targets are exact rectangles.** A tap two pixels outside a small
control does nothing, where a browser would apply touch adjustment. Size
controls for a finger rather than relying on slop.
**Scrollbars are mouse-sized**, 6 px thick with a 16 px reveal zone,
which is less pressing than it sounds now that dragging the content is
the gesture a finger reaches for anyway.
**Nothing reads `visualViewport`.** When a soft keyboard covers the
bottom of the window the runtime does not know, and a caret can sit
behind it.
## Next
[Pointer and keyboard](https://gesso-docs.vercel.app/interaction/pointer-and-keyboard) is the event
model underneath all of this, and
[overflow and scrolling](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) is what the
touch scroller is driving.
---
# Drag and drop
`draggable` moves a node's transform and stops. That is enough for a
card that goes back where it was and nothing at all for a card that goes
somewhere, so every application that needed a drag to _mean_ something
wrote its own hit testing over its own coordinates. Segue's queue did,
over a row-height constant and a rounded division that were only correct
for rows of one fixed height.
Three modifiers replace that, over one session per graph.
## What is being carried
A payload is a type and some data:
```ts
interface UiDragPayload {
readonly type: string;
readonly data: unknown;
}
```
`type` is the application's own vocabulary, `'queue/track'` or
`'board/card'`, and a drop target says which types it accepts. Matching
on a string rather than on a class is not laziness: a file dragged in
from the desktop arrives as a message that crossed the worker barrier,
and a message cannot carry a prototype. Making the ordinary case plain
data is what lets the extraordinary one use the same road.
## A source, and a target
```ts
```
Two modifiers, because they are two jobs. `draggable` moves a node and
knows nothing about what it is; `dragSource` knows what is being carried
and moves nothing. Splitting them is also what lets a drag source be
something that does not move at all, like a swatch that stays put while
a colour is carried off it.
```ts
move(String(payload.data), 'done'),
over: { borderColor: 'accent', backgroundColor: 'surfaceRaised' }
})
]}>
```
`accepts` takes a type, a list of types, or a predicate for a zone whose
answer depends on the data. `onDrop` returns what the drop did
(`'move'`, `'copy'`, `'link'`), which the source hears; returning
nothing means `'move'`, which is what almost every drop is.
`over` is the state a drop zone has to show. It is written by the
modifier rather than left to the application because the state is the
modifier's: an application cannot see "something I would take is over
me" without keeping a second copy of the drag.
The zone reports `layoutBox()`, which is where the node is _seen_, so a
row scrolled half off the top of a list is half a drop target, exactly
as it looks.
### Which zone wins
The deepest zone whose box contains the point and that accepts the
payload, breaking a tie by registration order. Depth rather than paint
order, because a drop target is a region rather than a drawing: a row
inside a list is inside the list, and dropping on the row is the more
specific answer. A zone that will not take the payload is passed over
rather than blocking the one behind it, so a page of mixed zones behaves
the way a person expects.
## The drop reports back
```ts
dragSource({
payload: () => ({ type: 'board/card', data: card.id }),
onEnd: (result, velocity) => {
if (result === null) {
// Let go over nothing that would take it: spring it home at the
// speed it was travelling.
spring(offset, 0, { velocity: velocity.y });
}
}
});
```
`result` is null when the drag was let go over nothing, which is the
case the source has to handle by putting the node back. `velocity` is
pixels per second, from the gesture that ended, ready for the spring
that catches it.
## A list that reorders
```ts
reorderable({ list: 'queue', index, onMove: (from, to) => queue.move(from, to) });
```
One modifier on the row rather than a component around the list, because
a reorderable list is not a widget: it is a list whose rows happen to be
draggable, and every application's rows look different. The row is a
drag source and a drop target at once over the same session, so a row
that hears an accepted payload enter it knows both indices and calls
`onMove` immediately.
The order commits **as the drag crosses each row**, not at the end. What
is on screen is then always the real order rather than a preview of one,
there is no second "committed" order to keep in step, and a drag
abandoned half way leaves something coherent behind. Put
[`animateLayout`](https://gesso-docs.vercel.app/appearance/motion) on the rows that are not being
carried and the FLIP is the framework's.
The carried row is placed from its own layout box, not from how far the
pointer has moved. The obvious way, translating by the pointer's travel,
works until the first reorder, at which point the row has _also_ been
moved by the layout and the two movements add up, so the row runs away
from the finger at twice the speed. Holding the grab point instead makes
the placement `pointer - grab - layoutBox().y`, recomputed on every move
and on every layout, which corrects itself on the frame the reorder
happens and needs no row-height constant.
A crossing waits for the layout it caused. Two pointer moves inside one
frame would otherwise have the second decided against boxes the first
has already invalidated.
## Auto-scroll
```ts
dropTarget({ accepts: 'gesso/reorder', onDrop: () => 'move', autoScroll: { edge: 40, speed: 700 } });
```
On a scroll container, a drag held near an edge scrolls it, whether or
not this zone is the one that would take the drop. A list whose rows are
drop targets of their own is never the winner while the pointer is over
a row, and it still has to scroll when a card is held at its edge, so
auto-scroll follows the pointer being inside the zone rather than the
zone winning. A zone written by hand gets the same signal through the
optional `hover(state)` on `UiDropZone`, called for every accepting zone
under the pointer and with `null` once it leaves. It has to be
driven by a timer rather than by pointer movement, because the gesture a
person makes is to hold the card still at the top of the list and wait,
which produces no pointer events at all. Without it the only rows a
long list can reach are the ones already on screen.
## Files dragged in from the desktop
A file drop arrives from the shell as plain data, in the same four
phases a drag inside the application goes through:
```ts
interface UiFileDropMessage {
readonly type: 'fileDrop';
readonly phase: 'enter' | 'over' | 'leave' | 'drop';
readonly x: number;
readonly y: number;
readonly files: readonly UiDroppedFile[];
}
interface UiDroppedFile {
readonly name: string;
readonly mediaType: string;
readonly size: number;
readonly lastModified: number;
readonly bytes?: ArrayBuffer;
}
```
Every field is plain data, because recognition belongs in the render
worker and the shell is holding a `File` object it cannot send. The
bytes are an `ArrayBuffer` so a large file is transferred rather than
copied, and they are optional because the platform lets a page read only
the names and types until the drop actually happens.
`UiDragSession.applyFileDrop` turns the message into an ordinary drag,
so a zone written for the payload type `gesso/files` is a zone like any
other and never learns where the drag came from:
```ts
dropTarget({
accepts: 'gesso/files',
onDrop: payload => {
for (const file of payload.data as readonly UiDroppedFile[]) {
importFile(file);
}
return 'copy';
}
});
```
Both browser shells post the message: `createApp`'s worker shell and
`createSyncApp`'s single-thread one listen for `dragenter`, `dragover`,
`dragleave` and `drop` on the canvas, for drags that carry files and
no others. While a drag is in flight the browser lets a page see each
file's type and nothing more, so `name` is empty and `bytes` absent
until the drop, when the files arrive whole; the worker shell
transfers their buffers rather than copying them.
---
# Shortcuts
Both of the applications built on Gesso put one `onKeyDown` on the root
and switched on the current route. That shape has three problems, and
the registry exists for all three.
A screen cannot add a key without editing a file it does not own. Two
screens that want the same key have no way to say which wins. And
nothing can list what is available, because the keys only ever existed
inside a handler's control flow, so neither application has a palette or
a help sheet and neither could grow one.
## The shape
```ts
const registry = new UiShortcutRegistry();
```
`shortcuts` goes on the application's root, once, and is the only thing
that listens. The registry itself listens to nothing, which is what lets
it stay a plain object a spec can drive directly and keeps it from
needing the dispatcher or the focus manager injected.
`shortcut` registers one command for as long as its element exists. That
is the single thing the root handler could not give you: the lifetime of
a shortcut is the lifetime of the thing it acts on, so a screen cannot
leave a command behind when it is replaced.
## Where a key is heard
The root listener is in the **bubble** phase, so a key reaches the
registry only after the focused node and everything above it have had
it. A text field that handles its own Escape keeps it and a dialog that
takes Enter keeps that, with no list of exceptions anywhere in the
registry.
A key a shortcut takes is marked `preventDefault()`, which is what the
keyboard controller reads before applying its own defaults, so a
shortcut on Tab is not also a tab navigation.
## Typing is not a shortcut
A shortcut with no modifier at all is skipped while a text field has
focus. Without that rule an application with `n` for "new note" becomes
an application you cannot type the letter n into, which is the failure
every home-grown key handler eventually ships.
Pressing isn't a shortcut either. A bare Enter or Space is skipped
while a button or link has focus, unless the shortcut is that control's
own, so a list's Enter for "open the row under the cursor" doesn't
also fire when the person pressed Enter on a button inside the list.
A button pressed by the keyboard is a default action, applied after the
key has been through every handler, so without this rule an
application-wide Enter would take the key from the very button that
has it.
Nor are a field's editing keys. While a text field has focus it keeps
undo and redo, select all, and moving and deleting by word or line,
whatever the application binds: an application-wide `Mod+Z` would
otherwise undo the app's last change instead of the typing. Other
modified keys (`Mod+S`, `Mod+K`) still reach the registry, and so does
Enter in a single-line field.
## Mod
`Mod` matches **either** Control or Command. The framework runs in a
worker and has no reliable answer to which platform it is on, and the
answer would be wrong for a Mac keyboard plugged into a Linux machine
anyway. An application that genuinely wants one of the two spells it
out: `Ctrl+S` or `Meta+S`.
Printing is different: a person reads a shortcut the way their
platform writes its own. `formatShortcut`, and every binding's
`display`, prints `⇧⌘K` on a Mac and `Ctrl+Shift+K` elsewhere, by the
same platform the editing keys follow (`detectEditingPlatform`, from the
user agent, which a render worker can read too). Pass a platform to
`formatShortcut` to print for another.
## Space
A shortcut string separates the presses of a chord with spaces, so the
space bar cannot be written as itself. `Space` names it, alone or with
modifiers: `shortcut({ registry, keys: 'Space', … })` runs on the space
bar, and `formatShortcut` prints it back as `Space`. Like any bare key,
it is skipped while a text field has focus.
## Symbols
```ts
shortcut({ registry, keys: '?', label: 'Keyboard shortcuts', scoped: false, run: openSheet });
```
A shortcut on punctuation, a digit or a symbol matches the character
the press produced, not the key. `?` is Shift+/ on a US keyboard,
Shift+ß on a German one and Shift+, on a French one, so Shift is not
asked about: `?` and `Shift+?` are the same shortcut, and both work on
every layout. A bare symbol also matches when AltGr typed it, which the
browser reports as Control and Alt together (or Option on a Mac).
Command still means something else, so `Mod+/` stays its own shortcut.
Letters keep their Shift, which is what tells `Shift+L` from `l`.
## Scope
```ts
shortcut({ registry, keys: 'Delete', label: 'Remove the chosen row', run: remove });
```
By default a shortcut is live only while focus is inside the element it
is attached to. That is what makes "Delete removes the selected row"
safe to register: the table registers it, and it stops existing the
moment the person tabs into the search field. `scoped: false` registers
it application-wide with the element's lifetime, which is what a route
wants.
When two live shortcuts want the same keys, the deeper scope wins,
without either of them naming a number. `priority` is for the case where
that is not the answer: a route taking a key the application had.
`when` is a last check before it runs, for a command that is not always
available.
## Chords
```ts
shortcut({ registry, keys: 'g b', label: 'Go to the board', scoped: false, run: goToBoard });
```
Space separates the presses of a chord. The first key is reported as
taken, so nothing else acts on a `g` that was the beginning of a
command, and the rest of the sequence has 1200 ms to arrive. A `g`
pressed into a page and then forgotten does not swallow a key a minute
later.
`+` separates the modifiers of one press, and the last segment is always
the key however it is spelled, so `Mod++` is Mod and the plus key.
## A palette
```ts
registry.active(focusedNode).map(binding => row(binding.display, binding.label, binding.group));
```
`active` returns what would run right now, in the order the registry
would try them. It is the same computation the key handler makes, so a
palette cannot show a command that would not fire and cannot hide one
that would.
Each binding carries `display`, the keys written the way they should be
shown, and the `label` and `group` the application gave it, so a palette
or a help sheet needs no second table of descriptions.
## A help sheet
```ts
shortcutKeyCaps(binding.steps); // [['⇧', '⌘', 'K']] on a Mac, [['Ctrl', 'Shift', 'K']] elsewhere
describeShortcut(binding.steps); // 'Shift Command K', 'Control Shift K'
```
A sheet that draws each key as a cap needs the keys apart, not joined
into one string: `shortcutKeyCaps` gives one string per key, one array
per press of a chord, and they are the pieces `display` is made of, so
a sheet and a palette never disagree. `Mod++` is two caps, Ctrl and
the plus key.
A cap's `⌘` or `↓` is read out inconsistently, and a screen reader at
its default verbosity skips punctuation, so `?` is silence.
`describeShortcut` says the shortcut in words (`Down arrow`,
`Question mark`, `g then d`), for the row's accessible name.
The focused node is the one the palette lists against. Inside the
handler it comes from the event's `target`, because the keyboard
controller routes a key to the focused node and to the root when nothing
is focused; a palette that wants the same answer follows focus itself.
---
# Focus and traps
At most one node holds keyboard focus, and where it is lives in the
runtime rather than in any component's state. That is the right place
for it: two components cannot disagree about who has the keyboard, and a
control that wants to know about its own focus asks rather than tracks.
[Pointer and keyboard](https://gesso-docs.vercel.app/interaction/pointer-and-keyboard) covers what is
focusable and in what order, and it is short: a `` and an
`` are focusable, anything with `focusable` opts in,
nothing inert ever is, and Tab walks document order and wraps. There is
no `tabIndex`, so the way to change the _order_ is to change the tree.
What you can change without moving anything is whether a focusable node
is in the cycle at all: see [tab stops](#tab-stops-and-focusable-nodes).
This page is what an application does with it.
## Focus is a service
A component reaches focus the way it reaches the clipboard, overlays and
the find session: through an injected service, not by holding the
runtime's manager.
```tsx
const focus = ctx.inject(FocusService);
```
| Member | What it does |
| ----------------- | ------------------------------------------------------------------- |
| `focus(node, o?)` | Moves focus there. A node that cannot take it is ignored |
| `blur()` | Drops focus without moving it anywhere |
| `focusNext()` | The next focusable node, wrapping |
| `focusPrevious()` | The previous one, wrapping |
| `trap(node)` | Confines focus to that subtree until it is released |
| `releaseTrap(n?)` | Ends the innermost trap, or the one on `n`, and restores its opener |
| `focused` | The node holding focus, or null, as state to bind to |
| `trapped` | Whether a trap is open, as state to bind to |
`focus(node)` scrolls the node into view, as focus from code does in a
browser. `focus(node, { preventScroll: true })` leaves every scroll
container where it is, as `element.focus({ preventScroll: true })` does.
`focused` carries the `UiNode` itself, not an id, which is what lets a
readout name it or a control compare it with its own node. Nodes come
from a `ref` prop, and they never cross a worker boundary, so this
service stays on the render thread with the rest of the tree.
An action taken before the runtime has installed its focus manager is
queued and replayed rather than dropped. That is not a corner case: an
application's refs fire while the first tree is being built, which is
before the services exist, and an autofocus in a dialog opened on the
first frame hits it every time.
## A control's own focus
A control does not ask "am I focused" of a boolean it keeps. It
compares the focused node with the node its own `ref` handed it, which
is one derived value:
```tsx
const self = new BehaviorSubject(null);
const isFocused = combineLatest([focus.focused, self]).pipe(map(([held, mine]) => mine !== null && held === mine));
```
Every control in `gesso-components` does exactly this, and forwards a
`ref` prop to the element that _is_ the control rather than to its
outermost box. That is what makes the next section possible from
outside the component.
## Moving focus to the field that failed
A form that reports "three fields are wrong" and leaves the caret where
it was has made the person hunt. The pattern is: keep the node each
field's `ref` gave you, and on a failed submit hand the first bad one to
`focus`.
```tsx
let firstBad: UiNode | null = null;
// ... validate, remembering the node of the first field that failed
if (firstBad !== null) {
focus.focus(firstBad);
}
```
The library's controls are built for this. Marking a field `invalid`
recolours its border and adds the `invalid` state to what a screen
reader sees; moving the caret to it is the application's call, one line
away, and the two together are what makes a validation message
actionable rather than decorative.
## Scopes and traps
```tsx
/**
* Two calls, and they are the whole of a modal dialog's keyboard.
*
* `trap(node)` confines focus to that subtree: the Tab traversal
* collects only what is inside it and wraps there, and focusing
* anything outside is refused, so both halves of "modal" come from
* one rule. `releaseTrap()` ends it and returns focus to whatever
* held it when the trap was taken, which is the button that opened
* the dialog.
*
* The trap is taken from a `ref`, which fires while the panel's own
* props are being reconciled and before its children exist. Nothing
* has to sequence around that: the runtime settles focus into the
* innermost scope once the frame's tree is built, which is why the
* caret is already in the field on the frame the dialog mounts.
*
* `autoFocus()` is what decides it is the field rather than the first
* button. It fires on the node's first layout, and once only, so a
* panel that is laid out again does not steal focus back from
* something the reader moved it to.
*/
let scope: UiNode | null = null;
const close = (): void => {
focus.releaseTrap();
open.value = false;
};
const dialog = (
{
if (node !== null && node !== scope) {
scope = node;
focus.trap(node);
}
}}
gap={10}
padding={14}
width={percent(100)}
borderRadius={10}
borderWidth={1}
borderColor="border"
backgroundColor="surface"
role="dialog"
label="Rename">
(name.value = event.value)}
/>
{stop('Save', () => {
saved.value = `saved as "${name.value}"`;
close();
})}
{stop('Cancel', close)}
);
```
`trap(node)` pushes a scope: the Tab traversal collects only that
subtree and wraps inside it, and `focus()` refuses a node outside it and
returns false. Both halves of "modal" come from that one rule, which is
why nothing in the example listens for Tab. Try it: the dialog's three
stops cycle, and the two buttons behind it are unreachable until it
closes.
- **Traps nest.** A confirmation dialog opened over a dialog pushes a
second scope, and each release restores its own opener.
- **A removed subtree is an implicit release.** A scope whose root has
left the tree is a trap that ended, so it pops and restores. A dialog
that simply unmounts hands the keyboard back without sequencing its
own teardown, and a `releaseTrap(node)` naming it afterwards does
nothing. Name the node when something else may have trapped over
yours: a bare `releaseTrap()` ends the innermost trap, which is then
the other one's.
- **Entering a scope is settled once per frame.** A `ref` fires once
its node's own props are written and it has the environment (the
theme) it will be under, but before its children exist, so a trap
taken from a ref sees an empty box with nothing focusable in it. The runtime moves focus into the innermost scope
after the frame's tree is built and before layout, which is why the
caret is in the dialog on the frame that mounts it, and why a
component does not have to know when its children appear.
Escape follows from the same rule rather than needing a stack. Every
overlay that takes the keyboard traps focus, so a dialog binds Escape on
its own content and the key cannot reach a dialog underneath: nothing
underneath is focusable.
A scope with nothing focusable in it falls back to the scope root, and
blurs only when the root cannot hold focus either. That fallback is why
a dialog whose content is a sentence still answers Escape: it used to
hand the keyboard to nothing, and the one key a modal must always
answer had nowhere to be delivered.
## Tab stops and focusable nodes
Every tab stop is focusable; not every focusable node is a tab stop.
`focusable` answers "may this node hold focus at all", and `tabStop`
answers "does Tab land here":
```ts
Box({ focusable: true, tabStop: false } /* … */);
```
That pair is `tabindex="-1"`: reachable by a press and by `focus()`,
skipped by the cycle. It is what a container wants when it must be able
to _take_ the keyboard without being a place a person tabs _to_: a
dialog body that is the keyboard's home of last resort, a scroll region
that answers arrow keys, a canvas-like surface that owns its own
navigation. `Dialog` sets exactly this pair, and setting only the first
half is the fix that looks right and gives every dialog a tab stop on a
box that announces nothing.
`tabStop` says nothing on a node that is not focusable, because there is
no order for it to be out of. And it does not reorder anything: the
cycle is still document order, and the only way to change that is still
to change the tree.
A subtree that is hidden (`visible={false}`) or disabled has no stops
and can't be focused from code, as `display: none` and a disabled
fieldset take theirs out of a page: a toolbar hidden until something is
selected isn't a run of stops nobody can see.
One thing does fold stops together: the fields of an
[editing group](https://gesso-docs.vercel.app/interaction/text-editing-and-ime) are a single stop, at
the first field, entered at the one last focused.
`Dialog`, in [the component library](https://gesso-docs.vercel.app/components/), is this with an
entrance, a backdrop and the semantics already attached. Reach for it
first; take the trap yourself when you are building something the
library does not have.
## Autofocus
`autoFocus()` is a modifier, so it goes on the element that should take
the caret and needs no ref, no service and no lifecycle hook.
It fires on the node's **first layout**, not on attach. A modifier
attaches while its node is still being built, before the builder inserts
it into its parent, so a focus taken at attach time would produce a
focused node that Tab could not reach and whose focus event no ancestor
would see. The first layout is the first moment the node is
demonstrably on screen, and for a node mounted into a live tree it is
the same frame.
It fires once. A panel that is laid out again does not steal focus back
from whatever the reader moved it to, which would make an autofocus a
trap of a different kind. A node that cannot take focus is left alone
and raises nothing.
In the example above the field is autofocused, which is why the caret
lands there rather than on the dialog's first focusable node. That is
the point of it: the scope's own entry rule picks the first stop, and
`autoFocus()` is how you say it should be somewhere else.
The node is scrolled into view, like any focus from code, once the
frame's layout has settled, so it is revealed from where it ends up,
not from where a first layout pass put it. When the focus is there for a
screen reader rather than for the eye, use `autoFocus({ preventScroll:
true })`. A page that focuses its content region as it opens, so the
region is read out, wants to start at its top; revealing a region taller
than the viewport scrolls it to a few pixels short of its own top
instead, because a reveal keeps a margin from the edge.
```tsx
```
Focus taken without scrolling stays that way while the node holds it: a
key pressed later shows the focus ring where it is and does not scroll
to it either.
## Roving focus
A group whose items are all tab stops makes a person press Tab five
times to get past it. The pattern here, as in ARIA, is that the group is
**one tab stop and the arrows move the choice inside it**:
[RadioGroup](https://gesso-docs.vercel.app/components/radio-group), [Tabs](https://gesso-docs.vercel.app/components/tabs),
[Tree](https://gesso-docs.vercel.app/components/tree), [LazyList](https://gesso-docs.vercel.app/components/lazy-list) and
[DataTable](https://gesso-docs.vercel.app/components/data-table) all work that way. A
[Toolbar](https://gesso-docs.vercel.app/components/toolbar) deliberately does not: it is a group for
a screen reader, and the buttons in it stay ordinary tab stops.
There is no roving `tabIndex`, because there is no `tabIndex`. Focus
stays on the container, which is the node carrying the role and the one
the ring marks; which item is chosen is said by that item's own
selection colour and by the semantics record, not by moving focus into
it. So a group that behaves this way sets `focusable` on the container,
handles the arrow keys there, and renders its items as ordinary
non-focusable nodes. A container that should own the arrows without
being a stop of its own adds `tabStop: false`.
## The focus ring
`focusRing()` draws a ring around whatever holds focus, when that focus
is worth showing. Focus that a pointer press put there is not: a ring on
every clicked button would mark a control nobody is about to drive with
the keys, so the ring waits. It appears for focus the keyboard reached,
for focus placed by code before any pointer press, and on a mouse-focused
control the moment a key is pressed; the next press takes it away again.
This is what a browser's `:focus-visible` does, and the focus manager
does it here by remembering whether the last input was the pointer or
the keyboard. A modifier can ask the same question through
`host.isFocusVisible()`.
The ring is a decoration rather than an overlay for two reasons that
decide its behaviour:
- **It is clipped with the node.** A ring on a row scrolled half out of
a scroll container is cut off at the same edge the row is, because it
is painted inside the node's own paint pass under its ancestors'
clips. Something drawn over the finished scene would float past the
scroller's edge.
- **It sits outside the node's own box.** The ring is `offset` pixels
clear of the control, so it never covers a border or any content,
which is what a bound `borderColor` could not avoid doing. Its radius
defaults to the node's own corner radius grown by that distance, so it
stays concentric without the caller knowing either number.
| Option | Default | What it is |
| -------- | --------------------- | ----------------------------------------------- |
| `color` | the `focusRing` token | A palette name or a literal colour |
| `width` | 2 | Thickness of the band |
| `offset` | 2 | Clear space between the node's box and the ring |
| `radius` | the node's, grown | An explicit corner radius |
| `after` | unset | `'children'` paints the ring over the content |
The colour names a palette entry and is resolved at paint against
whatever theme the node inherits, so a ring inside a dark card is the
dark palette's without the modifier reading the environment. Called with
no options it returns one shared value, which is the cheapest a modifier
can be: the comparison matches on the first line and does no work.
Options of your own are compared by what they hold, so a ring declared
inline survives a re-render as long as its options are plain data. See
[modifiers](https://gesso-docs.vercel.app/interaction/modifiers).
Every control in `gesso-components` already carries it. An element of
your own carries it by name.
## Limits
**Focus moves by keyboard scroll the focused node into view; focus
moved by pointer does not.** Someone who clicked a control can already
see it, and scrolling would pull it out from under a pointer still
resting on it. Focus moved by code scrolls too, unless it asked for
`preventScroll`.
**Where this was checked.** The behaviour on this page is covered by the
spec beside the example above, which drives Tab through the focus
manager and every press through the hit tester, and by the runtime's own
specs for scopes, nesting, refusal from outside, and a scope whose
subtree was removed. The ring was checked by hand in the framework
playground in Chrome on Linux, in the render worker, on both Canvas2D
and WebGPU: Tab moves it from control to control, and a ring on a row
inside a small scroller is cut off at the scroller's edge as the row
scrolls out. Its behaviour under a rotated ancestor rests on an argument
rather than an observation.
## Next
[Modifiers](https://gesso-docs.vercel.app/interaction/modifiers) is the mechanism `autoFocus()` and
`focusRing()` are built on, and
[pointer and keyboard](https://gesso-docs.vercel.app/interaction/pointer-and-keyboard) is what
decides who can hold focus in the first place.
---
# Text editing and IME
[TextInput](https://gesso-docs.vercel.app/components/text-input) is a label, a message and a theme
wrapped around one node. This page is the node: ``, what
it owns, and which parts of typing a canvas application cannot own at
all.
Reach for the component first. Come here when you are building a
control the library does not have, or when you need to know why the
component behaves as it does.
## The node
```tsx
(name.value = event.value)} />
```
Beyond every common prop, an editable takes `value`, `placeholder`,
`multiline`, `readOnly`, `textWrap`, `verticalAlign`, `caretColor`,
`selectionColor` and `placeholderColor`. The three colours default from
the theme, so a field that names none of them is already legible in
light and dark: the caret takes the text colour, the placeholder takes
the theme's `textMuted`, and the selection is the theme's `primary` at
a third of its opacity.
The text itself does not live in the `value` property. It lives in a
model held on the node, created the first time layout, paint or input
reaches for it, and the node is the identity that survives
reconciliation, so the model needs no registry of its own. That is what
keeps a keystroke off the application's critical path: the character is
in the model before any cell has been written, and layout, paint and
the caret all read it from there.
`value` is still honoured. Whenever the property differs from the last
value synchronised into the model, the model takes it. An application
that writes every reported value straight back therefore controls the
field and disturbs nothing, and one that never writes leaves the
person's text alone.
## An editable, and the two listeners
```tsx
/**
* Two listeners, and they run at different moments.
*
* `onBeforeInput` runs before the edit reaches the text, carries the
* DOM's own `inputType` vocabulary, and cancels the edit when it
* calls `preventDefault()`. This one refuses any insertion carrying a
* digit, whether it was typed, pasted or committed by an assistive
* technology, because all three arrive here as an insertion with
* `data`.
*
* `onInput` runs after the text changed, and carries the whole new
* value with the selection that followed it. Writing that value
* straight back to the cell the field is bound to is what makes the
* field controlled without disturbing the caret: the keystroke landed
* in the node's own model first, and the property it is compared
* against now agrees with it.
*/
const field = (
{
if (event.data !== null && /\d/.test(event.data)) {
event.preventDefault();
refused.value++;
}
}}
onInput={event => {
text.value = event.value;
selection.value = `${event.selectionStart} to ${event.selectionEnd}`;
}}
/>
);
```
Type a digit into the field and nothing happens, because the edit was
refused before it reached the text. Paste one and the same thing
happens, for the same reason: both arrive as an insertion carrying
`data`.
## Vetting an edit
Every change goes through two events on the node.
**`onBeforeInput`** runs first, and cancelling it with
`preventDefault()` drops the edit. It carries the DOM's own
`inputType` vocabulary whether the edit came from a shell's
`beforeinput`, a key the runtime resolved itself, or a paste:
| `inputType` | Where it comes from |
| ------------------------------------------------- | ------------------------------------------------ |
| `insertText` | Typing, and an insertion from a key |
| `insertReplacementText` | Setting the whole value, as a screen reader does |
| `insertFromPaste` | A paste |
| `insertLineBreak`, `insertParagraph` | Enter in a multiline field |
| `deleteContentBackward`, `deleteContentForward` | Backspace and Delete |
| `deleteWordBackward`, `deleteWordForward` | The same with the word modifier |
| `deleteSoftLineBackward`, `deleteSoftLineForward` | Deleting to a line end |
| `historyUndo`, `historyRedo` | Undo and redo |
`data` is the text an insertion carries, and null otherwise. A rule
written against `data` therefore covers typing, pasting and an
assistive technology setting the value, which is why the example refuses
digits with one condition rather than three.
**`onInput`** runs after the text changed, carries the whole new
`value` with `selectionStart` and `selectionEnd`, and bubbles, so a
form can watch all its fields from one listener. Nothing is reported
when an edit changed no text.
### Owning undo
`historyUndo` and `historyRedo` arrive through `onBeforeInput` like
every other edit, so an application that keeps its own history (an
editor whose document is several fields, where one undo has to cover a
paragraph split as well as the typing either side of it) cancels them
there and applies its own. The field's history is also cleared whenever
`value` changes from outside, because text the application replaced is
text the field never typed: a document-level history is the one that
can say what an outside change undoes to.
## The caret and the selection
A press places the caret, and Shift extends the selection to it. A
second press within half a second selects the word, a third the line,
and dragging after any of them extends from the anchor.
Where a press lands in the text is read before the press moves focus.
A field may draw itself differently once it has focus (a markdown
editor that shows its markers only in the block being edited), and
the caret belongs where the person pressed in what they saw, not at
the same x in the field as it is about to be drawn.
Moves and deletes step by grapheme, through `Intl.Segmenter` with a
surrogate-pair fallback, so the caret never splits an emoji. Word
boundaries come from the same segmenter, which is what makes a CJK word
a word.
The caret's position comes from the same paragraph layout the renderers
draw with, built from the same measurer the layout engine sized the node
with. That is a correctness property rather than an implementation
note: a caret cannot land between two characters that the renderer drew
somewhere else. See [text](https://gesso-docs.vercel.app/guide/text) for the measurer itself.
It blinks on the runtime's timer, 530 ms visible and 530 ms hidden from
the last activity, and one pending timeout repaints the node when it
next toggles. Nothing is scheduled while no field has focus, while a
composition is open, because an IME caret must not wink, or while the
page is hidden.
A field given runs with `hidden: true` keeps that text but draws none of
it, and the caret steps over each stretch of it as one unit, so every
arrow and every Backspace changes something you can see. [Rich
text](https://gesso-docs.vercel.app/guide/rich-text#hidden-runs) has the rules: which side of hidden
text a move stops on, what Backspace keeps, and where a press lands.
An undo entry coalesces a run of typing, a run of backspaces, or a whole
composition. Placing the caret with a press breaks the run, so what
comes next is its own entry.
`readOnly` takes focus and allows the caret, the selection and copying,
and refuses every edit. `disabled` is different and stronger: an inert
node takes no pointer events, no focus and no keys at all.
## The commands
A key that reaches a focused editable, and that no application listener
claimed, is looked up in one keymap and turned into a command: a
**move** or a **delete** by grapheme, word, line or document, a
**newline**, **select all**, **undo**, **redo**, or an **insert**.
Vertical moves are their own unit, because up and down a visual line
need the paragraph layout rather than the text.
The keymap follows the host's conventions, decided once from the user
agent, which a render worker can read as readily as the main thread:
Command jumps to the ends of a line and of the text and Option steps by
word on a Mac, and Control does both elsewhere.
[TextInput's keyboard table](https://gesso-docs.vercel.app/components/text-input) lists the map key
by key; it is the node's map, not the component's.
Two rules are worth knowing before you write an `onKeyDown`:
- **Editing is a default behaviour.** The application's listeners run
first, and a key one of them marks handled never reaches the keymap.
That is exactly how a single-line field's `onSubmit` takes Enter away
from the text.
- **Enter in a single-line field is not the field's.** The command is
resolved and then declined, so it goes on being an ordinary key the
application can answer.
## Composition from an IME
Keyboard events carry keys, not characters. Which character a key
produces depends on the OS layout, dead keys compose across presses,
and an IME builds a word over several keystrokes before committing it.
The browser resolves all of that in exactly one place: inside a focused,
editable DOM element, on the main thread. The application, its caret
included, is in a render worker.
So the shell keeps a hidden `