# The Headless Layer

> Source: https://design.nanatec.co.ke/guides/headless/
> Behaviour without a view — the rules a listbox or a dialog follows, shared by every target and published for your own components.

Two components can paint nothing alike and still think identically. A select and
a combobox look nothing like each other, but an arrow key moves the active option
and wraps at the ends in both. A dialog in React and the same dialog in Svelte
share no code, yet both have to hold focus inside themselves and hand it back on
close.

Those rules are not visual, and they are the part that is easy to get subtly
wrong. So they do not live in the components. They live in `src/headless/` — one
module per behaviour, read by every target that needs it.

## What makes a module headless here

Three properties, and they are the point rather than a style preference:

- **No DOM.** A module decides *which* option is active or *where* focus should
  land. Collecting elements and calling `.focus()` stays with whoever is drawing.
- **No framework.** Plain functions over plain data, so the same file compiles
  into a Lit element, a React component and a Svelte component unchanged.
- **No styling.** Nothing in here knows a class name exists.

The payoff is testability. A rule that touches no DOM runs in milliseconds
without a browser, so the awkward cases — an empty dialog, a single focusable
element, focus sitting somewhere the component does not know about — get covered
properly instead of being hoped about.

## The modules

| Module | Decides | Read by |
| --- | --- | --- |
| `field` | Which message `aria-describedby` points at, and in what order | Input, Select, Combobox, Meter input |
| `pagination` | Which page numbers show and where the gaps fall | Pagination |
| `bulk-progress` | Percentage, tone, and the sentence read aloud | Bulk progress |
| `meter-input` | Whether a reading is plausible against the last one | Meter input |
| `money` | Currency, precision, compact scale, and whether a negative is a debt or a credit | Money, unit map, approval card |
| `status` | The shared status sets: each value's words, tone and icon | Status, unit map, approval card |
| `unit-map` | Floors from units, occupancy, tenants and avatar stacks, search, moving between units | Unit map |
| `timeline` | Grouping by day, step states, the reading order of a tracking timeline | Timeline |
| `zoom` | Zooming about a point, panning within the frame, the zoom keys | Evidence compare, file viewer |
| `approval` | Which decisions need a reason, and whether one can be sent | Approval card |
| `file` | A file's kind and extension, whether a browser can preview it, stepping through a set | File, file viewer |
| `checklist` | How a set of checks comes out, and the sentence that says so | Checklist |
| `listbox` | Active option, wrapping, filtering, the empty-panel wording | Select, Combobox |
| `dialog` | Escape dismissal, the focus trap, where focus starts | Dialog |
| `menu` | Opening direction, roving focus, Home and End, typeahead | Menu, menu button, menubar, editor |
| `tabs` | Orientation, activation mode, stepping and Home/End | Tabs |
| `tooltip` | Open and close delays, what each intent means, Escape | Tooltip |
| `table` | Search, filter, the sort cycle and comparator, paging | Table, data table |
| `checkbox` | Tri-state, the group summary, what `aria-checked` reports | Checkbox |
| `selection` | Single and multiple choice within a set | Radio group, toggle group |
| `slider` | Snapping, clamping, value from a pointer, which thumb moves | Slider |
| `position` | Where a floating box sits, and how it flips and shifts to stay in view | Popup, Tooltip, Dropdown |
| `dismiss` | Which overlay Escape or an outside press closes when several are open | Dialog, Popover, Menu |
| `hover-card` | Hover intent, and the grace period for crossing to the card | Hover card |
| `collapsible` | Which panels are open, single or several, and whether the last may close | Accordion, Collapsible |
| `stepper` | Step status, what may be reached, and progress | Stepper |
| `breadcrumb` | Which crumbs survive when the trail is too long | Breadcrumb |
| `calendar` | Month grids, bounds, and range selection | Calendar, Date picker |
| `number-input` | Parsing, clamping, stepping without floating-point drift | Number input |
| `otp` | Distributing characters across slots, pasting, focus | OTP input |
| `toast` | The queue, its limit, and when a toast expires | Toast |
| `presence` | Staying mounted long enough to animate out | Any dismissible surface |
| `tree` | Flattening for navigation, expanding, the arrow keys | Tree view |
| `carousel` | Index, looping, and how far to shift | Carousel |
| `virtual` | Which rows are on screen, and the spacer around them | Virtual list |
| `resizable` | Sizes from a drag, honouring each panel's limits | Split panel, image comparer |

Five of these are published ahead of anything that reads them — `dismiss`,
`presence`, `selection`, `toast` and `virtual`. The rules and their tests are
there, and the component each one names is the one still to be built, so the
"Used by" column reads as intent for those rows rather than fact.

`menu` reads `listbox` for the wrap itself rather than writing it a second time,
so installing a dropdown pulls both. A menu still differs in the part that
matters: a listbox marks its active option with `aria-activedescendant` and
leaves focus on the field, while a menu moves real focus onto the item.

Each has its own page: [listbox rules](/guides/listbox-rules/) covers the panel
and keyboard model in full.

## Reading them directly

They ship as ordinary registry items, so a component that reads one pulls it in
automatically:

```bash
npx @nana-tec/ui add dialog
```

installs `headless/dialog.ts` alongside `dialog.tsx`. You can also install a
module on its own and build your own view on it:

```bash
npx @nana-tec/ui add headless-listbox
```

Or import from the published package without copying anything:

```js
import { keyToAction, project, reduce } from "@nana-tec/ui-headless/listbox";
import { trapTab, isDismissKey } from "@nana-tec/ui-headless/dialog";
```

## One package, copied on the way out

The rules are a package — `@nana-tec/ui-headless` — written once. Every target
imports it, so there is no second copy to keep in step:

```ts
import { trapTab, isDismissKey } from "@nana-tec/ui-headless/dialog";
```

Installing a component still gives you the file. The registry resolves that
import to the module it ships, so `add dialog` writes `headless/dialog.ts` into
your project and rewrites the import to point at it. One source here, source you
own there.

You can also take the package as an ordinary dependency and build your own views
on it:

```bash
npm install @nana-tec/ui-headless
```

Modules are exported one by one — `@nana-tec/ui-headless/listbox` — because
several answer the same question for different controls. A dialog and a tooltip
both have an `isDismissKey` and they are not the same function, so the barrel
namespaces them rather than flattening them into one collision.

## Beneath it: the primitives

`@nana-tec/ui-primitives` holds what every rule set needs and none of them owns:
merging props so two handlers both run, joining ids for `aria-describedby`,
writing `data-state` the way a CSS selector expects, resolving a controlled value
against an internal one.

It knows nothing about any component, and nothing about React, Svelte or the DOM
— so `headless/field` joins its ids with `joinIds` rather than writing the same
filter-and-join a fourth time. Installing a component that needs one pulls it in:
`input` asks for `headless-field`, which asks for `primitives-ids`.

## Where it does not apply

`nana-dialog` does not read the dialog module. It renders a native `<dialog>`,
and the browser already supplies trapping and Escape for it. Sharing a rule that
the platform provides for free would be worse than duplication: it would mean
running our version of something the browser does better.

The rule is that behaviour goes in the layer when more than one target has to
implement it by hand — not whenever more than one component happens to have it.
