@cssints/headless 0.17.0 → 0.19.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.
- package/README.md +216 -1
- package/dist/index.d.mts +527 -10
- package/dist/index.mjs +963 -50
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,7 +65,8 @@ A primitive that has a seam honours an optional `prefs` entry (a `Partial<Prefs>
|
|
|
65
65
|
- **menu / menubar** — `typeaheadDelayMs` (the typeahead buffer timeout).
|
|
66
66
|
- **tooltip** — `tooltipShowDelayMs` / `tooltipHideDelayMs`, exposed as `showDelayMs()` / `hideDelayMs()` for the host to wire its `setTimeout` from (the spec keeps timers as events).
|
|
67
67
|
|
|
68
|
-
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, popover, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
68
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, player, popover, radio, resizable-panels, scrollspy, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
69
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, confirm, dialog-modal, disclosure, feed, field, grid, link, listbox, meter, panzoom, player, popover, radio, resizable-panels, slider, slider-multithumb, spinbutton, switch, table, tabs, timeline, toolbar, treegrid, treeview, windowsplitter — they expose no timing / motion / live-region / camera seam. `listbox` / `treeview` / `timeline` typeahead timeouts are host-driven via their reset methods, so they take no delay here either.
|
|
69
70
|
|
|
70
71
|
`motionDurationMs` is **not consumed**: canvas tweens are tick-driven, not millisecond-driven, and the look layer owns durations. It stays in `Prefs` only so a host's preference record fits the type.
|
|
71
72
|
|
|
@@ -89,6 +90,41 @@ Membership, sizes and z-order are **not modelled** in `pointer.qnt` (its node se
|
|
|
89
90
|
|
|
90
91
|
**Nested canvases** are two instances the host composes: a `#canvas` node holds a second `createCanvas`, and the inner viewport stops propagation of its own pointer, wheel and key events. The innermost canvas under the pointer owns the event, so a press or zoom inside it never starts an outer gesture or moves the outer camera. A nested canvas is a single tab stop in its parent.
|
|
91
92
|
|
|
93
|
+
## Pan and zoom
|
|
94
|
+
|
|
95
|
+
`createPanZoom` is one image fitted to a viewport: the scale, the offset and the prop objects for the viewport, the image and a live value. **There is no APG pattern for pan-zoom**, so there is no Quint spec and no ported scenarios: the contract is this module's own and `test/panzoom.browser.test.tsx` is its oracle. The case it replaces (kocharin-art-3's `Zoomable`: ~110 lines of pointer maths) had no keyboard path and no ARIA at all.
|
|
96
|
+
|
|
97
|
+
**The accessible contract is three parts, and the pointer is a bonus:** a viewport that takes focus (`role=group`, `tabindex=0`, `aria-roledescription="zoomable image"`, `aria-describedby` the value), keyboard operations that stand in for every gesture (`+` / `=` / `-` zoom by `step`, arrows pan by 24 px, Shift ×4, `0` resets), and a polite live region (`role=status`, `aria-live=polite`, the current `"150%"`). `valueProps` deliberately carries no `hidden`: a `hidden` live region is not announced, so the host hides it visually.
|
|
98
|
+
|
|
99
|
+
**Not `createCanvas`.** The canvas is a camera over an infinite world with nodes, culling, snap and a z-order; a picture is one image in a box. There is no world, no node set and no reason to carry the camera machine.
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
const panzoom = createPanZoom({ min: 1, max: 4, step: 0.25 });
|
|
103
|
+
|
|
104
|
+
<div {...panzoom.viewportProps} class="viewport">
|
|
105
|
+
<img {...panzoom.imageProps} src={src} alt={alt} />
|
|
106
|
+
<span {...panzoom.valueProps} class="visually-hidden">
|
|
107
|
+
{panzoom.value()}
|
|
108
|
+
</span>
|
|
109
|
+
</div>;
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Gestures.** A drag pans, a second pointer pinches about its midpoint, a double click resets. Pinch is two pointers and the distance between them, not a platform gesture, so it works where `PointerEvent` does. **Wheel is zoom only with `ctrl` / `meta`** — the browser's own pinch arrives as `ctrl+wheel` — and a bare wheel is never `preventDefault`ed, so the page keeps scrolling; a swallowed wheel is the one thing that is never acceptable on a trackpad.
|
|
113
|
+
|
|
114
|
+
**`bounds: "clamp"`** (the default) bounds the offset by half the viewport's scaled overflow, so the image is expected to fill the viewport at `min` (`width: 100%; height: 100%` / `object-fit: cover`). A letterboxed image smaller than its viewport cannot be held gap-free: give it a viewport of its own or `bounds: "free"`.
|
|
115
|
+
|
|
116
|
+
Left to the host: the box (the viewport's size and `overflow: hidden`, `touch-action: none` if a touch drag should pan), the look (a transition on the transform, `transform-origin: 50% 50%` — the default this maths assumes), and the image itself. Not modelled: inertia, rotation, and a keyboard pan at scale `min` (the clamp means there is nothing to pan, so the arrows are inert).
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
const panzoom = createPanZoom({
|
|
120
|
+
scale, // controlled: the host owns the scale
|
|
121
|
+
onScaleChange: setScale,
|
|
122
|
+
onPanChange: (offset) => save({ x: offset.x, y: offset.y }),
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Controlled is `scale` + `onScaleChange`; the offset has no controlled prop, so a host that ignores a scale request also gets the offset resolved for the scale it refused.
|
|
127
|
+
|
|
92
128
|
## Resizable panels
|
|
93
129
|
|
|
94
130
|
`createResizablePanels` is a group of N panes and N-1 separators: react-resizable-panels' layout on the APG window splitter's keyboard. Its behaviour is `packages/apg-specs/specs/patterns/resizable-panels.qnt` — not an APG pattern, so it lives under `patterns/` — and the DOM is checked against it by the differential walk. The spec is the three-pane instance (10/80 bounds, 10% steps, pane 0 collapsible at 0); the primitive takes N panes and any bounds.
|
|
@@ -170,6 +206,36 @@ const email = createTextInput({ type: "email", autoComplete: "email", inputMode:
|
|
|
170
206
|
- **`disabled` vs `readOnly`.** Disabled is out of the tab order and not submitted; readOnly is focusable, selectable and submitted. A field the user must read before signing is readOnly, not disabled.
|
|
171
207
|
- `createTextArea` adds `rows`, `wrap` and `resize` (`resize` is a style, since CSS has no attribute for it; `none` is what a layout that owns its own height wants).
|
|
172
208
|
|
|
209
|
+
## Drop zone
|
|
210
|
+
|
|
211
|
+
`createDropZone` is a file drop target on a **hidden native `<input type="file">`**: the picker, the `accept` filter and `multiple` are the platform's, and the primitive adds a drag target, the validation and a status line. **There is no APG pattern for a drop zone**, so there is no Quint spec: `test/dropzone.browser.test.tsx` is its oracle. The same primitive replaces the bare file inputs of cms-admin's image field and kocharin's dashboard upload.
|
|
212
|
+
|
|
213
|
+
```tsx
|
|
214
|
+
const upload = createDropZone({
|
|
215
|
+
accept: "image/*",
|
|
216
|
+
multiple: true,
|
|
217
|
+
maxSize: 5 * 1024 * 1024,
|
|
218
|
+
labels: { zone: "Images", browse: "Choose images" },
|
|
219
|
+
onFiles: (files) => void add(files),
|
|
220
|
+
onReject: ({ file, reason }) => toast(`${file.name}: ${reason}`),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
<div {...upload.zoneProps}>
|
|
224
|
+
<p>Drop images here{upload.dragging() ? " (release to add)" : ""}</p>
|
|
225
|
+
<input {...upload.inputProps} />
|
|
226
|
+
<button {...upload.browseProps}>{upload.message() ?? "Choose images"}</button>
|
|
227
|
+
<p {...upload.statusProps}>{upload.message()}</p>
|
|
228
|
+
</div>;
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- **The zone is never drag-only.** `browseProps` goes on a real `<button type="button">` that clicks the hidden input, so the picker is the keyboard path (Enter and Space are the platform's) and a pointer user can click as well as drag. The browse button's text is the host's; `labels.browse` is its accessible name when that text is an icon.
|
|
232
|
+
- **Render the hidden input and the status region always.** `inputProps` carries `hidden`, so it is out of the tab order and the accessibility tree but still the thing the button clicks; the `role="status"` region announces a change of text, which only happens if it was already mounted.
|
|
233
|
+
- **Validation happens once per file**, type then size: `accept` is the HTML grammar (`.png`, `image/png`, `image/*`), `maxSize` is bytes. A refused file reaches `onReject` with `reason: "type" | "size"`; the accepted ones reach `onFiles` in one batch, and a batch with nothing accepted calls neither.
|
|
234
|
+
- **`message()` is a default sentence**, "2 files, 1 rejected: too large", for the status region; the words are the host's, the primitive only tracks the counts of the last batch. It is `undefined` before the first file.
|
|
235
|
+
- **A folder or an empty `DataTransfer` is a no-op**: no callback, no rejection, and the previous announcement stays.
|
|
236
|
+
- **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so the state is a depth counter: `dragging()` clears only when the drag leaves the zone itself, or on a drop. `dragging()` is the styling hook — the primitive writes no `data-*`.
|
|
237
|
+
- **`disabled`** marks the zone `aria-disabled`, disables the input natively and the browse button, and drops every drag before it becomes a batch. Options are read when an attribute is read, so a host may flip it on a mounted zone.
|
|
238
|
+
|
|
173
239
|
## Pagination
|
|
174
240
|
|
|
175
241
|
`createPagination` is a navigation landmark over a page list. **No APG pattern exists**; the requirements are TetraLogical's (accessible-components-library v1.0.0), and `test/pagination.browser.test.tsx` is the oracle.
|
|
@@ -253,6 +319,87 @@ const table = createTable({
|
|
|
253
319
|
- **`numeric` is a layout hint** for the host's skin (right alignment, tabular figures): HTML has no attribute for it, so the primitive carries nothing.
|
|
254
320
|
- Solid 2 arrives through delegated events; a host that reads the DOM right after a click waits a tick.
|
|
255
321
|
|
|
322
|
+
## Timeline
|
|
323
|
+
|
|
324
|
+
`createTimeline` has two modes over one axis. Without `rows` it renders the package's demo tree of five fixed spans (collapsing, a zoom window, the differential walk against `specs/apg/timeline.qnt`); with `rows` it takes real span data and lays it out as a trace waterfall.
|
|
325
|
+
|
|
326
|
+
```tsx
|
|
327
|
+
const timeline = createTimeline({
|
|
328
|
+
rows: spans, // { id, label, start, end, depth?, status? }, in the host's display order
|
|
329
|
+
axis: { unit: "ms" },
|
|
330
|
+
selected: selected(), // controlled; omit it and the primitive keeps the value
|
|
331
|
+
onSelect: (id) => setSelected(id),
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
<ul {...timeline.listProps}>
|
|
335
|
+
{spans.map((span) => (
|
|
336
|
+
<li {...timeline.rowProps(span.id)}>
|
|
337
|
+
<span {...timeline.labelProps(span.id)}>{span.label}</span>
|
|
338
|
+
<div class="track">
|
|
339
|
+
<div {...timeline.barProps(span.id)} />
|
|
340
|
+
</div>
|
|
341
|
+
</li>
|
|
342
|
+
))}
|
|
343
|
+
</ul>
|
|
344
|
+
<div {...{ role: timeline.rulerProps.role }}>
|
|
345
|
+
{timeline.rulerProps.ticks.map((tick) => (
|
|
346
|
+
<span style={{ left: `${tick.left}%` }}>{tick.label}</span>
|
|
347
|
+
))}
|
|
348
|
+
</div>;
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
- **A waterfall is a list, not an APG widget.** There is no trace/waterfall pattern, and the rows are neither a tree (nothing collapses) nor a grid (nothing is edited). The rows mode is `role="list"` / `role="listitem"` (see `listProps` / `rowProps`) with `aria-level` = `depth + 1`, so the nesting a flat DOM cannot show is still announced; the selected row is `aria-current="true"`, which every element may carry — `aria-selected` is not allowed on a `listitem` and a waterfall is not a selection control. The demo tree keeps `role="tree"` / `treeitem`, because it does collapse.
|
|
352
|
+
- **The extent is the rows'.** `extent()` is the smallest `start` and the largest `end`, so a nested span is positioned and sized as a percentage of the whole trace, not of its parent. `barProps(id).style` is `left` / `width` in percent of that extent. A zero-length span (a marker, `start === end`) still paints: every bar has a **0.5% floor** (2 px on a 400 px track), the floor the o11y dashboard hand-rolled before this mode existed.
|
|
353
|
+
- **Depth is a custom property**, `--depth` on `barProps` and `labelProps`, so a narrow track keeps its indentation: `padding-inline-start: calc(var(--depth) * 0.75rem)`. Nesting the label's DOM would steal width from the bar.
|
|
354
|
+
- **The ruler is derived.** `rulerProps.ticks` is the extent stepped by 1, 2 or 5 times a power of ten (about five ticks), each `{ value, left, label }` with the label in the `axis.unit` (default `ms`). The ruler itself is decorative (`role="presentation"`, `aria-hidden="true"`); render the ticks as children.
|
|
355
|
+
- **Selection and keys.** A row click selects it and moves the tab stop; ArrowUp / ArrowDown / Home / End move a roving tab stop, and Enter or Space selects the row that has DOM focus — the host moves the real focus after an arrow key, as the demo fixture does. `selected` + `onSelect` make it controlled (`selected` is a request the host may ignore); `select(id)` is the programmatic request.
|
|
356
|
+
- `status` is carried as `data-status` on the row for the host's colour, and `label` never leaves the host's data: the primitive does not invent the row's text.
|
|
357
|
+
|
|
358
|
+
## Tabs
|
|
359
|
+
|
|
360
|
+
`createTabs` is the APG tablist: roving tabindex, wrapping arrows, Home/End, automatic or manual activation, disabled tabs, and a selection that is a request (`value` + `onValueChange`) or uncontrolled (`defaultSelected`). The plain widget is checked against `specs/apg/tabs.qnt` by the scenario bridge and the differential walk in `test/tabs.browser.test.tsx`.
|
|
361
|
+
|
|
362
|
+
The docs playground's file tabs add and close files and rename them, so `createTabs` also takes `closable` and `renamable` — both off by default, so the plain widget is unchanged. **The primitive never takes the list:** it asks (`onClose`, `onRename`), the host removes or renames. `label(index)` is the tab's name: it names the close and rename buttons and seeds the rename draft, and it is read lazily.
|
|
363
|
+
|
|
364
|
+
```tsx
|
|
365
|
+
const tabs = createTabs({
|
|
366
|
+
get items() {
|
|
367
|
+
return files().length;
|
|
368
|
+
}, // the host's list; it shrinks after a close
|
|
369
|
+
get closable() {
|
|
370
|
+
return true;
|
|
371
|
+
},
|
|
372
|
+
get renamable() {
|
|
373
|
+
return true;
|
|
374
|
+
},
|
|
375
|
+
label: (index) => files()[index], // "Close index.ts", the rename draft
|
|
376
|
+
onClose: (index) => setFiles((all) => all.filter((_, at) => at !== index)),
|
|
377
|
+
onRename: (index, label) =>
|
|
378
|
+
setFiles((all) => all.map((name, at) => (at === index ? label : name))),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
<div {...tabs.tablistProps}>
|
|
382
|
+
{files().map((name, index) => (
|
|
383
|
+
<div {...tabs.tabProps(index)} onDblClick={tabs.renameProps(index).onDblClick}>
|
|
384
|
+
{tabs.renameProps(index).editing() ? (
|
|
385
|
+
<input
|
|
386
|
+
{...tabs.renameProps(index).inputProps}
|
|
387
|
+
ref={(element) => queueMicrotask(() => element.focus())}
|
|
388
|
+
/>
|
|
389
|
+
) : (
|
|
390
|
+
name
|
|
391
|
+
)}
|
|
392
|
+
</div>
|
|
393
|
+
))}
|
|
394
|
+
{/* ... the close button of each tab, and the panels */}
|
|
395
|
+
</div>;
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- **Close.** `closeProps(index)` is the tab's close button: `aria-label` "Close <label>" and `tabindex=-1`, because a tablist's tab stops are its tabs; the keyboard path is Delete on the focused tab. Focus after a close goes to the tab that slid into the closed one's place, or to the previous tab when the last one closed: `focusAfterClose(index)` is that index in the shortened list, and the host focuses it after its removal lands. Closing the last remaining tab is refused, as the spec refuses an empty tablist; a closed tab that was selected asks for the tab that takes the focus.
|
|
399
|
+
- **Rename.** A double click begins; the tab renders `inputProps` instead of its label. Enter commits, Escape reverts, blur commits; a label that trims to nothing cancels. An `<input>` cannot live in a `<button>`, so the tab becomes a `role="tab"` element while it is editing. While editing the input owns the keys: the tablist's roving does not move, and an arrow key leaves the caret alone. `begin(index)` is the programmatic start (a tab the host has just added: append it, then `focusTab(index)` and `begin(index)`).
|
|
400
|
+
- **State.** `editing()` is the tab being renamed, `null` when none. `items` is read from the option on every read, so the bounds, the roving tab stop and `firstEnabled` follow a host that shrinks its list.
|
|
401
|
+
- The rename and the two callbacks are the extension's own: the spec models a `deletable` flavour (Delete, focus to the next tab, the last refused) but no rename and no callbacks, so `test/tabs-close.browser.test.tsx` is the oracle for all of it.
|
|
402
|
+
|
|
256
403
|
## Confirm
|
|
257
404
|
|
|
258
405
|
`createConfirm` is a promise-based confirmation over `createDialogModal({ role: "alertdialog" })`. Like the field it is not checked against a spec (the dialog is; `test/confirm.browser.test.tsx` covers the promise around it, in Chromium).
|
|
@@ -382,6 +529,74 @@ const drawer = createDrawer({ side: "right", labelledby: "nav-title" });
|
|
|
382
529
|
- **No drag-to-dismiss.** A drag is not a dialog interaction: it is a scroll snap the platform runs off the main thread, and it is the host's CSS (`scroll-snap-type: y mandatory`, snap markers, `scrollsnapchange`; see [docs/bottom-sheet.md](docs/bottom-sheet.md)). Re-implementing a native snap in pointer events would be the gesture duplication that research forbids, and the handle would drag on top of the snap that already moved the panel. So `handleProps` is decoration (`aria-hidden`, no pointer handlers) and the ways out are the backdrop, Escape, a close control the host puts in the panel, and the trigger. Swipe-to-dismiss, if it is ever wanted, is a separate decision with its own oracle.
|
|
383
530
|
- **No `attach`.** The panel is edge-anchored: nothing is measured and no placement is computed, so focus and the Tab cycle find the panel through `contentProps.id` and the host only spreads attributes onto its own elements. Give the panel a name: pass `labelledby`, or set `aria-labelledby` / `aria-label` after the spread.
|
|
384
531
|
|
|
532
|
+
## Hotkeys
|
|
533
|
+
|
|
534
|
+
`createHotkeys` is a **platform utility, not a widget**: one registry for the chords every app hand-rolls (the docs app's `/` and Cmd/Ctrl+K, the CMS's Cmd/Ctrl+S). **No APG pattern exists for a keyboard registry**, so there is no role, no element, no styling and no Quint spec; `test/hotkeys.browser.test.tsx` is the oracle.
|
|
535
|
+
|
|
536
|
+
```tsx
|
|
537
|
+
const hotkeys = createHotkeys();
|
|
538
|
+
|
|
539
|
+
onSettled(() => {
|
|
540
|
+
hotkeys.bind({
|
|
541
|
+
keys: "mod+k",
|
|
542
|
+
onTrigger: () => searchEl.focus(),
|
|
543
|
+
});
|
|
544
|
+
hotkeys.bind({
|
|
545
|
+
keys: "/",
|
|
546
|
+
when: () => !dialog().open,
|
|
547
|
+
onTrigger: () => searchEl.focus(),
|
|
548
|
+
});
|
|
549
|
+
hotkeys.bind({
|
|
550
|
+
keys: "mod+s",
|
|
551
|
+
scope: editorEl,
|
|
552
|
+
onTrigger: () => save(),
|
|
553
|
+
});
|
|
554
|
+
return () => hotkeys.dispose();
|
|
555
|
+
});
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
- **One listener.** `bind` adds a chord to one `keydown` listener on the document in the capture phase (a `scope` only filters the event's target, it does not add a listener), and the last binding to go is the one that removes it; `dispose` unbinds everything and a later `bind` is a no-op. The unbind `bind` returns is idempotent.
|
|
559
|
+
- **Modifiers match exactly.** `mod` is Cmd on macOS and Ctrl elsewhere (`isApple` injects the answer, so a test does not read the runner's OS); `ctrl`, `alt`, `shift` and `meta` are as written. A chord that does not declare Shift does not fire with Shift held. A shifted character implies Shift: `"?"` fires from Shift+/ even without `"shift+?"`, and `"/"` does not fire from it.
|
|
560
|
+
- **A text field keeps its keys.** A binding does not fire while the focus is in an `input`, a `textarea` or a `contenteditable` (an inherited one included) unless it sets `allowInEditable`, which is what Escape wants: it closes a dialog wherever the focus is.
|
|
561
|
+
- **Last bound wins.** Only the most recently bound match fires, so a modal's `"/"` shadows the page's and unbinding it brings the page's back; a `when` that is false makes the next match fire instead, so the gate can close without unbinding.
|
|
562
|
+
- **`preventDefault` is on** for a binding that fires (Cmd/Ctrl+S and Cmd/Ctrl+K would otherwise reach the browser); pass `false` to let the platform act too.
|
|
563
|
+
|
|
564
|
+
Collisions with the browser's own chords: `preventDefault` reaches the page's default action, not the browser's. Cmd/Ctrl+S (Save Page), Cmd/Ctrl+K (the browser's search, in Chrome and Firefox), Cmd/Ctrl+P and `/` (Firefox's quick find) can be cancelled by a page, and a host that binds one is choosing to take it from the browser; Cmd/Ctrl+T, Cmd/Ctrl+W, Cmd/Ctrl+N, Cmd/Ctrl+L and F11 are reserved and cannot be intercepted, so a chord built on them never fires. `keys` takes any of them the same and says nothing.
|
|
565
|
+
|
|
566
|
+
## Scrollspy
|
|
567
|
+
|
|
568
|
+
`createScrollspy` is a table of contents whose current entry is the last heading to reach the top of the scrollport. **No APG pattern exists for a scrollspy** (the APG has no table-of-contents pattern), so there is no Quint spec and no ported scenarios: the contract is WAI-ARIA — `aria-current="location"` on the current link, exactly one link at a time, `role="list"` / `role="listitem"` for the list, and a named navigation landmark — plus HTML (`#id` targets). `test/scrollspy.browser.test.tsx` is its oracle.
|
|
569
|
+
|
|
570
|
+
```tsx
|
|
571
|
+
const toc = createScrollspy({
|
|
572
|
+
entries, // { id, label } in document order: the ids are the headings'
|
|
573
|
+
offset: 96, // a sticky header's height
|
|
574
|
+
onChange: (id) => track(id),
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
onSettled(() => toc.attach()); // returns the teardown
|
|
578
|
+
|
|
579
|
+
<nav {...toc.navProps}>
|
|
580
|
+
<ul {...toc.listProps}>
|
|
581
|
+
<For each={entries}>
|
|
582
|
+
{(entry) => (
|
|
583
|
+
<li {...toc.itemProps(entry.id)}>
|
|
584
|
+
<a {...toc.linkProps(entry.id)}>{entry.label}</a>
|
|
585
|
+
</li>
|
|
586
|
+
)}
|
|
587
|
+
</For>
|
|
588
|
+
</ul>
|
|
589
|
+
</nav>;
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
- **A scroll listener, not an `IntersectionObserver`.** "The last heading at or above the offset" is a total order over every heading, and an observer only reports what crossed its band: a band observer has to rebuild the order from its enter/leave state and misreports after a jump (an anchor click, a restored scroll position). The listener is passive, the measurements are reads only (no writes, so no layout thrash), and a docs page has a dozen headings — the same cost as the listener the page already keeps.
|
|
593
|
+
- **`attach()`** adds that one listener to `root()` (the document by default; read once, so attach again after swapping containers), calls it once to sync a page opened mid-scroll, and returns the teardown. Headings are looked up by id on every scroll, so they may render after `attach`; an id with no element is skipped.
|
|
594
|
+
- **`offset`** is px below the scrollport's top edge, not the viewport's, so an inner container plus a sticky header works: `offset` is the header's height inside that container.
|
|
595
|
+
- **Before any scroll the first entry is current**, and it stays current while nothing has passed the line. `active()` is `undefined` only when there are no entries.
|
|
596
|
+
- **Exactly one `aria-current="location"`**, on the link, never on the heading: the heading's own `id` is the target. Skin it with `a[aria-current="location"]`.
|
|
597
|
+
- **Smooth scrolling is the host's.** The links are plain `#id` targets, the primitive never moves the scrollport, so `scroll-behavior: smooth` (and any `prefers-reduced-motion` override) belongs to the page's CSS; there is no scroll helper to reduce.
|
|
598
|
+
- **"On this page" is English.** Spread `navProps` first and override it after: `<nav {...toc.navProps} aria-label={t("onThisPage")}>`.
|
|
599
|
+
|
|
385
600
|
## Commands
|
|
386
601
|
|
|
387
602
|
```sh
|