cssints 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/docs/API.md +1764 -1
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1245,6 +1245,45 @@ More plugins on `scope()`, each a package of its own with its rules and limits i
1245
1245
  | [`@cssints/skeleton`](../packages/skeleton/README.md) | `skeleton().radius().color().highlight()` | Loading placeholders whose shimmer is in phase across the page (one clock on `:root`), static under reduced motion |
1246
1246
  | [`@cssints/reactive`](../packages/reactive/README.md) | `state()`, `toggle()`, `set()`, `value()`, `checked()`, `persist()`, `equals()`, `either()`, `on()`, `emit()`, `bind()`, `when()`, `inside()` | State as custom properties, events as data on the element: writes only in events, conditions and DOM output in plain CSS, wired by a 1.4 kB runtime shipped to every target; `value()`/`checked()` take the field's value, calls of `on()`, `bind()` or `state()` in one `cn()` append, `persist(value, key)` keeps a state across a reload, and `bind()` takes a source, a comparison (`equals()`), a choice of two literals (`either()`) or a literal — the one expression the runtime evaluates, because an attribute on the element cannot be computed in CSS (2026-10-31, cssints-ac3o). `emit(name)` is the one hand-off to JavaScript: `on({ submit: emit("save") })` writes no state and dispatches a bubbling `reactive:save` from the element the native event fired on, with `{ event, owner }` in its detail, which `listen(target, name, handler)` from `@cssints/reactive/runtime` hears and whose disposer it returns (2026-10-31, cssints-4s8d) |
1247
1247
 
1248
+ ### A state machine for UI protocols: `@cssints/machine` (cssints-im52)
1249
+
1250
+ A signal holds state, not a protocol. `@cssints/machine` is the protocol: a state machine for the behaviour a component grows the moment it animates — a `closing` and an `opening` in one frame, a cancelled animation that still reports back, an interrupted close that reverses from where the box is, a burst of pointer events, a debounce that must not swallow the last intent. It has no dependencies; `./solid` is the only file that imports anything, and solid-js is its optional peer. The README has the animated-disclosure example and the five properties; `test/machine.mjs` asserts them in plain node.
1251
+
1252
+ ```ts skip
1253
+ defineMachine(config: {
1254
+ initial: Context; // the context, inferred from this object
1255
+ states: { [name]: {
1256
+ on?: { [event]: transition | transition[] }; // the event union; the first guard that passes wins
1257
+ after?: { [ms]: transition }; // cleared when the state is left
1258
+ effect?: EffectSpec[]; // issued on entry, cancelled on exit
1259
+ }};
1260
+ start?: StateName; // the first key of states by default
1261
+ debounce?: { [event]: ms };
1262
+ }): MachineDefinition;
1263
+
1264
+ defineMachine<Payloads>()(config) // typed payloads: { close: undefined; finish: { id: number } }
1265
+ createMachine(definition, { clock?, autoStart?, onEffect?, onCancel?, onTransition? }): Machine
1266
+
1267
+ transition = StateName | { target, guard?(context, event), effect?, assign? }
1268
+
1269
+ Machine: getState() · subscribe(listener) · subscribe(selector, listener) · send(event, { debounce? }) ·
1270
+ flush() · cancel(type?) · effects() · onEffect/onCancel/onTransition · start() · destroy()
1271
+
1272
+ harness(definition): { machine, clock, log, send, enqueue, deliver, deliverAll, advance, race, interleave,
1273
+ pendingEffects, flushEffects, cancelled }
1274
+
1275
+ createMachineStore(machine, { settled?, intent? }): { state, optimistic, projection, send } // ./solid
1276
+ ```
1277
+
1278
+ - **Events are a union derived from the `on` keys**, the context is inferred from `initial`, and a `send` the states do not accept is a type error (`test/types/machine.ts`). A payload map, declared once as the type argument, types the payloads; a guard or an `assign` narrows on `event.type`.
1279
+ - **Effects are data**, not callbacks: `{ type: "animate", ... }`. The machine issues a handle per effect (`{ id, type, spec, state }`) and cancels it when the state is left or the transition is superseded. A completion is an event carrying the handle's numeric `id`: a stale id is dropped, a live one completes without a cancellation. `effects()` is what the host should be running now.
1280
+ - **Debounce** coalesces a burst by event type; `flush()` delivers the final intent, `cancel(type)` drops a superseded one. The same clock drives `after` and a transition's timestamp.
1281
+ - **The clock is injected** (`systemClock` is the default); the harness's fake clock moves only on `advance(ms)`, and the harness logs `{ at, event, from, to, effect, context, cancelled }` per delivery (and per effect it issued).
1282
+ - **The state is an external store**: `getState()`, `subscribe(listener)` and `subscribe(selector, listener)`, the last one firing only when the slice changes.
1283
+ - **`./solid`**: `state` is a `createStore` of `{ ...context, phase }` written once per transition, mutating only the changed fields; `optimistic` is a derived `createOptimisticStore` whose intent, written inside a Solid `action`, reverts to the committed snapshot at settle (a superseded intent rolls back); `projection` is a `createProjection` over `state`, so a reader of a view's field does not re-run when another field changes. A projection reads the committed frame, not an optimistic override, so the visual phase is read off `optimistic` directly. One store write per transition is the atomicity the reactive runtime gets from its one flush (cssints-tm9j).
1284
+
1285
+ Limits: one state at a time (no parallel states, history states or nested machines), effects are data the host runs, and a numeric event `id` is reserved for an effect handle. There is no Quint spec for this runtime yet; the harness is the oracle.
1286
+
1248
1287
  ### Headless: `createField` and `createConfirm` (`@cssints/headless`)
1249
1288
 
1250
1289
  Two primitives with no APG pattern behind them, so no Quint spec: `createField` is the ARIA contract of a form field, `createConfirm` a promise over `createDialogModal({ role: "alertdialog" })`. Like the rest of the package they return state and attribute objects, no elements, no styling; the README has the examples.
@@ -1319,8 +1358,10 @@ createToast(options?: {
1319
1358
  description?: string | undefined;
1320
1359
  tone?: "info" | "success" | "error" | undefined; // "info"
1321
1360
  }): string; // the id
1322
- dismiss(id: string): void;
1361
+ dismiss(id: string): void; // removes now, skipping whatever a renderer would animate
1323
1362
  dismissAll(): void;
1363
+ requestDismiss(id: string): void; // asks the renderer to remove it; removes at once when none claims it
1364
+ onDismissRequest(handler: (id: string) => boolean): () => void; // the expiry: true when this row owns it, then dismiss
1324
1365
  regionProps: { role: "region"; "aria-label": string }; // the F6 target
1325
1366
  toastProps(id: string): {
1326
1367
  role: "alert" | "status"; // "alert" for tone "error", "status" otherwise
@@ -1337,6 +1378,8 @@ createToast(options?: {
1337
1378
 
1338
1379
  A toast is its own live region inside the `role=region` landmark: `role=alert` for `tone: "error"` (assertive) and `role=status` otherwise (polite). The host renders the `title` and `description` handed to `show` inside that element, so the toast's own text is the announcement — `aria-atomic` reads all of it, a close button's label included, so keep a toast to its title and description and give a close button no more than an icon with a label. The role sits on the toast rather than on one container per tone: the container is the `region` landmark, so it cannot also be the live region, and a role per toast needs no second region from the host. One timer per toast honours `timeout`; hover pauses it when `pauseOnHover` is true and focus always pauses, and both resume with the time that was left rather than restarting the deadline. `max` drops the oldest toast past the ceiling, and `onDismiss` fires for every toast that leaves. F6 into the region is the host's handler (hotkeys are a separate bean, cssints-h0d5): give `regionProps` an element with `tabindex="-1"` and focus it, remembering where focus was.
1339
1380
 
1381
+ The countdown does not remove a toast itself: its expiry calls every `onDismissRequest` handler and the removal is the renderer's — a handler returns `true` when it owns that row and calls `dismiss(id)` once the row has finished leaving, so a departure can be animated. With no handler, none that claims the id (a row that has not mounted yet), and for `requestDismiss(id)`, the toast leaves at once, as `dismiss(id)` always does; `dismissAll()` does too, and so does the ceiling, which replaces the dropped row with the new one.
1382
+
1340
1383
  ### Headless: `createPopover` (`@cssints/headless`)
1341
1384
 
1342
1385
  **No APG pattern exists** for a popover (APG has Dialog, which is modal), so it has no Quint spec and no ported scenarios: `packages/headless/test/popover.browser.test.tsx` is its oracle. The behaviour is copied from Base UI's Popover (a trigger click toggles, Enter / Space / ArrowDown open, an outside `pointerdown` light-dismisses without stealing focus) and React Aria's `usePopover` (focus into the content on open and back to the trigger on close, `role="dialog"` without `aria-modal`, a placement that flips at the viewport edge).
@@ -1553,6 +1596,40 @@ createScrollspy(options: {
1553
1596
 
1554
1597
  f812d87e (headless: a data table with sortable headers and row selection)
1555
1598
 
1599
+ ### Headless: `createVirtualizer` (`@cssints/headless`)
1600
+
1601
+ A window over a list of thousands: the host renders the visible rows plus an overscan, and two spacer boxes stand in for the rest, so the declared DOM, the content height and the scroll's clamp and thumb are the whole list's. It ports the rules the native renderer had (`native/solid/src/virtual.ts`: `fixedWindow`, `measuredWindow`, `OVERSCAN` and the gap-compensated spacers); the DOM adds the scrollport (`attach`), a `ResizeObserver` (the native renderer reads a row's box from `ui.bounds` after layout, which the DOM has no equivalent of) and the ARIA pair on every declared row. **No APG pattern exists for virtualisation and no Quint machine**: the state is pixels, not items, so `packages/headless/test/virtual.browser.test.tsx` is the oracle.
1602
+
1603
+ ```ts skip
1604
+ createVirtualizer(options: {
1605
+ count: number | (() => number); // the whole list, read on every window calculation
1606
+ itemSize?: number | ((index: number) => number) | undefined; // a fixed px height, or a per-row estimate; omit to measure
1607
+ gap?: number | undefined; // the column's gap, px; 0
1608
+ overscan?: number | undefined; // declared either side of the viewport; OVERSCAN (2)
1609
+ estimate?: number | undefined; // one height for the unmeasured rows; the mean of the measured, else 190
1610
+ }): {
1611
+ window(): { first: number; last: number; above: number; below: number; content: number };
1612
+ rangeCount(): number; // last - first + 1, or 0 (nothing to declare)
1613
+ scrollProps: { onScroll(event: Event): void }; // spread on the scrollport; attach already listens
1614
+ itemProps(index: number): {
1615
+ "aria-setsize": number; // the whole count
1616
+ "aria-posinset": number; // index + 1
1617
+ ref(element: HTMLElement): void; // the measured mode's observer wiring; a no-op when fixed
1618
+ };
1619
+ spacerProps(position: "start" | "end"): { "aria-hidden": "true"; style: { height: string } };
1620
+ scrollToIndex(index: number): void; // the least scroll that shows the row
1621
+ attach(element: HTMLElement): () => void; // the scrollport and the observers; returns the teardown
1622
+ };
1623
+ ```
1624
+
1625
+ A number `itemSize` is the arithmetic window: every row is that tall and row `n` starts at `n·(itemSize + gap)`. A function, or no `itemSize` at all, is the measured mode: a height per index arrives from the `ResizeObserver` on the declared rows, and an unmeasured row is the per-row estimate, else `estimate`, else the mean of what has been measured, else 190. The measured mode **measures once, then virtualises**: while any row's height is unknown the whole column is declared (one frame pays the whole list's layout), and the frame after it has every height, so the content height, the clamp and the thumb do not move as rows are first seen. `count` shrinking drops only the cached heights past the new count; growing it again leaves the new indexes unknown and the pass runs for them.
1626
+
1627
+ The items and the spacers are children of **one gap-spaced column** (`display: flex; flex-direction: column; row-gap: gap`, or a grid with `row-gap`), and a spacer's height is already the gap short (`above = first·(itemSize + gap) - gap`), so the first declared row starts at its real place. At an end there is **no** spacer: a zero-height box would still add the column's gap, so a host renders one only while its height is positive — `window().above > 0` / `window().below > 0`, or the `spacerProps` height.
1628
+
1629
+ Every declared row carries `aria-setsize` (the whole count) and `aria-posinset` (index + 1), so a screen reader announces "row 4,321 of 10,000" while only a screenful exists. A table host adds `aria-rowindex` itself: it counts the header row, which this primitive cannot know (and `createTable` already carries `aria-rowcount`). **Focus is the host's**: a focused row that leaves the window unmounts, so the host calls `scrollToIndex` when its keys move focus, before the new row must exist — the list declares the target, the host focuses it. `scrollToIndex` is the least scroll that shows the row (no move when it is already fully visible). In the measured mode its first offset is only an estimate for a row not measured yet, so the primitive keeps the target and lands again as each height arrives, until every height is in; a scroll the host makes by hand cancels the pending jump, because the reader has taken over.
1630
+
1631
+ `attach` only records the element, adds a passive `scroll` listener, observes the scrollport's size and (measured mode) the rows' heights, and returns the teardown: it **creates no computation**, so a host may call it from a `ref`, as `createPopover` learned to allow. The measured mode requires it; the fixed mode works from `scrollProps` alone but then a resized scrollport is not seen. `scrollProps.onScroll` is the same sync as a prop, for a host that wires scrolling itself. The scrollport and its column must have no padding (the arithmetic starts at the content's 0), horizontal virtualisation and a sticky header are not here, and the measured window rebuilds a prefix sum per call (fine for a feed, not for a dataset).
1632
+
1556
1633
  ### Headless: `createCommandPalette` (`@cssints/headless`)
1557
1634
 
1558
1635
  The first **composition**, not a new primitive and not a new spec: `createDialogModal` + `createTextInput` + `createStatus` wired into a palette. Its oracle is `packages/headless/test/command-palette.browser.test.tsx`. A palette is a dialog, **not a combobox**: choosing a row runs a command, so the field is `input[type=text][role=searchbox]` (no `aria-autocomplete`, no `aria-haspopup`), the container is `role=dialog` and the list is a `role=listbox`.
@@ -2167,6 +2244,1692 @@ createDiagramFocus(options: {
2167
2244
  - **The lists are getters.** Pass `() => nodes()` and the arrays are read at attribute-read time: an async model arrives without rebuilding the primitive, and the tab stop keeps its node while that node exists (else it falls back to the first). A node id that is not in `nodes` is a no-op in `nodeProps` and `focus`; an edge whose endpoint is missing is ignored everywhere.
2168
2245
  - **The host owns the DOM.** Nothing is measured, nothing is positioned and nothing is focused by the primitive; the host renders the nodes where its layout put them, the edges on its SVG layer, and the `role="status"` paragraph.
2169
2246
 
2247
+ ### Headless: `createRearrangeableListbox` (`@cssints/headless`)
2248
+
2249
+ The APG rearrangeable listbox: two lists that share one option set, with the option's index as its identity. Single-select, selection follows the focus and Alt+Up / Alt+Down reorder the focused list; multi-select adds Space to toggle and Enter / Delete to transfer the selected options to the other list, where they arrive deselected. Its behaviour is `packages/headless/test/listbox-rearrangeable.browser.test.tsx`, a differential walk against `specs/apg/listbox-rearrangeable.qnt` in six configurations (single and multi, uncontrolled, controlled accepting and controlled holding a request).
2250
+
2251
+ ```ts skip
2252
+ createRearrangeableListbox(options: {
2253
+ items: ReadonlyArray<string>; // labels; the option id is the index, and every option starts in list A
2254
+ multi?: boolean; // false: one selection, which follows the focus
2255
+ value?: ReadonlyArray<number> | undefined; // controlled selection (option ids); undefined = uncontrolled
2256
+ defaultValue?: ReadonlyArray<number>; // the selection at mount
2257
+ id?: string; // base of `<id>-option-<i>`; generated when absent
2258
+ labels?: Readonly<Record<"a" | "b", string>>; // each list's aria-label
2259
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request the host may ignore
2260
+ }): {
2261
+ items: ReadonlyArray<string>;
2262
+ order(list: "a" | "b"): ReadonlyArray<number>; // the option ids a list holds, in order
2263
+ active(list: "a" | "b"): number | null; // the active option, null when the list is empty
2264
+ focusedList(): "a" | "b" | null;
2265
+ selected(): ReadonlyArray<number>;
2266
+ multi(): boolean;
2267
+ focus(list: "a" | "b"): void;
2268
+ blur(): void;
2269
+ keyDown(key: string, modifiers?: { shift?: boolean; alt?: boolean; ctrl?: boolean; meta?: boolean }): void;
2270
+ move(direction: "up" | "down"): void; // swap the focused list's active option with its neighbour
2271
+ transfer(): void; // move the focused list's selected options to the other list
2272
+ setMulti(flag: boolean): void;
2273
+ listProps(list: "a" | "b"): {
2274
+ role: "listbox";
2275
+ tabindex: 0;
2276
+ "aria-multiselectable": "true" | "false";
2277
+ "aria-activedescendant": string | undefined; // only while this list has the primitive's focus
2278
+ "aria-label": string | undefined;
2279
+ onFocus;
2280
+ onBlur;
2281
+ onKeyDown;
2282
+ };
2283
+ optionProps(optionId: number): { id: string; role: "option"; "aria-selected": "true" | "false"; onClick };
2284
+ };
2285
+ ```
2286
+
2287
+ - **One option set, two orders.** `items` is read once; `order("a")` and `order("b")` give the two lists. An option is never copied: a transfer removes it from one order and appends it to the other. A transferred option arrives deselected.
2288
+ - **Selection follows the focus in single-select.** Focusing a list makes its active option the selection; ArrowUp / ArrowDown move the active option (and the selection with it), Home / End jump to the ends, and Alt+ArrowUp / Alt+ArrowDown swap the active option with its neighbour. `move` is that swap as a method, for a host's own button or drag.
2289
+ - **Multi-select is the APG's second example.** `Space` toggles the active option, Enter or Delete transfers every selected option of the focused list, and the orders survive the move.
2290
+ - **Controlled, a transfer waits for the host.** With `value` set, the list surgery is applied only when the `value` prop comes back equal to the request's intent; a host that holds the value moves nothing. Reordering (`move`) is not gated: it changes the primitive's own order arrays, not the selection.
2291
+ - **The primitive owns the ARIA of a listbox, not the position or the drag.** It writes `role`, `tabindex`, `aria-multiselectable`, `aria-activedescendant`, `aria-selected` and the handlers; it does not write `aria-posinset` / `aria-setsize` (a listbox's order is implicit), and it has no pointer drag. A host that wants a drag translates it to `move`; the `reorder` starter in `apps/ui` does exactly that, and adds the position attributes itself.
2292
+ - **Both lists or one.** A host renders `listProps("a")` and `listProps("b")` for the full pattern, or the first alone for a reorderable list. With one list, Enter and Delete have nowhere to transfer, so the host ignores them.
2293
+
2294
+ ### Headless: `createDisclosure` (`@cssints/headless`)
2295
+
2296
+ The APG Disclosure: one trigger that shows and hides one panel. Its behaviour is
2297
+ `packages/apg-specs/specs/apg/disclosure.qnt`, which
2298
+ `packages/headless/test/disclosure.browser.test.tsx` holds it to by replaying the ported scenarios of the APG,
2299
+ ariakit, base-ui and React Aria (`packages/apg-specs/scenarios/apg/disclosure.*.json`) and by a seeded
2300
+ differential walk in seven configurations: uncontrolled, expanded, disabled, and a controlled host that
2301
+ accepts or holds a request. The host renders a `<button>` and a panel and spreads the two prop objects; the
2302
+ primitive owns the state and the attributes, not the elements or the look. Options are read lazily, so pass a
2303
+ component's `props` or getters, never destructured values.
2304
+
2305
+ `defaultValue` starts the uncontrolled state (`false`), and `value` + `onValueChange` make the host own it:
2306
+ every intent is a request, so a refused one leaves the state untouched. `setExpanded`, `expand`, `collapse`
2307
+ and `toggle` are requests too.
2308
+
2309
+ ```ts skip
2310
+ createDisclosure(options?: {
2311
+ value?: boolean | undefined; // controlled; undefined = uncontrolled
2312
+ defaultValue?: boolean; // false: collapsed at mount
2313
+ disabled?: boolean; // the trigger's native attribute, and its click is dropped
2314
+ id?: string; // base of `<id>-trigger` and `<id>-panel`; generated when absent
2315
+ onValueChange?(expanded: boolean): void; // a request the host may ignore
2316
+ }): {
2317
+ expanded(): boolean;
2318
+ setExpanded(expanded: boolean): void; // a request, like expand / collapse / toggle
2319
+ expand(): void;
2320
+ collapse(): void;
2321
+ toggle(): void;
2322
+ triggerProps: {
2323
+ id; // `<id>-trigger`
2324
+ type: "button";
2325
+ "aria-expanded"; // "true" | "false", from the state
2326
+ "aria-controls"; // `<id>-panel`, always present
2327
+ disabled; // the option, read on every read
2328
+ onClick;
2329
+ };
2330
+ panelProps: {
2331
+ id; // `<id>-panel`
2332
+ hidden; // true while collapsed
2333
+ };
2334
+ };
2335
+ ```
2336
+
2337
+ - **The panel stays mounted.** `panelProps.hidden` is the whole hide, so `aria-controls` always names a real
2338
+ element and a closing animation has something to animate. Base UI unmounts the closed panel instead, and
2339
+ that missing `aria-controls` while collapsed is a named divergence. So are React Aria's `aria-hidden`
2340
+ beside `hidden` (which already removes the panel from the accessibility tree) and its `role="group"` on
2341
+ the panel.
2342
+ - **The pattern's attributes only.** React Aria forwards an attribute the consumer supplied; this primitive
2343
+ returns only the pattern's, so the host adds its own (an `aria-label`, a `class`).
2344
+ - **Ids.** `id` is the base of `<id>-trigger` and `<id>-panel`; a page with two disclosures passes its own base
2345
+ to each, since one base names one pair.
2346
+
2347
+ No key is bound: the trigger is a native `<button>`, so Enter and Space arrive as its `click`, and the host
2348
+ sees one request path for pointer and keyboard. `disabled` is the trigger's native attribute, so the browser
2349
+ skips it in the tab order and drops its click; the primitive guards its `onClick` as well.
2350
+
2351
+ ### Headless: `createAccordion` (`@cssints/headless`)
2352
+
2353
+ The APG Accordion: N headings, each a button that shows and hides its region, under the mode constraints of
2354
+ `packages/apg-specs/specs/apg/accordion.qnt`. `packages/headless/test/accordion.browser.test.tsx` replays the
2355
+ ported base-ui and React Aria scenarios and walks the model in eleven configurations (uncontrolled, expanded,
2356
+ multiple, non-collapsible, and a controlled host that accepts or holds a request). `items` is the number of
2357
+ panels (3, at least one), and the host renders a heading, a button and a panel per index, spreading
2358
+ `headingProps`, `triggerProps` and `panelProps`. Options are read lazily; `defaultValue` is the uncontrolled
2359
+ expanded set and `value` + `onValueChange` make the host own it.
2360
+
2361
+ ```ts skip
2362
+ createAccordion(options?: {
2363
+ items?: number; // 3; at least 1
2364
+ value?: ReadonlyArray<number> | undefined; // controlled: the expanded indices
2365
+ defaultValue?: ReadonlyArray<number>; // []: all collapsed
2366
+ multiple?: boolean; // false: at most one panel expanded
2367
+ collapsible?: boolean; // true: the last expanded panel may collapse
2368
+ headingLevel?: number; // 3, clamped into 1..6
2369
+ disabled?: boolean; // every trigger
2370
+ disabledItems?: ReadonlyArray<number>; // the named triggers only
2371
+ id?: string; // base of `<id>-trigger-<i>`, `<id>-panel-<i>`, `<id>-heading-<i>`
2372
+ onValueChange?(expanded: ReadonlyArray<number>): void; // a request the host may ignore
2373
+ }): {
2374
+ items: number;
2375
+ expanded(): ReadonlyArray<boolean>; // one flag per panel
2376
+ isExpanded(index: number): boolean;
2377
+ toggle(index: number): void; // a request; a disabled index changes nothing
2378
+ setMultiple(value: boolean): void; // re-normalizes the uncontrolled set only
2379
+ setCollapsible(value: boolean): void; // likewise
2380
+ setHeadingLevel(level: number): void; // clamped into 1..6
2381
+ triggerProps(index: number): {
2382
+ id;
2383
+ type: "button";
2384
+ "aria-expanded";
2385
+ "aria-controls"; // the panel's id
2386
+ "aria-disabled": "true" | undefined; // the sole expanded trigger when it cannot collapse
2387
+ disabled; // `disabled` or `disabledItems`
2388
+ onClick;
2389
+ };
2390
+ panelProps(index: number): {
2391
+ id;
2392
+ role: "region";
2393
+ "aria-labelledby"; // the trigger's id
2394
+ tabindex: 0;
2395
+ hidden;
2396
+ };
2397
+ headingProps(index: number): { id; role: "heading"; "aria-level": number };
2398
+ };
2399
+ ```
2400
+
2401
+ - **The mode constraints are the spec's `normExpanded`.** `multiple: false` keeps only the first expanded
2402
+ index, and `collapsible: false` re-expands the first panel when the set would be empty. A controlled host
2403
+ owns the set verbatim, so `setMultiple` / `setCollapsible` change their flag everywhere but re-normalize
2404
+ only while uncontrolled.
2405
+ - **The panel that cannot collapse says so.** While `collapsible` is false and this trigger is the only
2406
+ expanded one, `toggle` refuses it and `triggerProps` announces `aria-disabled="true"`; it stays focusable
2407
+ (the native `disabled` comes only from `disabled` / `disabledItems`), so a keyboard user meets the same
2408
+ refusal as a pointer.
2409
+ - **The panel is a region and a tab stop.** `role="region"`, named by its trigger through `aria-labelledby`,
2410
+ with `tabindex=0` whatever it holds and `hidden` while collapsed.
2411
+ - **`headingProps` is the wrapper, not a level of markup.** It carries `role="heading"` and the clamped
2412
+ `aria-level`, so the host spreads it on the element it would have used anyway (`<h3>`, a `<div>`).
2413
+ - **Two divergences from React Aria:** panels are keyed by index where React Aria keys them by item text, and
2414
+ `onValueChange` reports the whole expanded index set (the base-ui model) where React Aria reports one
2415
+ boolean per change.
2416
+
2417
+ No key is bound beyond the native button's: Enter and Space on a focused trigger arrive as its `click`, and
2418
+ Tab walks the triggers and the open panels (no roving tabindex, as the pattern's keyboard section has it).
2419
+
2420
+ ### Headless: `createSwitch` (`@cssints/headless`)
2421
+
2422
+ The APG Switch: one binary on/off control whose label never changes with its state. Its behaviour is
2423
+ `packages/apg-specs/specs/apg/switch.qnt`, replayed by
2424
+ `packages/headless/test/switch.browser.test.tsx` against the ported base-ui and React Aria scenarios and a
2425
+ differential walk in nine configurations (unchecked, checked, disabled, the optional Enter key, and a
2426
+ controlled host that accepts or holds a request). The host spreads `switchProps` onto the focusable element;
2427
+ `defaultValue` is the uncontrolled state (`false`) and `value` + `onValueChange` make the host own it.
2428
+
2429
+ The platform behind the pattern is the native `<input type="checkbox" switch>` as progressive enhancement
2430
+ (Safari only), with a checkbox carrying `role="switch"` as the safe baseline. This primitive returns that
2431
+ baseline's contract (`role="switch"`, `aria-checked`, `tabindex=0`, the handlers) and no element.
2432
+
2433
+ ```ts skip
2434
+ createSwitch(options?: {
2435
+ value?: boolean | undefined; // controlled; undefined = uncontrolled
2436
+ defaultValue?: boolean; // false: off at mount
2437
+ disabled?: boolean; // aria-disabled; every intent is dropped
2438
+ readOnly?: boolean; // aria-readonly; focus works, every intent is dropped
2439
+ enterToggles?: boolean; // false: the pattern's optional Enter key
2440
+ onValueChange?(checked: boolean): void; // a request the host may ignore
2441
+ }): {
2442
+ checked(): boolean;
2443
+ disabled(): boolean;
2444
+ readOnly(): boolean;
2445
+ enterToggles(): boolean;
2446
+ focused(): boolean;
2447
+ setChecked(checked: boolean): void; // a request
2448
+ setDisabled(value: boolean): void; // also disarms a held Space
2449
+ setEnterToggles(value: boolean): void;
2450
+ toggle(): void; // a request
2451
+ switchProps: {
2452
+ role: "switch";
2453
+ "aria-checked": "true" | "false";
2454
+ "aria-disabled": "true" | undefined;
2455
+ "aria-readonly": "true" | undefined;
2456
+ tabindex: 0;
2457
+ onKeyDown;
2458
+ onKeyUp;
2459
+ onClick;
2460
+ onFocus;
2461
+ onBlur;
2462
+ };
2463
+ };
2464
+ ```
2465
+
2466
+ - **Space is armed, then released.** `onKeyDown` arms on Space and `preventDefault`s the page scroll;
2467
+ `onKeyUp` flips. A repeated keydown does not re-arm, and a blur disarms. Enter flips on keydown only while
2468
+ `enterToggles` is on.
2469
+ - **`disabled` and `readOnly` block the same intents and announce differently.** Both drop every key, click
2470
+ and `toggle`, but `disabled` says `aria-disabled` and `readOnly` says `aria-readonly`. Only `disabled` has a
2471
+ setter; a host that flips `readOnly` flips the prop, which is read on every read.
2472
+ - **`focused()` is the primitive's own signal.** Focus sets it, a click sets it (a click focuses the control
2473
+ in a browser) and blur clears it; the primitive never moves the DOM focus.
2474
+ - **Two divergences:** React Aria renders a native input and carries the HTML `checked` / `disabled`
2475
+ attributes, where this primitive exposes `role="switch"` + `aria-checked`; base-ui reports its clicks to
2476
+ ancestor elements, where this primitive owns only the switch's callbacks.
2477
+
2478
+ ### Headless: `createRadioGroup` (`@cssints/headless`)
2479
+
2480
+ The APG Radio Group: one `radiogroup` with `role="radio"` items, where selection follows the focus. Its
2481
+ behaviour is `packages/apg-specs/specs/apg/radio.qnt`, replayed by
2482
+ `packages/headless/test/radio.browser.test.tsx` against the ported ariakit, base-ui and React Aria scenarios
2483
+ and a differential walk in six configurations (unchecked, checked, disabled, `activedescendant`, and a
2484
+ controlled host that accepts or holds a request). `values` is the option list (a getter is fine, so a list
2485
+ that arrives later is still one widget), `defaultValue` is the uncontrolled check and `value` +
2486
+ `onValueChange` make the host own it.
2487
+
2488
+ `value` may be a getter, and then `undefined` means "nothing checked" as a controlled state: it is the bare
2489
+ `undefined` prop that says uncontrolled.
2490
+
2491
+ ```ts skip
2492
+ createRadioGroup<T>(options: {
2493
+ values: ReadonlyArray<T> | (() => ReadonlyArray<T>); // one per radio
2494
+ value?: T | (() => T | undefined) | undefined; // controlled; bare undefined = uncontrolled
2495
+ defaultValue?: T; // nothing checked
2496
+ flavor?: "roving" | "activedescendant"; // "roving"
2497
+ disabled?: ReadonlyArray<number>; // indices never focused, checked or picked as the tab stop
2498
+ orientation?: "horizontal" | "vertical"; // sets aria-orientation only
2499
+ dir?: "ltr" | "rtl"; // "ltr": under rtl the horizontal arrows swap
2500
+ id?: string; // base of `<id>-radio-<i>`
2501
+ describedby?: string | undefined; // aria-describedby on every radio
2502
+ onValueChange?(value: T): void; // a request the host may ignore
2503
+ onFocus?(target: string): void; // "radio:<index>"
2504
+ onBlur?(target: string): void;
2505
+ }): {
2506
+ checked(): number | null; // the index, null when nothing is checked
2507
+ focused(): number | null;
2508
+ flavor(): RadioFlavor;
2509
+ values: ReadonlyArray<T>;
2510
+ value(): T | null;
2511
+ focus(): void;
2512
+ blur(): void;
2513
+ keyDown(key: string): void;
2514
+ click(index: number): void;
2515
+ setDisabled(indices: ReadonlyArray<number>): void;
2516
+ setFlavor(flavor: RadioFlavor): void;
2517
+ setPreselect(index: number | null): void;
2518
+ radiogroupProps: {
2519
+ role: "radiogroup";
2520
+ "aria-orientation";
2521
+ "aria-activedescendant": string | undefined; // only the activedescendant flavor
2522
+ tabindex: 0 | undefined; // 0 only in the activedescendant flavor
2523
+ onKeyDown;
2524
+ onFocus;
2525
+ onBlur;
2526
+ };
2527
+ radioProps(index: number): {
2528
+ id;
2529
+ role: "radio";
2530
+ "aria-checked": "true" | "false";
2531
+ "aria-disabled": "true" | "false";
2532
+ "aria-describedby";
2533
+ type: "button"; // keeps a surrounding form from submitting
2534
+ tabindex: 0 | -1 | undefined; // roving only: 0 for the tab stop
2535
+ onClick;
2536
+ onFocus;
2537
+ };
2538
+ };
2539
+ ```
2540
+
2541
+ - **Two flavors, one machine.** `"roving"` (the default) keeps the tab stop on the radios: `tabindex=0` on
2542
+ the checked radio, or on the first enabled one while nothing is checked, `-1` on the rest.
2543
+ `"activedescendant"` puts the tab stop on the group (`tabindex=0` there, nothing on the radios) and names
2544
+ the focused radio in `aria-activedescendant`; the host keeps the DOM focus on the group. The primitive
2545
+ moves no DOM focus in either flavor: `focused()` reads synchronously, so the host applies it.
2546
+ - **Selection follows the focus.** An arrow moves to the next enabled radio (wrapping) and requests it, Space
2547
+ requests the focused one, a click focuses and requests, and Tab enters on the tab stop. `setPreselect`
2548
+ starts the check without a request, and `setDisabled` / `setFlavor` reset the focus.
2549
+ - **Config remounts fire no request.** `setDisabled`, `setFlavor` and `setPreselect` write the uncontrolled
2550
+ local directly; with `value` set, what is checked is the prop's, so they only move the focus.
2551
+ - **Two divergences:** React Aria renders native `<input type="radio">` elements and carries the HTML
2552
+ `disabled` attribute; one `onFocus` / `onBlur` reports the radio (`"radio:<index>"`) where the React Aria
2553
+ scenario uses a callback per radio.
2554
+
2555
+ ArrowDown / ArrowRight are forward and ArrowUp / ArrowLeft backward, with `dir: "rtl"` swapping the
2556
+ horizontal pair; both axes work whatever `orientation` says, since that option only sets `aria-orientation`.
2557
+ The arrows wrap past the ends and skip every disabled index; Home / End are not handled, as the pattern has
2558
+ no such key.
2559
+
2560
+ ### Headless: `createTooltipGroup` (`@cssints/headless`)
2561
+
2562
+ The APG Tooltip with WCAG 1.4.13 (dismissable, hoverable, persistent), over the spec's pair of triggers. Its
2563
+ behaviour is `packages/apg-specs/specs/apg/tooltip.qnt`, which
2564
+ `packages/headless/test/tooltip.browser.test.tsx` holds it to by replaying the ported ariakit, base-ui and
2565
+ React Aria scenarios and by a differential walk in four configurations (uncontrolled, controlled accepting,
2566
+ controlled holding, and one holding a tooltip open). Timers are events, not clocks: the spec and the walk
2567
+ fire `showTimer`, `hideTimer` and `warmExpire`, and the host wires its own `setTimeout` from
2568
+ `showDelayMs()` / `hideDelayMs()`, resolved from `prefs` (600 ms and 300 ms by default).
2569
+
2570
+ The visible tooltip is the controlled value: `0` none, `1` the first trigger's, `2` the second's.
2571
+ `defaultOpen` is the uncontrolled index and `onOpenChange(open, index)` is the request. The rest of the
2572
+ machine (focus, pointer, pending timers, the dismissal memory) tracks every event either way and only the
2573
+ commit is gated, so a refused request leaves the visible tooltip where it was.
2574
+
2575
+ ```ts skip
2576
+ createTooltipGroup(options: {
2577
+ triggers: ReadonlyArray<{ id?: string; tooltipId?: string }>; // the spec's pair
2578
+ disabled?: boolean; // focus and pointer are ignored entirely
2579
+ openOnFocus?: boolean; // keyboard focus opens at once (the host applied its delay)
2580
+ openOnHover?: boolean; // hover opens at once (a delay of 0)
2581
+ nativePointer?: boolean; // true: the elements' own pointer events drive the state
2582
+ defaultOpen?: number; // trigger index whose tooltip starts visible
2583
+ value?: number | undefined; // controlled: 0 none, 1 first, 2 second; undefined = uncontrolled
2584
+ onOpenChange?(open: boolean, index: number): void; // a request the host may ignore
2585
+ prefs?: Partial<Prefs> | undefined; // tooltipShowDelayMs / tooltipHideDelayMs
2586
+ }): {
2587
+ visibleIndex(): number | null;
2588
+ showDelayMs(): number;
2589
+ hideDelayMs(): number;
2590
+ described(index: number): boolean; // this trigger names its tooltip now
2591
+ focusTrigger(index: number): void;
2592
+ blur(): void;
2593
+ pointerEnter(index: number): void;
2594
+ pointerEnterTip(): void;
2595
+ pointerLeave(): void; // also calls hideTimer()
2596
+ showTimer(index: number): void; // the host's show setTimeout fired
2597
+ hideTimer(): void; // the grace period expired
2598
+ escape(): void;
2599
+ warmExpire(): void; // closes the warm window
2600
+ setWarmup(on: boolean): void; // enables the spec's warm-up variant
2601
+ hostSet(vis: number): void; // call before a controlled write of `value`
2602
+ hostToggle(): void; // call before a controlled flip between none and the first
2603
+ triggerProps(index: number): {
2604
+ id;
2605
+ "aria-describedby": string | undefined; // the tooltip, only while visible
2606
+ onFocus;
2607
+ onPointerEnter;
2608
+ onPointerLeave;
2609
+ onKeyDown; // Escape
2610
+ };
2611
+ tooltipProps(index: number): {
2612
+ id;
2613
+ role: "tooltip";
2614
+ hidden;
2615
+ onPointerEnter; // hoverable: the pointer may travel onto the tooltip
2616
+ onPointerLeave;
2617
+ };
2618
+ };
2619
+ ```
2620
+
2621
+ - **Ids.** A trigger's `id` and `tooltipId` name it and its tooltip (`<base>-trigger-<i>` and
2622
+ `<base>-tooltip-<i>` when absent); `aria-describedby` names the tooltip only while it is visible, and
2623
+ `described(index)` is that same condition as a boolean.
2624
+ - **The delays belong to the host.** `openOnFocus` / `openOnHover` open with no delay, for a host that has
2625
+ already applied its own; otherwise focus and hover only arm a show timer, and `showTimer(index)` is the
2626
+ host's `setTimeout` firing. A pointer leaving a trigger or the tooltip calls `hideTimer()` — the grace
2627
+ period is the host's too.
2628
+ - **Hoverable is the tooltip's own handlers.** `tooltipProps` carries `onPointerEnter` / `onPointerLeave`, so
2629
+ the pointer can travel onto the tooltip without closing it. `nativePointer: false` stops the rendered
2630
+ elements from driving the state, for a host that feeds `pointerEnter` / `pointerLeave` itself.
2631
+ - **Warm-up.** `setWarmup(true)` enables the spec's variant: while a tooltip is visible, or was hidden within
2632
+ the warm window, a newly entered trigger opens immediately. `warmExpire()` closes that window, and an
2633
+ Escape leaves the widget warm, so the next trigger inside the window opens at once.
2634
+ - **Host writes.** `hostSet(vis)` and `hostToggle()` drop the timers and the dismissal memory a controlled
2635
+ write invalidates, so call one before writing `value`; both do nothing while uncontrolled.
2636
+ - **One divergence:** the base-ui scenario that starts open with `defaultOpen` and then unhovers a trigger
2637
+ never sees a pointer event in the DOM, so the hide grace is never armed there.
2638
+ - **Two triggers.** The spec models a pair: index `0` is the first and `1` the second. There is no `id`
2639
+ option, because each trigger names its own elements.
2640
+
2641
+ Escape on a trigger hides the tooltip (`escape()`) without moving the focus or the pointer, and nothing
2642
+ reopens it until that trigger is left and entered again — WCAG's dismissable part. No other key is bound:
2643
+ focus and hover are the other two ways in, and the pointer handlers above are the hoverable part.
2644
+
2645
+ ### Headless: `createToolbar` (`@cssints/headless`)
2646
+
2647
+ The APG Toolbar: a row or column of controls behind one tab stop, with the roving tabindex of
2648
+ `shared/focusgroup.qnt` and the delta of `packages/apg-specs/specs/apg/toolbar.qnt` (`aria-orientation`, the
2649
+ author's wrap choice, disabled-but-focusable items, embedded controls that consume keys, and activation).
2650
+ `packages/headless/test/toolbar.browser.test.tsx` replays the ported ariakit, base-ui and React Aria
2651
+ scenarios and walks the model horizontally (no wrap) and vertically (wrapping); the fixture composes
2652
+ `createMenu` for the spec's menu-button, which the primitive never imports.
2653
+
2654
+ Platform: the roving tabindex is the `focusgroup="toolbar"` contract, so ship the
2655
+ `@microsoft/focusgroup-polyfill` first and switch to the native attribute when Chrome/Edge is the floor.
2656
+
2657
+ `items` is fixed at construction, as label strings or `{ label, kind, disabled }`, and `kinds` names a kind per
2658
+ index for the string form (`"button"` by default). `defaultFocus` is the index that starts focused, and
2659
+ `focus: -1` means "outside the toolbar".
2660
+
2661
+ ```ts skip
2662
+ createToolbar(options: {
2663
+ items: ReadonlyArray<string | { label: string; kind?: ToolbarItemKind; disabled?: boolean }>;
2664
+ kinds?: ReadonlyArray<"button" | "text" | "spin">; // per index, for the string form
2665
+ orientation?: "horizontal" | "vertical"; // "horizontal"
2666
+ wrap?: boolean; // false: the arrows stop at the ends
2667
+ defaultFocus?: number; // -1: nothing focused yet
2668
+ disabled?: ReadonlyArray<number>; // items disabled at mount, still focusable
2669
+ id?: string; // base of `<id>-item-<i>`
2670
+ onActivate?(index: number): void; // a button item, by click, Enter or Space
2671
+ }): {
2672
+ focus(): number; // -1 when the toolbar is left
2673
+ tabStop(): number;
2674
+ orientation(): "horizontal" | "vertical";
2675
+ wrap(): boolean;
2676
+ items: ReadonlyArray<{ id; label; kind; disabled }>;
2677
+ focusIn(): void; // Tab into the toolbar: the tab stop
2678
+ leave(): void;
2679
+ focusItem(index: number): void;
2680
+ keyDown(key: string): void;
2681
+ tab(back: boolean): void;
2682
+ clickItem(index: number): void;
2683
+ activate(): void; // a button item
2684
+ toggleDisabled(index: number): void;
2685
+ setOrientation(orientation: "horizontal" | "vertical"): void;
2686
+ setWrap(wrap: boolean): void;
2687
+ toolbarProps: {
2688
+ role: "toolbar";
2689
+ "aria-orientation": "horizontal" | "vertical";
2690
+ onKeyDown;
2691
+ };
2692
+ itemProps(index: number): {
2693
+ id;
2694
+ role: "button";
2695
+ "aria-disabled": "true" | undefined;
2696
+ tabindex: 0 | -1; // the roving tab stop
2697
+ onClick;
2698
+ onFocus;
2699
+ };
2700
+ };
2701
+ ```
2702
+
2703
+ - **Disabled but focusable.** A disabled item keeps its place in the arrows and the tab-stop order and
2704
+ announces `aria-disabled="true"`; it is never activated, so `onActivate` does not fire for it.
2705
+ `toggleDisabled(index)` flips the mask at runtime.
2706
+ - **Embedded controls consume their keys.** A `"text"` item swallows the axis navigation keys and a
2707
+ `"spin"` item swallows Up / Down and Home / End, so typing or stepping inside an item never moves the
2708
+ toolbar's focus. The embedded control's own value is not modelled: the host renders it and owns it.
2709
+ - **Divergences.** base-ui and ariakit skip disabled items in roving navigation, where `toolbar.qnt` keeps
2710
+ them focusable; the tab stop starts on item `0` even when that item is disabled, where the abstract
2711
+ Composite picks the first non-disabled item; and the primitive renders `role="toolbar"` with
2712
+ `aria-orientation`, where the abstract Composite role carries neither. A toggle group's `aria-pressed` is
2713
+ not this primitive.
2714
+ - **Limits (the source's own):** no separators, no RTL, and `items` does not grow after construction.
2715
+
2716
+ `Right` / `Left` on a horizontal toolbar and `Down` / `Up` on a vertical one move the roving tab stop along
2717
+ the axis (`ArrowRight` and `Right` are normalised to one key), Home / End jump to the ends, and `wrap`
2718
+ decides whether an edge wraps or stops. Enter and Space activate the focused `button` item, and both are
2719
+ `preventDefault`ed. Tab from a `button` item leaves the toolbar (the browser moves on); from a `text` item it
2720
+ steps to the neighbouring item and only leaves at the edge, which is how Tab escapes a text field inside a
2721
+ composite. `focusIn`, `leave` and `tab(back)` are those steps as methods, and `focusItem`, `clickItem` and
2722
+ `activate` are the pointer and programmatic paths.
2723
+
2724
+ ### Headless: `createTextInput` and `createTextArea` (`@cssints/headless`)
2725
+
2726
+ `createField` plus the props a host forgets: a text input or a textarea with the field's label, hint, error
2727
+ and description contract attached. There is no APG pattern for text editing (an `<input>` already is the
2728
+ pattern), so there is no Quint spec and `packages/headless/test/textbox.browser.test.tsx` is the oracle for
2729
+ both. The field half — ids, `aria-describedby` order, `aria-invalid` only while invalid, `role="alert"` on a
2730
+ shown error — is `createField`'s and is documented above.
2731
+
2732
+ ```ts skip
2733
+ createTextInput(options?: {
2734
+ // the field's options: id, required, invalid, hint, error, describedby
2735
+ type?: "text" | "email" | "tel" | "url" | "search" | "password"; // "text"
2736
+ autoComplete?: AutoCompleteToken; // the HTML token list; the browser's autofill is the accessible path
2737
+ spellCheck?: boolean; // spellcheck, "true" | "false"; absent when the option is
2738
+ autoCapitalize?: "none" | "sentences" | "words" | "characters";
2739
+ inputMode?: "none" | "text" | "tel" | "url" | "email" | "numeric" | "decimal" | "search";
2740
+ maxLength?: number; // the browser's own limit
2741
+ readOnly?: boolean; // focusable, selectable, submitted
2742
+ disabled?: boolean; // out of the tab order, not submitted
2743
+ name?: string;
2744
+ dir?: "ltr" | "rtl" | "auto";
2745
+ placeholder?: string;
2746
+ onValueChange?(value: string): void; // every `input` event
2747
+ }): {
2748
+ // the field's members: invalid(), hint(), error(), describedIds(), labelProps, hintProps, errorProps
2749
+ controlProps: {
2750
+ id; // `<id>-control`, from the field
2751
+ "aria-describedby"; // hint, then error, while shown, then the host's describedby
2752
+ "aria-invalid"; // "true" only while invalid
2753
+ "aria-required"; // "true" while required
2754
+ required; // true while required
2755
+ type;
2756
+ autoComplete;
2757
+ spellcheck: "true" | "false" | undefined;
2758
+ autocapitalize;
2759
+ inputmode;
2760
+ maxlength;
2761
+ readOnly: true | undefined;
2762
+ disabled: true | undefined;
2763
+ name;
2764
+ dir;
2765
+ placeholder;
2766
+ onInput;
2767
+ };
2768
+ };
2769
+ ```
2770
+
2771
+ ```ts skip
2772
+ createTextArea(options?: {
2773
+ // the text input's options and members, plus:
2774
+ rows?: number;
2775
+ wrap?: "soft" | "hard" | "off";
2776
+ resize?: "none" | "vertical" | "horizontal" | "both"; // carried as a style
2777
+ }): {
2778
+ controlProps: {
2779
+ // …the input's controlProps, plus
2780
+ rows: number | undefined;
2781
+ wrap: "soft" | "hard" | "off" | undefined;
2782
+ style: { resize: "none" | "vertical" | "horizontal" | "both" } | undefined;
2783
+ };
2784
+ };
2785
+ ```
2786
+
2787
+ - **Both `required` attributes.** The native one is what the browser enforces, `aria-required` is what a
2788
+ custom widget reading the same props needs. A host that validates itself puts `noValidate` on the form
2789
+ rather than dropping the attribute.
2790
+ - **`maxLength` is the browser's.** The primitive emits the attribute and reports the value it is given; it
2791
+ never truncates in JS, which would break paste and IME.
2792
+ - **`disabled` is not `readOnly`.** Disabled is out of the tab order and is not submitted; readOnly is
2793
+ focusable, selectable and submitted, which is what a value the user must read before signing wants.
2794
+ - **One callback per edit.** `onValueChange` is wired to `input`, so a keystroke, a paste, a dropped
2795
+ character and an IME commit all reach it; `change` is not bound.
2796
+ - **The textarea adds `rows`, `wrap` and `resize`.** `resize` is a `style` because CSS has no attribute for
2797
+ it, and it is absent when the option is; `none` is what a layout that owns its own height wants.
2798
+ - **Options are read on every attribute read,** so `disabled`, `hint` and `error` may change on a mounted
2799
+ control.
2800
+
2801
+ No key is bound: editing is the platform's, with its arrows, Home / End, selection, undo, IME and paste
2802
+ behaviour. The primitive neither commits on Enter nor intercepts Tab, and a multiline field's Enter is the
2803
+ browser's too, so a host that wants Enter to submit reads the key itself.
2804
+
2805
+ ### Headless: `createLink` (`@cssints/headless`)
2806
+
2807
+ The APG link as attribute objects: a native `<a href>` when the host has an `href` (the default),
2808
+ and otherwise a custom element that takes `role="link"`, `tabindex="0"` and an Enter handler,
2809
+ because the pattern says that "applying the link role will not cause browsers to enhance the
2810
+ element". Its behaviour is `packages/apg-specs/specs/apg/link.qnt`;
2811
+ `packages/headless/test/link.browser.test.tsx` replays the ariakit, Base UI and react-aria scenarios
2812
+ (`packages/apg-specs/scenarios/apg/link.*.json`) against it and walks it differentially against the
2813
+ model in both the native and the custom configuration.
2814
+
2815
+ ```ts skip
2816
+ createLink(options?: {
2817
+ href?: string | undefined; // set: a native anchor, unless native: false
2818
+ native?: boolean | undefined; // defaults to href !== undefined
2819
+ disabled?: boolean; // false
2820
+ current?: boolean; // aria-current="page", for a trail's current entry
2821
+ onActivate?(): void; // Enter or a click reached the link
2822
+ onNavigate?(href: string): void; // router integration: where the activation would go
2823
+ }): {
2824
+ native(): boolean;
2825
+ href(): string | undefined;
2826
+ disabled(): boolean;
2827
+ linkProps: {
2828
+ role: "link" | undefined; // undefined on a native anchor
2829
+ tabindex: number | undefined; // undefined natively, -1 while disabled, else 0
2830
+ href: string | undefined; // only a native anchor carries it
2831
+ "aria-disabled": "true" | undefined;
2832
+ "aria-current": "page" | undefined;
2833
+ onKeyDown;
2834
+ onClick;
2835
+ };
2836
+ };
2837
+ ```
2838
+
2839
+ The primitive holds no state: `native`, `href`, `disabled` and `current` are read when an attribute
2840
+ is read, so a host that flips one of them reacts without rebuilding anything. `linkProps` is
2841
+ getter-backed for the same reason: spread it once and every attribute follows the props.
2842
+
2843
+ **The primitive never navigates.** `onClick` prevents the anchor's default action on every click and
2844
+ then calls `onActivate()` and `onNavigate(href())`, which is what makes a router integration a
2845
+ callback rather than an intercept of the browser's own follow. A middle click is an `auxclick`,
2846
+ which `linkProps` does not handle, so "open in a new tab" keeps the browser's path. A disabled
2847
+ custom link leaves the tab order (`tabindex="-1"`); a disabled native one keeps its tab stop and
2848
+ only announces `aria-disabled="true"`, so a host that must truly retire the link drops `href` while
2849
+ it is disabled. The one react-aria scenario that stays a negative test is
2850
+ `accessible-props-passed-through`: it passes `aria-current` through a consumer prop, and the
2851
+ primitive only returns the pattern's own attributes.
2852
+
2853
+ **Keyboard, and what the host owns.** Enter activates through the same handler for both shapes, so a
2854
+ native anchor's activation reaches `onNavigate` instead of the address bar; Space is consumed
2855
+ (`preventDefault`) but does nothing, as the pattern's key table says, so the page never scrolls on a
2856
+ focused link. There is no live region and no focus management (`link.qnt`'s "focus moves to the
2857
+ target" is the browser's job, or the host's after `onActivate`). `role` and `tabindex` exist only
2858
+ for the custom element: a native anchor gets focusability and activation from the browser.
2859
+
2860
+ ### Headless: `createListbox` (`@cssints/headless`)
2861
+
2862
+ The APG listbox as state plus attribute objects, one widget for the three machines the scenarios
2863
+ use: single-select (with optional selection-follows-focus), multi-select in the recommended model,
2864
+ and type-ahead. `packages/apg-specs/specs/apg/listbox-single.qnt`, `listbox-multi.qnt` and
2865
+ `listbox-typeahead.qnt` are its models; `packages/headless/test/listbox.browser.test.tsx` replays
2866
+ the ariakit, Base UI and react-aria scenarios (`packages/apg-specs/scenarios/apg/listbox.*.json`)
2867
+ and walks it differentially in thirteen configurations: single, follows-focus, a default selection,
2868
+ multi with and without one, type-ahead with `selectionMode: "none"`, and each of those controlled by
2869
+ a host that accepts every request and one that holds its value.
2870
+
2871
+ ```ts skip
2872
+ // ListboxItemInput is a label string or { id?, label, textValue?, disabled? }
2873
+ createListbox(options: {
2874
+ items: ReadonlyArray<ListboxItemInput> | (() => ReadonlyArray<ListboxItemInput>);
2875
+ selectionMode?: "single" | "multiple" | "none"; // "single"
2876
+ followsFocus?: boolean; // false; single-select only: the active option is also selected
2877
+ value?: ReadonlyArray<number> | undefined; // controlled selection, by option index
2878
+ defaultValue?: ReadonlyArray<number>; // uncontrolled; defaultSelected is an alias
2879
+ defaultActive?: number; // 0
2880
+ activeValue?: number | undefined; // controlled active option (the type-ahead focus move)
2881
+ disabled?: ReadonlyArray<number>; // indices that cannot be selected or reached
2882
+ focusOnHover?: boolean; // false: hoverOption moves the active option only when true
2883
+ id?: string; // base of the generated option ids
2884
+ label?: string | undefined; // aria-label on the listbox
2885
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request; onSelectionChange is an alias
2886
+ onActiveChange?(index: number): void; // a request
2887
+ }): {
2888
+ active(): number;
2889
+ selected(): ReadonlyArray<number>; // sorted, de-duplicated, in range
2890
+ focused(): boolean;
2891
+ items: ReadonlyArray<{ id; label; textValue; disabled }>; // getter-backed
2892
+ selectionMode: "single" | "multiple" | "none";
2893
+ focus(): number; // the option that takes focus on entry
2894
+ blur(): void;
2895
+ keyDown(key: string, modifiers?: { shift?; ctrl?; meta?; alt? }): void;
2896
+ type(text: string): void;
2897
+ clearTypeahead(): void;
2898
+ clickOption(index: number): void;
2899
+ hoverOption(index: number): void;
2900
+ setFollowsFocus(value: boolean): void;
2901
+ listboxProps: {
2902
+ role: "listbox";
2903
+ "aria-multiselectable": "true" | "false" | undefined; // undefined in "none"
2904
+ "aria-activedescendant": string | undefined; // only while the widget holds focus
2905
+ "aria-label": string | undefined;
2906
+ onKeyDown;
2907
+ onFocusOut;
2908
+ };
2909
+ optionProps(index: number): {
2910
+ id;
2911
+ role: "option";
2912
+ "aria-selected": "true" | "false";
2913
+ "aria-disabled": "true" | undefined;
2914
+ "aria-posinset";
2915
+ "aria-setsize";
2916
+ tabindex: 0 | -1; // 0 on the active option only
2917
+ onClick;
2918
+ onFocus;
2919
+ onMouseEnter;
2920
+ };
2921
+ };
2922
+ ```
2923
+
2924
+ `items` takes a getter (`() => items()`) and is read at attribute-read time, so a list that arrives
2925
+ later, or is filtered, keeps the active option and the selection instead of being rebuilt; the
2926
+ normalized list is cached by the identity of the array it came from, so one re-rendered option does
2927
+ not walk the whole list. A selection or a `defaultActive` chosen before the items landed still
2928
+ applies once they do, because `selected()` normalizes against the live count at every read.
2929
+ Selection is a set of indices, always returned sorted and de-duplicated, and an option that is
2930
+ disabled either by the `disabled` index list or by its own `disabled` field is skipped by the
2931
+ arrows, Home/End, clicks and hover.
2932
+
2933
+ Selection is uncontrolled by default (`defaultValue`, or `defaultActive` for the active option) and
2934
+ controlled by `value` + `onValueChange` (the active option by `activeValue` + `onActiveChange`,
2935
+ which is the type-ahead machine's value). Every selection change and every focus move is a request:
2936
+ uncontrolled it commits, controlled it commits nothing until the host writes the prop back, and a
2937
+ request equal to the current state is not made at all.
2938
+
2939
+ **Keyboard.** In `"single"` and `"none"` a printable character is type-ahead: a fresh buffer
2940
+ searches from the option after the active one, wrapping, and a growing buffer searches from the
2941
+ active one inclusive, both on the item's `textValue` (its label by default); a Space continues a
2942
+ non-empty buffer instead of selecting. ArrowDown / ArrowUp move the active option without wrapping,
2943
+ Home / End jump to the first / last enabled option, and Space or Enter selects the active option in
2944
+ single-select (in `"none"` there is nothing to select). In `"multiple"` Space toggles the active
2945
+ option, Shift+Space selects the run from the anchor to the active option, Shift+Arrow toggles the
2946
+ option it enters, Ctrl+Shift+Home / End select to that edge, and Ctrl+A (or Meta+A) selects all, or
2947
+ clears when all are selected. Ctrl+Space and Ctrl+Arrow are consumed and move nothing: the pattern's
2948
+ alternative model, where the arrows move focus only, is not implemented. The type-ahead keys are
2949
+ single- and none-only; `type(text)` itself searches in any mode.
2950
+
2951
+ There is no live region and no timer: the host calls `clearTypeahead()` from its own idle timer, and
2952
+ `focusOnHover` is off by default because moving the active option on hover fights a nested scroll or
2953
+ a tooltip. `focus()` names the entry option (the first selected one, else the first), returns the
2954
+ active one when the widget already has focus, and, in single-select with `followsFocus`, commits it.
2955
+ `aria-activedescendant` is emitted while the widget holds focus, but the container itself has no
2956
+ tabindex: the active option is the single tab stop, so the host keeps the DOM focus on that option.
2957
+ The scenario divergences: `autoFocus` focuses the option, not the listbox, so a probe reads
2958
+ `option:0`; and `onAction` / `onPress` are react-aria extensions the primitive does not report (only
2959
+ the selection change).
2960
+
2961
+ ### Headless: `createMeter` (`@cssints/headless`)
2962
+
2963
+ A graphical display of a numeric value within a range, for a battery or a fuel gauge: **not a
2964
+ progress bar** (there is no completion) and not for an unbounded value. The platform does the work
2965
+ (`<meter>`), so the primitive only derives the ARIA contract and runs no state machine:
2966
+ `packages/apg-specs/specs/apg/meter.qnt` checks the three properties the pattern states (min below
2967
+ max, the value clamped into the range, a value text that describes the current value),
2968
+ `packages/headless/test/meter.browser.test.tsx` replays the Base UI and react-aria scenarios
2969
+ (`packages/apg-specs/scenarios/apg/meter.*.json`) and walks it differentially with in-range,
2970
+ offset-range and out-of-range configurations.
2971
+
2972
+ ```ts skip
2973
+ createMeter(options?: {
2974
+ value?: number; // 0; clamped into [min, max]
2975
+ min?: number; // 0
2976
+ max?: number; // 100
2977
+ valueText?: string | undefined; // aria-valuetext; the rounded percentage when absent
2978
+ label?: string | undefined; // aria-label
2979
+ labelId?: string | undefined; // aria-labelledby, for a visible label element
2980
+ }): {
2981
+ min(): number;
2982
+ max(): number;
2983
+ value(): number; // the clamped value, never the raw one
2984
+ percent(): number; // 0..100, rounded to a whole number
2985
+ meterProps: {
2986
+ role: "meter";
2987
+ "aria-valuemin";
2988
+ "aria-valuemax";
2989
+ "aria-valuenow";
2990
+ "aria-valuetext"; // always present: the host's text or "42%"
2991
+ "aria-label";
2992
+ "aria-labelledby";
2993
+ };
2994
+ };
2995
+ ```
2996
+
2997
+ The primitive is controlled by construction: the host feeds `value`, `min` and `max`, and nothing is
2998
+ stored. A value outside the range is clamped for both `aria-valuenow` and `percent()`, and a range
2999
+ whose `max` does not exceed its `min` reports `min` and 0% rather than a negative ratio: the model
3000
+ ignores such a range change and keeps the last valid one, where a pure function of the current props
3001
+ has nothing to keep. `percent()` is the skin's input (fill width, needle angle); the primitive draws
3002
+ nothing. Pass `label` or `labelId`, not both: both attributes are emitted as given, and
3003
+ `aria-labelledby` wins in ARIA.
3004
+
3005
+ **Keyboard and limits.** None: a meter is not focusable, binds no key and has no live region,
3006
+ because the value it shows is not a control the user operates. `aria-valuetext` is always emitted,
3007
+ so a host that wants a friendlier sentence ("3 of 5 bars") passes it; the default is the rounded
3008
+ percentage. Neither a progressbar's completion semantics nor the value's history belongs here: a
3009
+ task that finishes, and has a `role="progressbar"` of its own, is not a meter.
3010
+
3011
+ ### Headless: `createPagination` (`@cssints/headless`)
3012
+
3013
+ A navigation landmark over a page list. **No APG pattern exists for pagination**; the requirements
3014
+ are TetraLogical's (accessible-components-library v1.0.0), and
3015
+ `packages/headless/test/pagination.browser.test.tsx` is its oracle: exactly one current page, the
3016
+ ends kept in place and disabled rather than removed, the count announced once, and an `href` for
3017
+ every link so the control works without JS.
3018
+
3019
+ ```ts skip
3020
+ createPagination(options: {
3021
+ page: number; // required, 1-based
3022
+ pageCount: number; // required
3023
+ siblings?: number; // 1: pages shown on each side of the current one
3024
+ boundaries?: number; // 1: pages always shown at each end
3025
+ hrefFor?: ((page: number) => string) | undefined; // set: anchors; absent: buttons
3026
+ disabled?: boolean; // false
3027
+ labels?: {
3028
+ nav?; // "Pagination"
3029
+ previous?; // "Previous page"
3030
+ next?; // "Next page"
3031
+ page?; // the prefix of a page link's name, "Page"
3032
+ gap?; // declared, but unused: a gap carries its key, the host writes its text
3033
+ status?: (page: number, count: number) => string; // "Page <page> of <count>"
3034
+ } | undefined;
3035
+ onPageChange?(page: number): void; // a request the host may ignore
3036
+ }): {
3037
+ items(): ReadonlyArray<
3038
+ { kind: "page"; page: number; current: boolean } | { kind: "gap"; key: string }
3039
+ >;
3040
+ current(): number; // page, clamped into 1..max(pageCount, 1)
3041
+ navProps: { "aria-label": string };
3042
+ listProps: { role: "list" };
3043
+ itemProps(page: number): { role: "listitem"; "aria-current": "page" | undefined };
3044
+ linkProps(page: number): {
3045
+ href: string | undefined;
3046
+ "aria-current": "page" | undefined;
3047
+ "aria-disabled": "true" | undefined; // disabled, or a page outside the range
3048
+ "aria-label": string;
3049
+ rel: undefined;
3050
+ onClick;
3051
+ };
3052
+ previousProps: { href; "aria-current": undefined; "aria-disabled"; "aria-label"; rel: "prev"; onClick };
3053
+ nextProps: { href; "aria-current": undefined; "aria-disabled"; "aria-label"; rel: "next"; onClick };
3054
+ status(): string;
3055
+ statusProps: { role: "status"; "aria-live": "polite"; "aria-atomic": "true" };
3056
+ };
3057
+ ```
3058
+
3059
+ `items()` is the page list: the current page, `siblings` pages either side of it, `boundaries` pages
3060
+ at each end, and **one `gap` entry per elided run**, keyed by the run's start so the list is stable.
3061
+ It is recomputed on every read, so a host may change `page` and `pageCount` without rebuilding;
3062
+ `current()` clamps the page it was given, and the page list and both ends are built from the
3063
+ clamped value. A gap entry has no handler and no role: the host renders its own marker, hidden from
3064
+ assistive technology (the count is already in the status line).
3065
+
3066
+ The ends are `previousProps` and `nextProps`, getters that resolve their page from the current one
3067
+ at read time, so a host that spreads the object onto an anchor once still steps past the first move;
3068
+ both carry `rel`, `aria-disabled` at a boundary, and an `href` from `hrefFor`. A page link's
3069
+ `aria-label` is `"Page <n>"`; `itemProps(page)` is meant for the `<li>` around the page link and
3070
+ around each end. `status()` is the one announcement, and the current page is marked on its item and
3071
+ on its link. The host's `hrefFor` is what makes the control work without JS; without it the props
3072
+ carry no `href` and a click only asks `onPageChange`. Target size is the skin's job: 24x24 CSS px at
3073
+ AA, 44x44 at AAA.
3074
+
3075
+ **Keyboard and limits.** The primitive binds no key and moves no focus: the props go on anchors or
3076
+ buttons, so Tab, Enter and Space are the platform's, and `disabled` only sets `aria-disabled` and
3077
+ drops the request (the host keeps its anchors out of the tab order if it must). `labels` is the
3078
+ whole translation surface, including the status sentence, which defaults to English. The current
3079
+ page is still rendered as a page link and still gets its `href`; a click on it is refused
3080
+ (`navigate` returns early when the page is current), so the mark changes and the page does not
3081
+ reload.
3082
+
3083
+ ### Headless: `createBreadcrumb` (`@cssints/headless`)
3084
+
3085
+ The APG breadcrumb: a trail of links in hierarchical order, the current page last and marked
3086
+ `aria-current="page"` on both the item and its link. The platform carries the navigation landmark,
3087
+ so the primitive only returns the trail's attributes. Its behaviour is
3088
+ `packages/apg-specs/specs/apg/breadcrumb.qnt`; `packages/headless/test/breadcrumb.browser.test.tsx`
3089
+ replays the react-aria scenarios (`packages/apg-specs/scenarios/apg/breadcrumb.react-aria.json`) and
3090
+ walks it differentially for a trail of four items and of two.
3091
+
3092
+ ```ts skip
3093
+ createBreadcrumb(options: {
3094
+ items: ReadonlyArray<{ label: string; href?: string | undefined }>;
3095
+ currentIndex?: number; // 1-based; defaults to the last item, 0 marks no item
3096
+ disabled?: boolean; // false: aria-disabled on every item and every press refused
3097
+ label?: string | undefined; // the landmark's name, "Breadcrumbs"
3098
+ labelledby?: string | undefined; // aria-labelledby, for a visible heading
3099
+ describedby?: string | undefined; // aria-describedby
3100
+ onPress?(index: number): void; // a request: the host navigates or cuts the trail
3101
+ }): {
3102
+ navProps: { "aria-label": string | undefined; "aria-labelledby"; "aria-describedby" };
3103
+ listProps: { role: "list" };
3104
+ itemProps(index: number): { role: "listitem"; "aria-current": "page" | undefined; "aria-disabled" };
3105
+ linkProps(index: number): { href: string; "aria-current": "page" | undefined; onClick };
3106
+ };
3107
+ ```
3108
+
3109
+ The trail's length is read once at creation, so the array is not a getter: a trail that grows (the
3110
+ user descends) or shrinks (an ancestor link cuts it back) is a new `createBreadcrumb` call, or the
3111
+ host renders fewer items itself. `currentIndex` is clamped into `0..items.length`, which gives the
3112
+ model's three states: the last item marks the trail's end, `0` marks nothing (the spec's "none"),
3113
+ and an index past the end clamps to the last item.
3114
+ `linkProps(index).href` falls back to `"#"` for an item with no `href`, because an anchor without
3115
+ one is not a link; the current item may be rendered as text instead (the pattern allows
3116
+ `aria-current` on the item alone), which is the fixture's `renderText` case. Pressing any link,
3117
+ current one included, calls `onPress(index)`; the host decides whether that navigates or cuts the
3118
+ trail. Nothing is prevented, so a native anchor follows its own `href` as well: `onPress` is a
3119
+ notification that can cut the trail, and the address the link points at stays the host's.
3120
+
3121
+ **Keyboard and limits.** None of its own: the entries are plain links, so Tab and Enter are the
3122
+ browser's, and there is no live region and no focus management (the react-aria scenario that focuses
3123
+ an ancestor at mount stays a negative test). `navProps` carries `aria-label` even beside a
3124
+ `labelledby`, so pass one of them per landmark; `aria-labelledby` wins in ARIA. **No overflow is
3125
+ built in**: a trail too long for the width is the host's collapse (the APG example truncates the
3126
+ middle behind a menu), and this primitive returns one item per input, in order. The other two
3127
+ divergences are the upstream's generic `aria-label` on every item and its expectation that an item
3128
+ wrapper carries no role: the primitive always renders a `listitem` around the link, and an item's
3129
+ label stays its text.
3130
+
3131
+ ### Headless: `createTreeview` (`@cssints/headless`)
3132
+
3133
+ The APG tree view: a `role="tree"` of `role="treeitem"` rows, one roving tab stop, a selection
3134
+ that is a request and an expansion that is always uncontrolled. Its behaviour is
3135
+ `packages/apg-specs/specs/apg/treeview.qnt`, checked against the real DOM by the differential walk
3136
+ in `packages/headless/test/treeview.browser.test.tsx` (single and multi, uncontrolled, controlled
3137
+ accepting and holding). The ported scenarios are
3138
+ `packages/apg-specs/scenarios/apg/treeview.apg.json`: 30 of the 35 replay, two stay recorded
3139
+ divergences (APG moves focus to the selected node when the tree receives focus; the spec and the
3140
+ primitive keep the roving node), and three skip because their steps carry a `modifiers` field the
3141
+ bridge cannot express (Shift+ArrowUp / Shift+ArrowDown, Ctrl+A), which the walk covers instead. A
3142
+ tree with columns and cell navigation is `createTreegrid`, below; this one is the single-column
3143
+ tree.
3144
+
3145
+ `nodes` is read once, at creation — ids in pre-order (document order), `parent` 0 for a root —
3146
+ so a list that arrives later needs a fresh primitive; the default is `TREEVIEW_NODES`, the spec's
3147
+ seven-node file tree. The host renders every node in document order and spreads `treeProps` on
3148
+ the tree and `itemProps(id)` on each item: `aria-level`, `aria-setsize`, `aria-posinset`,
3149
+ `aria-expanded` on parents only, `aria-selected` on every node, the roving `tabindex` and `hidden`
3150
+ for a node inside a closed parent. Render all of them and let `hidden` hide a subtree: the
3151
+ setsize / posinset of a visible row count its hidden siblings too. There is no expander button in
3152
+ the primitive: the host draws its chevron from `aria-expanded` and attaches no handler to it,
3153
+ because a click on a parent row already toggles it as well as selecting and focusing it.
3154
+
3155
+ Expansion is uncontrolled (`defaultExpanded` + `onExpandedChange`); the selection is `value` +
3156
+ `onValueChange` when the host owns it and `defaultValue` (or its alias `defaultSelected`) when it
3157
+ does not, and a request fires `onSelectionChange` once per newly selected node. The primitive never
3158
+ calls `focus()`: it publishes the roving node as `focus()`, `tabindex="0"` and `hasFocus`, and the
3159
+ host moves the DOM focus after an arrow (the fixture does it in an effect). `select`,
3160
+ `toggleSelected`, `expand`, `collapse` and `toggleExpanded` are the programmatic requests.
3161
+
3162
+ ```ts skip
3163
+ createTreeview(options?: {
3164
+ nodes?: ReadonlyArray<TreeviewNode>; // read once: { id, parent, label }, parent 0 = a root, document order
3165
+ multiSelectable?: boolean; // false: one selected node, and Space / Enter / a click replace it
3166
+ value?: ReadonlyArray<number> | undefined; // controlled: the selected node ids; undefined = uncontrolled
3167
+ defaultValue?: ReadonlyArray<number>; // the selection at mount
3168
+ defaultSelected?: ReadonlyArray<number>; // an alias for defaultValue
3169
+ defaultExpanded?: ReadonlyArray<number>; // the parent ids open at mount
3170
+ defaultFocus?: number; // the roving node at mount; the first node by default
3171
+ label?: string; // the tree's aria-label
3172
+ orientation?: "vertical" | "horizontal"; // "vertical": announced only, the keys are the vertical set
3173
+ onExpandedChange?(node: TreeviewNode): void; // once per node whose expansion changed
3174
+ onSelectionChange?(node: TreeviewNode): void; // once per newly selected node
3175
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request the host may ignore
3176
+ onActivate?(node: TreeviewNode): void; // Enter's default action
3177
+ }): {
3178
+ nodeIds(): ReadonlyArray<number>;
3179
+ expanded(): ReadonlyArray<number>;
3180
+ selected(): ReadonlyArray<number>;
3181
+ focus(): number; // the roving node
3182
+ hasFocus(): boolean;
3183
+ isExpanded(id: number): boolean;
3184
+ isSelected(id: number): boolean;
3185
+ isVisible(id: number): boolean; // not inside a closed parent
3186
+ isParent(id: number): boolean;
3187
+ expand(id: number): void;
3188
+ collapse(id: number): void;
3189
+ toggleExpanded(id: number): void;
3190
+ select(id: number): void; // replace the selection with this node
3191
+ toggleSelected(id: number): void;
3192
+ resetTypeahead(): void; // the timeout, modelled explicitly
3193
+ treeProps: {
3194
+ role: "tree";
3195
+ "aria-multiselectable": "true" | "false";
3196
+ "aria-label": string | undefined;
3197
+ "aria-orientation": "horizontal" | undefined; // vertical carries nothing
3198
+ onKeyDown;
3199
+ onFocusIn;
3200
+ onFocusOut;
3201
+ };
3202
+ itemProps(id: number): {
3203
+ role: "treeitem";
3204
+ "aria-level": number;
3205
+ "aria-setsize": number; // the siblings, hidden ones included
3206
+ "aria-posinset": number;
3207
+ "aria-expanded": "true" | "false" | undefined; // parents only
3208
+ "aria-selected": "true" | "false";
3209
+ tabindex: 0 | -1; // the roving node
3210
+ hidden: boolean; // inside a closed parent
3211
+ onClick;
3212
+ };
3213
+ };
3214
+ ```
3215
+
3216
+ **Keyboard.** ArrowDown / ArrowUp move over the visible nodes, a closed subtree skipped; ArrowRight
3217
+ opens a closed parent, and on an open parent it moves to the first child; ArrowLeft closes an open
3218
+ parent, else moves to the parent; Home / End are the first and last visible node; `*` opens every
3219
+ closed sibling of the focused node (the focused node included when it is a parent); Enter toggles a
3220
+ parent, selects it when single-select, and then fires `onActivate`; Space selects it (multi:
3221
+ toggles); Shift+ArrowUp / Shift+ArrowDown move and toggle the node they land on; Ctrl+A or Cmd+A
3222
+ (multi) selects every node and a second press clears, nodes inside collapsed subtrees included.
3223
+ Type-ahead matches the visible labels from the node after the roving one for one character and from
3224
+ the roving node for two; the buffer holds two characters and a third starts a new one, and its
3225
+ timeout is the host's `resetTypeahead()` call, so no timer and no `prefs` delay is involved.
3226
+
3227
+ **Limits.** There is no live region: the roving focus and the ARIA the host spreads are the
3228
+ announcement. Single-select selection does not follow the focus (Space, Enter and a click select;
3229
+ the arrows only move), and range selection (Shift+Space, Ctrl+Shift+Home / End), Ctrl+Arrow,
3230
+ Ctrl+click, `aria-current` navigation trees and `aria-disabled` nodes are not modelled. The model
3231
+ checks three levels; the primitive computes `aria-level` at any depth. `orientation` is announced
3232
+ only, and the ids pack into a 32-bit mask, so the widget takes at most 32 nodes.
3233
+
3234
+ ### Headless: `createTreegrid` (`@cssints/headless`)
3235
+
3236
+ The APG treegrid: rows of cells with a tree in the first column, the example's three navigation
3237
+ models and row selection. Its behaviour is `packages/apg-specs/specs/apg/treegrid.qnt` — the Email
3238
+ Inbox tree, `A > {A.1, A.2 > {A.2.1}}` and `B > {B.1, B.2}` — checked against the real DOM by the
3239
+ differential walk in `packages/headless/test/treegrid.browser.test.tsx` over seven configurations:
3240
+ each navigation model, single and multi selection, controlled accepting and holding. The ported
3241
+ scenarios (`scenarios/apg/treegrid.apg.json` and `scenarios/apg/treegrid.react-aria.json`, 86
3242
+ together) are replayed but every one skips: their configs carry a dynamic `tree` / `columns` /
3243
+ `expanded` the bridge cannot express, because the model is the spec's fixed seven-row tree. The
3244
+ walk, not the ported scenarios, is the oracle, and it holds the three navigation models rather
3245
+ than the tree a host passes.
3246
+
3247
+ `parents` is the tree and its only tree data: the parent of each row in document order, `-1` for a
3248
+ root and otherwise the index of that row's parent. **It is read once, at creation**, and the
3249
+ default is the spec's seven rows — so the primitive is not tied to the spec tree, but nothing
3250
+ rebuilds a tree that changes underneath it. `cols` is the column count (3 by default; the spec
3251
+ fixes three). Expansion is uncontrolled — `defaultExpanded` at mount, then `toggleRow` /
3252
+ `setExpanded`, with no expansion callback — while the selection is `value` + `onValueChange` or
3253
+ `defaultValue`.
3254
+
3255
+ The host renders `gridProps` on the treegrid, `rowProps(row)` on every row, `cellProps(row, col)` on
3256
+ every cell and `expanderProps(row)` on a button inside the first cell of a row where
3257
+ `hasChildren(row)` is true. The primitive owns the roving tab stop (exactly one row or cell is
3258
+ tabbable, remembered while the focus is outside), `aria-level` / `aria-setsize` / `aria-posinset`,
3259
+ `aria-expanded` on parent rows, `aria-selected`, the ids (`<id>-row-<row>` and
3260
+ `<id>-cell-<row>-<col>`) and the key handling. `focusTargetPart()` names the tab stop as `row:<row>`
3261
+ or `cell:<row>,<col>`, and `treegridFocusTargetPart(grid)` is the same value for a host that holds
3262
+ the primitive in a signal; the host focuses that element after a key, as the fixture does.
3263
+ `gridProps` carries no accessible name: put `aria-label` or `aria-labelledby` on the element
3264
+ yourself.
3265
+
3266
+ ```ts skip
3267
+ createTreegrid(options?: {
3268
+ nav?: "rowsFirst" | "cellsFirst" | "cellsOnly"; // "rowsFirst": Tab lands on the row
3269
+ selection?: "single" | "multi"; // "single": the selection follows the focus
3270
+ cols?: number; // 3; the spec fixes three
3271
+ parents?: ReadonlyArray<number>; // read once: -1 is a root, else the index of the row's parent
3272
+ defaultExpanded?: ReadonlyArray<number>; // the rows open at mount, by index
3273
+ value?: ReadonlyArray<number> | undefined; // controlled: the selected rows; undefined = uncontrolled
3274
+ defaultValue?: ReadonlyArray<number>; // the selection at mount
3275
+ id?: string; // base of <id>-row-<row> and <id>-cell-<row>-<col>
3276
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request the host may ignore
3277
+ }): {
3278
+ rows(): number;
3279
+ cols(): number;
3280
+ nav(): "rowsFirst" | "cellsFirst" | "cellsOnly";
3281
+ selection(): "single" | "multi";
3282
+ focusedRow(): number;
3283
+ focusedCol(): number;
3284
+ kind(): "row" | "cell"; // which of the two holds the tab stop
3285
+ inGrid(): boolean;
3286
+ visible(row: number): boolean;
3287
+ hasChildren(row: number): boolean;
3288
+ isExpanded(row: number): boolean;
3289
+ isSelected(row: number): boolean;
3290
+ focusTargetPart(): string | null; // "row:2" / "cell:2,1"; null while the focus is out
3291
+ gridProps: {
3292
+ role: "treegrid";
3293
+ "aria-multiselectable": "true" | undefined; // multi only
3294
+ onFocusOut;
3295
+ };
3296
+ rowProps(row: number): {
3297
+ role: "row";
3298
+ id: string;
3299
+ "aria-level": number;
3300
+ "aria-setsize": number;
3301
+ "aria-posinset": number;
3302
+ "aria-expanded": "true" | "false" | undefined; // parent rows only
3303
+ "aria-selected": "true" | "false" | undefined; // undefined on unselected rows when single
3304
+ tabindex: 0 | -1;
3305
+ onFocus;
3306
+ onKeyDown;
3307
+ };
3308
+ cellProps(row: number, col: number): {
3309
+ role: "gridcell";
3310
+ id: string;
3311
+ tabindex: 0 | -1;
3312
+ onClick;
3313
+ onFocus;
3314
+ onKeyDown;
3315
+ };
3316
+ expanderProps(row: number): {
3317
+ role: "button";
3318
+ "aria-label": "Expand" | "Collapse";
3319
+ "aria-expanded": "true" | "false";
3320
+ onMouseDown; // keeps the focus where it is
3321
+ onClick; // toggles the row, and does not reach the cell
3322
+ };
3323
+ focus(): void; // moves the tab stop into the grid; the host focuses it
3324
+ blur(): void;
3325
+ clickCell(row: number, col: number): void;
3326
+ toggleRow(row: number): void;
3327
+ configureNav(nav: "rowsFirst" | "cellsFirst" | "cellsOnly"): void; // resets expansion and focus
3328
+ configureSelection(selection: "single" | "multi"): void;
3329
+ setExpanded(row: number, expanded: boolean): void;
3330
+ };
3331
+ ```
3332
+
3333
+ - **`nav`** picks the tab stop and the Left / Right path. `"rowsFirst"` (the default) puts Tab on
3334
+ the row: Right enters the row's first cell, then moves right, and Left on the first cell returns
3335
+ to the row. `"cellsFirst"` puts Tab on the first cell, and Left from it still reaches the row.
3336
+ `"cellsOnly"` never focuses a row: Left stays among the cells, and Enter on the first cell of a
3337
+ parent toggles it.
3338
+ - **Movement** is the same in all three. Up / Down move over the visible rows, staying in the
3339
+ column in cell mode; Home / End are the first / last cell in cell mode and the first / last
3340
+ visible row in row mode; Ctrl+Home / Ctrl+End are the first / last visible row, keeping the
3341
+ column. Right on a collapsed parent expands it and keeps the row; on an expanded row it
3342
+ enters the row's first cell, and the next press moves right. Left on an expanded parent
3343
+ collapses it and keeps the row.
3344
+ - **Selection** is per row. `"single"` follows the focus, and `aria-selected` is on the selected
3345
+ row only (absent elsewhere); a cell click selects its row. `"multi"` sets
3346
+ `aria-multiselectable`, writes `aria-selected` true or false on every row, and changes only on
3347
+ Shift+Space (toggle the focused row), Ctrl+A (every row, no second press to clear) and
3348
+ Shift+ArrowUp / Shift+ArrowDown in row mode (move and add the neighbouring visible row to the
3349
+ selection); a click or a plain arrow does not select.
3350
+ - **`configureNav` / `configureSelection`** are a host's switches between models: they reset the
3351
+ expansion, the tab stop and an uncontrolled selection to the new model's start, and leave a
3352
+ controlled selection alone.
3353
+
3354
+ **Keyboard and limits.** Enter acts in `cellsOnly` only, and the other two models
3355
+ `preventDefault` it without doing anything, so a row's default action is the host's; a click is a
3356
+ cell click, not a row click. The expander takes no focus on `mousedown`, stops its click from
3357
+ reaching the cell, and moves the focus to the toggled row when the collapse would hide the focused
3358
+ row. No live region: the ARIA and the moved focus are the announcement. Not modelled: cell and
3359
+ column selection, editing or Tab inside a cell, PageUp / PageDown, `aria-rowcount` / `-colcount` /
3360
+ `-index`, disabled rows, type-ahead and row header cells (every cell is a `gridcell`). The model
3361
+ checks the fixed seven rows and three columns, the parent map is fixed at creation, and row ids
3362
+ pack into a 32-bit mask, so the grid takes at most 32 rows.
3363
+
3364
+ ### Headless: `createWindowSplitter` (`@cssints/headless`)
3365
+
3366
+ The APG window splitter: one focusable `role="separator"` between a primary and a secondary pane,
3367
+ whose value is the primary pane's size. Its behaviour is
3368
+ `packages/apg-specs/specs/apg/windowsplitter.qnt` (the bounds 0..6, a step of 2, a start of 4 and a
3369
+ primary pane that collapses at 0), checked against the real DOM by the differential walk in
3370
+ `packages/headless/test/windowsplitter.browser.test.tsx` (vertical, horizontal, fixed, controlled
3371
+ accepting and holding) and by the 25 ported scenarios in
3372
+ `packages/apg-specs/scenarios/apg/windowsplitter.apg.json`. Two of those stay recorded
3373
+ divergences: they write `"*"` for any IDREF or accessible name, which the replay engine's literal
3374
+ comparison cannot match (`aria-label`, `aria-controls`), and the rest replay.
3375
+
3376
+ **This is the single-separator pattern, not the N-pane group.** `createResizablePanels` is the
3377
+ group of N panes and N-1 separators: the same splitter keyboard, with the layout and its bounds in
3378
+ `packages/apg-specs/specs/patterns/resizable-panels.qnt`, plus the pointer drag this primitive does
3379
+ not have. A two-pane split belongs here; a layout with more than one separator belongs there.
3380
+
3381
+ The value is one number in the host's own unit: `min`, `max` and `step` are in it, `aria-valuenow`
3382
+ prints it as it is, and the primitive knows nothing of pixels, percentages or the layout. There is
3383
+ no `aria-valuetext`, so a percentage reads as its bare number. `collapsed` means "collapsed by
3384
+ Enter", not "the value is `min`": a pane at `min` that was never collapsed is not collapsed. While
3385
+ controlled, `collapsed` is derived from the host's value plus the last collapse request, so call
3386
+ `freezeCollapse()` before the host writes `value` itself (a preset, a saved layout), or a write
3387
+ that happens to land on `min` reads as a collapse. `onCollapsedChange` reports the derived state
3388
+ when it changes.
3389
+
3390
+ The host spreads `separatorProps` on the focusable bar and `primaryProps` / `secondaryProps` on the
3391
+ two panes. The panes are `tabindex="0"`, not `-1`, because F6's cycle lands on them, and their
3392
+ `onFocus` / `onBlur` mirror the DOM focus into `focusPart`; the primitive never calls `focus()`
3393
+ itself, so the host follows `focusPart` with its own effect to make F6 move the focus (the fixture
3394
+ does). `separatorProps["aria-controls"]` names the primary pane's id, and the host must put that id
3395
+ on the primary element, because `primaryProps` carries none: spread the separator first and read
3396
+ it back, or pass `primaryId` and use the same string. `label` or `labelledby` names the separator,
3397
+ and APG wants the primary pane's own label, so `labelledby` is the better one.
3398
+
3399
+ Options are read lazily, so pass the object itself: `orientation`, `fixed`, `min`, `max`, `step`
3400
+ and `value` may be getters, and a host that flips `fixed` or `orientation` on a mounted splitter
3401
+ sees the new bounds and keys at once (the fixture switches both that way).
3402
+
3403
+ ```ts skip
3404
+ createWindowSplitter(options?: {
3405
+ orientation?: "vertical" | "horizontal"; // "vertical": a left/right split; horizontal: top/bottom
3406
+ fixed?: boolean; // false: arrows and Home/End move; true: only Enter's two positions
3407
+ value?: number | undefined; // controlled: the primary pane's size; undefined = uncontrolled
3408
+ defaultValue?: number; // 4, clamped into the range
3409
+ min?: number; // 0
3410
+ max?: number; // 6
3411
+ step?: number; // 2: one arrow press
3412
+ id?: string; // base of the separator and pane ids
3413
+ primaryId?: string; // the id aria-controls names; <id>-primary by default
3414
+ label?: string; // the separator's aria-label
3415
+ labelledby?: string; // the id of the primary pane's visible label; it wins the name
3416
+ onValueChange?(value: number): void; // a request the host may ignore
3417
+ onCollapsedChange?(collapsed: boolean): void; // the derived state, when it changes
3418
+ }): {
3419
+ value(): number;
3420
+ collapsed(): boolean;
3421
+ focusPart(): "none" | "splitter" | "primary" | "secondary";
3422
+ focusSeparator(): void; // sets focusPart only
3423
+ setFocusPart(part: "none" | "splitter" | "primary" | "secondary"): void;
3424
+ keyDown(key: string): void;
3425
+ freezeCollapse(): void; // call before the host writes `value` itself
3426
+ collapse(): void; // only while the separator has focus
3427
+ expand(): void; // only while the separator has focus
3428
+ separatorProps: {
3429
+ id: string;
3430
+ role: "separator";
3431
+ "aria-orientation": "vertical" | "horizontal";
3432
+ "aria-valuenow": number;
3433
+ "aria-valuemin": number;
3434
+ "aria-valuemax": number;
3435
+ "aria-controls": string; // the primary pane's id: set it on the element
3436
+ "aria-label": string | undefined;
3437
+ "aria-labelledby": string | undefined;
3438
+ tabindex: 0;
3439
+ onKeyDown;
3440
+ onFocus;
3441
+ onBlur;
3442
+ };
3443
+ primaryProps: { tabindex: 0; onKeyDown; onFocus; onBlur };
3444
+ secondaryProps: { tabindex: 0; onKeyDown; onFocus; onBlur };
3445
+ };
3446
+ ```
3447
+
3448
+ **Keyboard.** On the bar, ArrowLeft / ArrowRight move a vertical splitter and ArrowUp / ArrowDown
3449
+ a horizontal one, by `step` and clamped at the bounds; Home / End jump to the bounds; Enter
3450
+ collapses the primary pane to `min` and restores the size it had before. A fixed splitter ignores
3451
+ the arrows and Home / End and keeps Enter's two positions; a collapsed one ignores everything but
3452
+ Enter. While a pane has focus those keys do nothing (their presses are `preventDefault`ed), and F6
3453
+ is the only one that acts: it cycles the focus splitter → secondary → primary → splitter. The
3454
+ primitive handles the keys a host's own listener passes to `keyDown`, and nothing else.
3455
+
3456
+ **Limits.** One separator and a two-pane split, no drag (a dragged separator is the host's, or
3457
+ `createResizablePanels`), no nested splits, no `aria-valuetext` and no live region. The spec fixes
3458
+ its bounds and step and does not model a `min` above zero; the options are the primitive's and the
3459
+ walk covers the spec's constants.
3460
+
3461
+ ### Headless: `createResizablePanels` (`@cssints/headless`)
3462
+
3463
+ A group of N panes and N-1 separators: react-resizable-panels' percentage layout on the APG
3464
+ window splitter's keyboard. It is **not `createWindowSplitter`**: that primitive is one separator,
3465
+ the APG pattern only, with no drag, no bounds and no collapse; this one owns the whole layout — a
3466
+ drag with pointer capture, per-pane `min` / `max`, a collapsible pane, a remembered size and
3467
+ persistence. It is not an APG pattern, so its spec lives under `specs/patterns/`:
3468
+ `packages/apg-specs/specs/patterns/resizable-panels.qnt` is the three-pane instance (10% steps,
3469
+ 10/80 bounds, pane 0 collapsible at 0) and the primitive takes N panes and any bounds.
3470
+ `packages/headless/test/resizable-panels.browser.test.tsx` replays its 11 scenarios
3471
+ (`scenarios/patterns/resizable-panels.spec.json`) and walks the generated model in four
3472
+ configurations (uncontrolled, horizontal, a controlled host that accepts, one that holds), then
3473
+ pins the drag, the remembered size, the defaults and persistence, which the model cannot hold. The
3474
+ host renders the group, each pane and a separator between neighbours, and spreads the props: the
3475
+ group is a flex or grid box, and the pane's size is `sizes()[index]`.
3476
+
3477
+ Sizes are percentages of the group and always sum to 100. A move (an arrow, Home/End or a drag)
3478
+ changes only the two panes around its separator, clamped by both bounds, and never drags a pane
3479
+ below its expanded `min` (only Enter or a host write collapses one). Collapsing hands the space to
3480
+ the nearest pane that can take it and cascades outward, right then left; restoring takes it back in
3481
+ the same order from the size the pane left. `defaultSize` is a pane's named start: it is kept, and
3482
+ the panes that name none share what is left equally (changed 2026-11-02); `defaultSizes` states the
3483
+ whole start instead and wins over the per-pane names, and a layout stored under `storageKey` wins
3484
+ over both. Every start is clamped and its residual pushed into the panes in order, so a host's bad
3485
+ sum still lands on 100.
3486
+
3487
+ Uncontrolled by default; `sizes` + `onSizesChange` make it controlled, and the request is the whole
3488
+ layout array. The drag is a snapshot plus a delta: pointerdown remembers the layout, every move
3489
+ re-solves from it, so dragging back past a clamp recovers exactly. A host write of `sizes` ends a
3490
+ drag; the echo of a request the host accepted does not count as one. The newest pointerdown takes
3491
+ the drag over, the keyboard is inert while a drag runs, and only the primary button starts one.
3492
+
3493
+ ```ts skip
3494
+ createResizablePanels(options: {
3495
+ panes: ReadonlyArray<{
3496
+ id?: string; // the pane's id; the separator's aria-controls names it
3497
+ min?: number; // smallest expanded size, percent (10)
3498
+ max?: number; // largest size, percent (80)
3499
+ defaultSize?: number; // the named start; panes that name none share what is left
3500
+ collapsible?: boolean; // Enter collapses to collapsedSize and restores the remembered size
3501
+ collapsedSize?: number; // 0
3502
+ label?: string; // the separator announces "Resize <label>"
3503
+ }>;
3504
+ orientation?: "vertical" | "horizontal"; // "vertical": a vertical bar, panes side by side
3505
+ sizes?: ReadonlyArray<number> | undefined; // controlled; undefined = uncontrolled
3506
+ defaultSizes?: ReadonlyArray<number>; // the uncontrolled start as a whole
3507
+ step?: number; // keyboard step, percent (10)
3508
+ id?: string; // base of the generated ids; generated when absent
3509
+ label?: string | undefined; // the group's aria-label
3510
+ storageKey?: string | undefined; // persist the layout in localStorage
3511
+ onSizesChange?(sizes: ReadonlyArray<number>): void; // a request the host may ignore
3512
+ }): {
3513
+ sizes(): ReadonlyArray<number>; // the committed layout, percent; sums to 100
3514
+ collapsed(index: number): boolean;
3515
+ resizing(): number | null; // the separator being dragged
3516
+ focusSeparator(): number | null;
3517
+ setFocusSeparator(index: number | null): void;
3518
+ keyDown(index: number, key: string): void; // the separator's keys, without an event
3519
+ groupProps: { role: "group"; "aria-label": string | undefined };
3520
+ paneProps(index: number): { id: string };
3521
+ separatorProps(index: number): {
3522
+ id;
3523
+ role: "separator";
3524
+ "aria-orientation": "vertical" | "horizontal";
3525
+ "aria-valuenow": number; // the committed size of the pane on its left
3526
+ "aria-valuemin": number; // that pane's min
3527
+ "aria-valuemax": number; // that pane's max
3528
+ "aria-controls": string; // the pane on its left
3529
+ "aria-label": string; // "Resize <label>", else "Resize"
3530
+ tabindex: 0;
3531
+ onKeyDown;
3532
+ onFocus;
3533
+ onBlur;
3534
+ onPointerDown;
3535
+ onPointerMove;
3536
+ onPointerUp;
3537
+ onPointerCancel;
3538
+ };
3539
+ };
3540
+ ```
3541
+
3542
+ - **The separator is the widget.** It is the only tab stop the primitive adds: `role=separator`,
3543
+ `aria-orientation` the group's, `aria-valuenow` the committed size of the pane on its left,
3544
+ `aria-valuemin` / `-max` that pane's `min` and `max` (10 and 80 by default), `aria-controls` its
3545
+ id, and `aria-label` "Resize <label>" (else "Resize"). A collapsible pane's committed
3546
+ `collapsedSize` (0) therefore reads below its announced minimum.
3547
+ - **Keyboard.** Left/Right move a vertical bar's pair (the default: panes side by side), Up/Down a
3548
+ horizontal one's, Home/End jump the pair to its bounds, and Enter collapses or restores; all six
3549
+ `preventDefault`, and every other key passes. Tab walks the bars in DOM order — there is no
3550
+ arrow navigation between them and no F6. A bar beside a collapsed pane is inert except Enter.
3551
+ - **Nested groups.** The drag measures `event.currentTarget.parentElement`'s box at pointerdown, so
3552
+ an inner group's bar is a share of the inner group, not of the workspace (2026-11-02), and an
3553
+ outer bar resizing the inner group does not re-solve its percentages. Each group keeps its own
3554
+ ids, `aria-controls`, orientation and keyboard.
3555
+ - **Persistence is the host's.** `storageKey` reads and writes `localStorage` best effort: a stored
3556
+ array of another length, or one with a non-number, is ignored, and a blocked or full store is
3557
+ silent. A pane mounted already collapsed restores to its own `min`, not to 0.
3558
+ - **Limits.** Percentages only (no pixel bounds), a fixed pane list, and a persisted layout with no
3559
+ version or namespace: the key belongs to the host's storage. F6 is left out; with three panes a
3560
+ Tab is enough.
3561
+
3562
+ ### Headless: `createCarousel` (`@cssints/headless`)
3563
+
3564
+ A carousel after the APG pattern: a rotator, previous and next controls, an optional tabbed picker,
3565
+ a slide that is a `group` (or `tabpanel`) and a pause policy. **It is not a scroll-snap strip**:
3566
+ every slide but the current one gets `hidden`, so the DOM holds one visible slide and no scroll
3567
+ container, and the primitive owns the slide semantics, the pause on hover and focus, and the live
3568
+ region. It owns no timer either: `tick()` is one rotation interval and the host calls it from its
3569
+ own interval (and passes `reducedMotion`, from its own `matchMedia`). Its behaviour is
3570
+ `packages/apg-specs/specs/apg/carousel.qnt`; `packages/headless/test/carousel.browser.test.tsx`
3571
+ replays the 51 scenarios of `scenarios/apg/carousel.apg.json` in Chromium and walks the model in
3572
+ seven configurations (plain, tabbed, auto-rotation off, started paused, hovering, controlled
3573
+ accepting, controlled pinned).
3574
+
3575
+ The host renders the wrapper with `carouselProps`, the rotation control (hidden when `autoRotate` is
3576
+ false), previous and next, a slide per item with `slideProps`, and for `tabbed` a `tablist` with one
3577
+ tab per slide. The host puts the rotation control first in the tab order, as the pattern asks. It is
3578
+ `hidden` rather than disabled when `autoRotate` is false, so the option removes it. `items` is read
3579
+ once (6, at least 1); `value` + `onValueChange` own the shown slide, `defaultIndex` starts it, and
3580
+ `next`, `prev`, `selectSlide`, `toggleRotation` and `tick` are requests too — `next` and `prev`
3581
+ wrap.
3582
+
3583
+ `autoRotate` (true) is the feature, `playing` (true) is the start and `reducedMotion` (false) starts
3584
+ it paused; `rotating()` is true when the feature is on, the state wants it, and either the user
3585
+ started it or neither focus nor hover is inside. Focus entering the carousel stops the rotation
3586
+ under `stickyFocusPause` (true) and the rotation control starts it again; a pointer press on a
3587
+ control suppresses the focus it would give, so a click does not count as focus entering. Hover is a
3588
+ real `mousemove` (`mouseenter` would mark an element that appeared under a stationary pointer), and
3589
+ it pauses a rotation the user did not start. `onRotatingChange` reports the user's intent, not the
3590
+ momentary pause. `focused()` names the part holding focus (`rotation`, `prev`, `next`, `tab:<i>`,
3591
+ `slide:<i>`), which a host syncs to the DOM as the test fixture does.
3592
+
3593
+ ```ts skip
3594
+ createCarousel(options?: {
3595
+ items?: number; // slides (6, at least 1)
3596
+ value?: number | undefined; // controlled shown slide; undefined = uncontrolled
3597
+ defaultIndex?: number; // the uncontrolled start (0)
3598
+ autoRotate?: boolean; // true; false removes (hides) the rotation control
3599
+ playing?: boolean; // true: rotation starts on
3600
+ reducedMotion?: boolean; // false: the host's prefers-reduced-motion starts it paused
3601
+ tabbed?: boolean; // tablist / tab / tabpanel instead of plain groups
3602
+ stickyFocusPause?: boolean; // true: focus entering stops rotation until Start
3603
+ hovering?: boolean; // the pointer starts over the carousel
3604
+ role?: "region" | "group"; // "group"
3605
+ labelledBy?: string | undefined; // the carousel's accessible name
3606
+ id?: string; // base of the generated ids
3607
+ onValueChange?(index: number): void; // a request the host may ignore
3608
+ onRotatingChange?(rotating: boolean): void; // the user's intent, not a hover/focus pause
3609
+ }): {
3610
+ items: number;
3611
+ current(): number;
3612
+ rotating(): boolean;
3613
+ focused(): "rotation" | "prev" | "next" | "tab:1" | "slide:0" | null; // the part holding focus
3614
+ next(): void; // wraps; a request
3615
+ prev(): void;
3616
+ selectSlide(index: number): void; // out of range is a no-op
3617
+ toggleRotation(): void;
3618
+ tick(): void; // one interval: next() while rotating
3619
+ carouselProps: {
3620
+ role: "region" | "group";
3621
+ "aria-roledescription": "carousel";
3622
+ "aria-live": "off" | "polite"; // off while rotating
3623
+ "aria-labelledby": string | undefined;
3624
+ onMouseMove;
3625
+ onMouseLeave;
3626
+ };
3627
+ rotationProps: {
3628
+ type: "button";
3629
+ id;
3630
+ "aria-label": string; // "Stop automatic slide show" / "Start automatic slide show"
3631
+ "aria-controls": string;
3632
+ hidden: boolean; // true when autoRotate is false
3633
+ onMouseDown;
3634
+ onClick;
3635
+ onFocus;
3636
+ onBlur;
3637
+ };
3638
+ prevProps: { type: "button"; id; "aria-controls": string; onMouseDown; onClick; onFocus; onBlur };
3639
+ nextProps: { type: "button"; id; "aria-controls": string; onMouseDown; onClick; onFocus; onBlur };
3640
+ slideProps(index: number): {
3641
+ id;
3642
+ role: "group" | "tabpanel";
3643
+ "aria-roledescription": "slide";
3644
+ "aria-label": string; // "1 of 6"
3645
+ tabindex: 0 | -1; // the current slide only
3646
+ hidden: boolean; // every other slide
3647
+ onFocus;
3648
+ onBlur;
3649
+ };
3650
+ tablistProps: { role: "tablist" };
3651
+ tabProps(index: number): {
3652
+ id;
3653
+ role: "tab";
3654
+ "aria-label": string; // "Slide 1"
3655
+ "aria-selected": "true" | "false";
3656
+ "aria-controls": string;
3657
+ tabindex: -1 | undefined; // undefined on the current tab (a native button)
3658
+ onClick;
3659
+ onFocus;
3660
+ onBlur;
3661
+ onKeyDown;
3662
+ };
3663
+ };
3664
+ ```
3665
+
3666
+ - **Keyboard.** Rotation, previous and next are native buttons, so Enter and Space are the
3667
+ browser's. A tab's ArrowRight / ArrowDown, ArrowLeft / ArrowUp, Home and End select the next,
3668
+ previous, first and last slide, wrapping, with automatic activation; a slide has no keys. Exactly
3669
+ one slide is in the tab order (`tabindex="0"` on the current, `-1` on the rest), and every
3670
+ non-current one is hidden.
3671
+ - **Live region.** The carousel wrapper is the region: `aria-live="off"` while rotating and
3672
+ `"polite"` otherwise. There is no second status element, and while it is paused a slide change is
3673
+ announced.
3674
+ - **ARIA.** `role` is `group` by default; pass `region` for a named landmark. A slide is a `group`
3675
+ (a `tabpanel` when `tabbed`) with `aria-roledescription="slide"` and "N of M" as its name.
3676
+ The controls' `aria-controls` names `<base>-slides`, so a host that wants the reference to
3677
+ resolve passes `id` and gives its slides wrapper that id. The rotation control's label flips
3678
+ between "Stop automatic slide show" and "Start automatic slide show", with no `aria-pressed`.
3679
+ - **Divergences.** Two ported scenarios use the `*` (any non-empty IDREF) convention, which the
3680
+ replay engine does not implement, and one asserts the raw `hidden=""` that the DOM driver reports
3681
+ as `"true"`; those three stay negative tests. A fourth assumes focus does not pause rotation,
3682
+ while the pattern text says it does, so the first activation restarts and the second stops: that
3683
+ expectation stays negative too, because the pattern text wins (the README's divergence policy).
3684
+ - **Limits.** The host owns the interval, the media query, the slide transition and the slide
3685
+ content. The picker activates automatically (there is no manual mode), and the grouped variant
3686
+ with `aria-disabled` picker buttons is not modelled.
3687
+
3688
+ ### Headless: `createFeed` (`@cssints/headless`)
3689
+
3690
+ The APG feed: a stream of articles that loads more when the reading cursor reaches the last loaded
3691
+ one, announces each article's place with `aria-posinset` / `aria-setsize`, and brackets a load with
3692
+ `aria-busy`. Its behaviour is `packages/apg-specs/specs/apg/feed.qnt`;
3693
+ `packages/headless/test/feed.browser.test.tsx` replays the 29 scenarios of
3694
+ `scenarios/apg/feed.apg.json` in Chromium and walks the model in seven configurations (1, 3 and 6
3695
+ loaded; both `aria-setsize` modes with the total known and unknown; a controlled host accepting or
3696
+ holding).
3697
+
3698
+ The host renders a focusable element before the feed and one after it (`beforeProps`,
3699
+ `afterProps`), the container (`feedProps`), and one article slot per `items` with
3700
+ `articleProps(posinset)`; slots past `loaded()` are hidden. `items` is the number of slots the host
3701
+ renders, not the loaded count: the controlled value is `value`, the uncontrolled start
3702
+ `defaultValue` (1), and `loaded()` is clamped to 1..`items`. `onValueChange` comes from
3703
+ `loadFinish`, so the request is the new count. The primitive never moves DOM focus: it records the
3704
+ cursor from each article's `onFocus`, and the host focuses the article (or sentinel) that `active()`
3705
+ names, as the test fixture does. The cursor starts on the `"before"` zone; an article's
3706
+ `aria-labelledby` / `aria-describedby` name the host's title and content elements
3707
+ (`<base>-article-<n>-title` / `-content`), which the host must create.
3708
+
3709
+ Load lifecycle: the primitive fetches nothing. Focus reaching the last loaded article, and
3710
+ `pageDown` onto it, call `loadStart`, which moves idle to loading only while more articles exist
3711
+ (`totalArticles` unknown or `loaded < total`), and `busy()` becomes `aria-busy="true"`. The skin
3712
+ watches that (or its own trigger), calls `loadStart` itself for a scroll sentinel, runs its loader,
3713
+ then reports back: `loadFinish(count)` requests `loaded + count`, clamped by the total, and returns
3714
+ to idle; `loadFail` returns to idle with the count unchanged. `loadFinish` is ignored when no load
3715
+ is in flight, and a controlled host that holds its `value` keeps the count while the busy state
3716
+ still ends. A host write of `value` also settles an in-flight load and pulls the cursor back into
3717
+ the new range.
3718
+
3719
+ `aria-setsize` depends on `sizeMode`: `"loaded"` (the default) announces the loaded count,
3720
+ `"total"` the server total when `totalKnown` is true, else `-1` (ARIA's undetermined);
3721
+ `totalArticles: null` means the total is not known. `setSizeMode` and `setTotalKnown` change the
3722
+ mode at run time, and `aria-posinset` is the slot's 1-based position.
3723
+
3724
+ ```ts skip
3725
+ createFeed(options?: {
3726
+ items?: number; // article slots the host renders (6, at least 1)
3727
+ totalArticles?: number | null; // the server total; null = undetermined
3728
+ value?: number | undefined; // controlled loaded count; undefined = uncontrolled
3729
+ defaultValue?: number; // uncontrolled start (1, at least 1 and at most items)
3730
+ sizeMode?: "loaded" | "total"; // "loaded": aria-setsize is the loaded count
3731
+ totalKnown?: boolean; // false: total mode announces -1 until known
3732
+ labelledBy?: string | undefined; // the feed's accessible name
3733
+ id?: string; // base of the generated ids
3734
+ onValueChange?(loaded: number): void; // a request the host may ignore
3735
+ }): {
3736
+ items: number;
3737
+ loaded(): number; // the committed count, 1..items
3738
+ busy(): boolean; // a load is in flight (aria-busy)
3739
+ active(): number | "before" | "after"; // the reading cursor: a 1-based posinset or a zone
3740
+ focusArticle(posinset: number): void; // a loaded, rendered article only
3741
+ pageDown(): void;
3742
+ pageUp(): void;
3743
+ ctrlEnd(): void;
3744
+ ctrlHome(): void;
3745
+ loadStart(): void; // idle -> loading, only when more articles exist
3746
+ loadFinish(count: number): void; // a batch arrived: a request to grow the count
3747
+ loadFail(): void; // end the busy state, keep the count
3748
+ setSizeMode(mode: "loaded" | "total"): void;
3749
+ setTotalKnown(known: boolean): void;
3750
+ feedProps: {
3751
+ id;
3752
+ role: "feed";
3753
+ "aria-busy": "true" | undefined;
3754
+ "aria-labelledby": string | undefined;
3755
+ };
3756
+ articleProps(posinset: number): {
3757
+ id;
3758
+ role: "article";
3759
+ "aria-posinset": number;
3760
+ "aria-setsize": number; // the loaded count, the total, or -1
3761
+ "aria-labelledby": string; // <base>-article-<n>-title
3762
+ "aria-describedby": string; // <base>-article-<n>-content
3763
+ tabindex: 0;
3764
+ hidden: boolean; // every slot past the loaded count
3765
+ onFocus;
3766
+ onKeyDown;
3767
+ };
3768
+ beforeProps: { id; tabindex: 0; onFocus }; // an element just before the feed
3769
+ afterProps: { id; tabindex: 0; onFocus }; // an element just after it
3770
+ };
3771
+ ```
3772
+
3773
+ - **Keyboard.** Page Down and Page Up move the reading cursor to the next and previous article and
3774
+ stop at the last loaded one (Page Down there asks for more); Control+End and Control+Home leave
3775
+ the feed for `afterProps` / `beforeProps`, not for the first article. Tab enters the feed on any
3776
+ loaded article and leaves it from any, and the arrows do nothing.
3777
+ - **Divergence.** The APG example focuses the first article on Control+Home; the pattern text says
3778
+ "the first focusable element before the feed", so `beforeProps` gets it and the ported scenario
3779
+ stays a negative test. Two `*`-IDREF scenarios are negative for the replay engine's sake.
3780
+ - **Limits.** `items` is a slot count, so the host decides how many article elements exist; the
3781
+ primitive observes no scroll and owns no focus. A batch only grows the count (a removal is a host
3782
+ write of `value`), and a load is one batch, not a queue.
3783
+
3784
+ ### Headless: `createPlayer` (`@cssints/headless`)
3785
+
3786
+ The media player as a composition: there is no APG player pattern, so the control surface restates
3787
+ the APG Slider (the media seek slider), Toolbar, Button and Menu Button contracts, and the playback
3788
+ machine is the HTML `HTMLMediaElement` state and event contract reduced to the stages a UI can show.
3789
+ `packages/apg-specs/specs/apg/player.qnt` is that composition (the generator imports pure defs
3790
+ only, so the slider, toolbar and button specs are restated, not imported); there is no ported
3791
+ scenario file, and `packages/headless/test/player.browser.test.tsx` is the oracle: four differential
3792
+ walks (uncontrolled at 90 s and 1 s, a controlled host accepting and holding) plus real-DOM checks
3793
+ of the controlled preferences, the `m:ss` value text, Space on the surface, the seek keys and the
3794
+ pressed states. The contract behind it is `packages/headless/docs/player-aria-contract.md`.
3795
+
3796
+ The engine is injected as a `PlaybackAdapter`, so `@cssints/headless` imports no media library
3797
+ (Mediabunny is one adapter). The adapter owns transport and decoding; the primitive owns the stage,
3798
+ position and duration, the four preferences, and the seek slider's ARIA and keys. It subscribes at
3799
+ creation, calls `load(src ?? "")` after the first render, and unsubscribes and disposes on cleanup.
3800
+ Stages are `loading` at mount, `ready` on `loadedmetadata` (which also sets the duration and resets
3801
+ the position to 0), `playing`, `paused`, `buffering` (`waiting` while playing, `playing` back),
3802
+ `ended` (`ended` while playing sets the position to the duration) and `error` (an adapter `error`,
3803
+ or a rejected load or play; the cause is not exposed). `timeupdate` moves the position only while
3804
+ playing; `pause` from the adapter affects only an active stage; `seeking` and `seeked` change no
3805
+ state; a `ratechange` is a request for the rate. `play` is accepted from ready, paused, buffering
3806
+ and ended (from ended it starts at 0), `pause` from playing or buffering, and `seek` does nothing
3807
+ until metadata has arrived; a seek clamps to the duration and leaves the ended stage paused.
3808
+
3809
+ The four preferences follow the request/commit model, each with `<name>`, `default<Name>` and
3810
+ `on<Name>Change`: `volume` is 0..100 from a linear control, applied to the adapter as a squared gain
3811
+ (the perceptual curve); `muted` is a boolean; `rate` is a percent, where `setRate` accepts 50, 100,
3812
+ 150 or 200 and ignores anything else; `fullscreen` is a flag only, and the host applies the
3813
+ Fullscreen API. The adapter follows the committed values, so a request a controlled host rejects
3814
+ never reaches it; a `ratechange` from the adapter is taken as `round(rate × 100)` and, controlled,
3815
+ waits for the host like the other preferences.
3816
+
3817
+ ```ts skip
3818
+ interface PlaybackAdapter {
3819
+ load(src: string): Promise<void>; // emits loadedmetadata once the duration is known
3820
+ play(): Promise<void>;
3821
+ pause(): void;
3822
+ seek(seconds: number): Promise<void>;
3823
+ setVolume(gain: number): void; // 0..1, already muted-resolved
3824
+ setPlaybackRate(rate: number): void; // 1 = normal
3825
+ dispose(): void;
3826
+ subscribe(listener: (event: PlaybackAdapterEvent) => void): () => void;
3827
+ }
3828
+
3829
+ type PlaybackAdapterEvent =
3830
+ | { type: "loadedmetadata"; duration: number }
3831
+ | { type: "canplay" | "playing" | "pause" | "waiting" | "seeking" | "seeked" | "ended" }
3832
+ | { type: "timeupdate"; currentTime: number }
3833
+ | { type: "ratechange"; rate: number }
3834
+ | { type: "error"; cause: unknown };
3835
+
3836
+ createPlayer(options: {
3837
+ adapter: PlaybackAdapter;
3838
+ src?: string; // the load source; "" when absent
3839
+ volume?: number | undefined; // controlled, 0..100; undefined = uncontrolled
3840
+ defaultVolume?: number; // 100
3841
+ onVolumeChange?(volume: number): void; // a request the host may ignore
3842
+ muted?: boolean | undefined;
3843
+ defaultMuted?: boolean; // false
3844
+ onMutedChange?(muted: boolean): void;
3845
+ rate?: number | undefined; // controlled percent: 50, 100, 150 or 200
3846
+ defaultRate?: number; // 100
3847
+ onRateChange?(percent: number): void;
3848
+ fullscreen?: boolean | undefined;
3849
+ defaultFullscreen?: boolean; // false
3850
+ onFullscreenChange?(fullscreen: boolean): void;
3851
+ onPlay?(): void;
3852
+ onPause?(): void;
3853
+ onSeek?(seconds: number): void;
3854
+ onEnded?(): void;
3855
+ }): {
3856
+ stage(): "loading" | "ready" | "playing" | "paused" | "buffering" | "ended" | "error";
3857
+ position(): number; // seconds
3858
+ duration(): number; // 0 until loadedmetadata
3859
+ muted(): boolean;
3860
+ volume(): number; // 0..100
3861
+ rate(): number; // percent
3862
+ fullscreen(): boolean;
3863
+ focus(): "none" | "surface" | "seek" | "control";
3864
+ play(): void;
3865
+ pause(): void;
3866
+ togglePlay(): void;
3867
+ seek(seconds: number): void; // a commit: a no-op until the duration is known
3868
+ setVolume(value: number): void; // clamped to 0..100
3869
+ toggleMute(): void;
3870
+ setRate(percent: number): void; // 50, 100, 150 or 200; anything else is ignored
3871
+ toggleFullscreen(): void;
3872
+ surfaceProps: { tabindex: 0; onKeyDown; onFocus; onBlur };
3873
+ playProps: {
3874
+ type: "button";
3875
+ "aria-label": "Play" | "Pause";
3876
+ onClick;
3877
+ onMouseDown;
3878
+ onFocus;
3879
+ onBlur;
3880
+ };
3881
+ seekProps: {
3882
+ role: "slider";
3883
+ tabindex: 0;
3884
+ "aria-valuemin": 0;
3885
+ "aria-valuenow": number; // the position
3886
+ "aria-valuemax": number; // the duration
3887
+ "aria-valuetext": string; // "m:ss"
3888
+ onKeyDown;
3889
+ onFocus;
3890
+ onBlur;
3891
+ };
3892
+ muteProps: {
3893
+ type: "button";
3894
+ "aria-pressed": "true" | "false";
3895
+ onClick;
3896
+ onMouseDown;
3897
+ onFocus;
3898
+ onBlur;
3899
+ };
3900
+ fullscreenProps: {
3901
+ type: "button";
3902
+ "aria-pressed": "true" | "false";
3903
+ onClick;
3904
+ onMouseDown;
3905
+ onFocus;
3906
+ onBlur;
3907
+ };
3908
+ };
3909
+ ```
3910
+
3911
+ - **Keyboard.** On the surface: Space (or the legacy `Spacebar`), `k` or `K` toggles play and
3912
+ pause; ArrowLeft / ArrowRight seek -5 s / +5 s; `f` / `F` toggles fullscreen; `m` / `M` toggles
3913
+ mute. On the seek slider: ArrowLeft / ArrowDown -1 s, ArrowRight / ArrowUp +1 s, PageUp +15 s,
3914
+ PageDown -15 s, Home 0 and End the duration. A handled key `preventDefault`s; anything else
3915
+ passes.
3916
+ - **Focus.** `focus()` is `surface`, `seek`, `control` or `none`. Every control is its own tab stop
3917
+ (there is no roving toolbar), but a pointer press does not move focus: each button prevents its
3918
+ `mousedown` default, so the surface shortcuts keep their scope after a click.
3919
+ - **Announcement.** There is no live region: the seek slider's `aria-valuetext` (`m:ss`, minutes
3920
+ never folded into hours) is what a screen reader reads while it holds focus, and the play button's
3921
+ label flips between "Play" and "Pause". `aria-valuenow` and `-max` are the position and duration
3922
+ in seconds.
3923
+ - **The host's parts.** The primitive has no volume or rate control: render one from `volume()` and
3924
+ `setVolume` (or `createSlider`) and from `rate()` and `setRate`. The mute and fullscreen buttons
3925
+ carry only `aria-pressed` and no accessible name, so label them after the spread; the surface has
3926
+ no role, so name it. The seek slider has no pointer handlers: a drag is the host's, mapped to
3927
+ `seek` (a commit, with no preview).
3928
+ - **Limits.** `fullscreen` is a flag, not a Fullscreen API call. `src` is read at mount (there is no
3929
+ reload and no retry), a live stream is out of scope (`duration` is taken as finite), and the seek
3930
+ slider does not mirror its arrows under RTL — a time axis does not flip, a deliberate divergence
3931
+ from `slider.qnt`.
3932
+
2170
3933
  ### Publishing a library (decided 2026-10-02, cssints-0l3f)
2171
3934
 
2172
3935
  A package written on cssints ships JavaScript that still has the attribute, and its `.d.ts`. The app that installs it evaluates the package with its own engine: one sheet, the app's browserslist and themes, and a token, a kernel or an `@property` of the package is made once however many modules use it. Nothing is declared: the engine recognises a file by `with { type: "cssints" }`, the same marker as in app code.