@cssints/headless 0.19.0 → 0.20.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 +79 -2
- package/dist/index.d.mts +457 -203
- package/dist/index.mjs +877 -416
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,8 +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, 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.
|
|
68
|
+
Everything else **ignores** prefs: accordion, alert, alertdialog, breadcrumb, button, carousel, checkbox, checkbox-group, combobox, command-palette, 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, command-palette, 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.
|
|
70
70
|
|
|
71
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.
|
|
72
72
|
|
|
@@ -125,6 +125,30 @@ const panzoom = createPanZoom({
|
|
|
125
125
|
|
|
126
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
127
|
|
|
128
|
+
## Knob
|
|
129
|
+
|
|
130
|
+
`createKnob` is a virtual knob after GarageBand's ([How Apple Designs a Virtual Knob](https://jherrm.github.io/knobs/)): one focusable control you spin around its centre or slide vertically / horizontally, with **one gesture locked per drag**. **There is no APG pattern and no ARIA role for a dial**, so it is a `role=slider` with `aria-valuenow` / `aria-valuetext`, and `test/knob.browser.test.tsx` (real Chromium) is its oracle — there is no Quint spec.
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
const knob = createKnob({ labels: { knob: "Volume" }, format: (value) => `${value}%` });
|
|
134
|
+
const turn = () => knob.percentOf(knob.value());
|
|
135
|
+
|
|
136
|
+
<div {...knob.knobProps} class="knob">
|
|
137
|
+
<span class="indicator" style={{ transform: `rotate(${turn() * 360}deg)` }} />
|
|
138
|
+
</div>
|
|
139
|
+
<span {...knob.valueProps} class="visually-hidden">
|
|
140
|
+
{knob.value()}
|
|
141
|
+
</span>;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`percentOf` is the fraction of a full turn the skin rotates by; the look is the host's. `valueProps` must be rendered (hide it visually, never with `hidden`): it is the live announcement.
|
|
145
|
+
|
|
146
|
+
**A dial is never the only control.** The keyboard contract stands in for every gesture — arrows step, PageUp/PageDown by ten steps, Home/End to the bounds — but a numeric field (`createSpinbutton`) still belongs next to it: a slider role is operable but does not make a range easy to discover or type.
|
|
147
|
+
|
|
148
|
+
**Gestures** (`gesture`, default `"both"`). A pointerdown records the centre, the start angle and the side. On the first movement past 6 px the drag picks a mode once and keeps it for the rest of the drag: movement that stays on one axis (the other under 35% of it) is a slide, anything else is a turn around the centre. The reference's direction rule holds: down on the right half turns clockwise (up in value), down on the left half counter-clockwise, and a horizontal slide uses the same left/right side. A spin's value per degree shrinks as the pointer moves away from the centre — the same angle is a longer arc, so farther is finer. `gesture: "spin"` / `"slide"` disables the other. The wheel steps once per event and `preventDefault`s, so hovering the dial stops the page from scrolling.
|
|
149
|
+
|
|
150
|
+
**`wrap`** makes the dial continuous: past `max` comes back at `min`, so a full turn is a full cycle instead of the clamped end of the range. `setValue` / `stepBy` are requests like a key, so a controlled `value` still owns the result.
|
|
151
|
+
|
|
128
152
|
## Resizable panels
|
|
129
153
|
|
|
130
154
|
`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.
|
|
@@ -359,6 +383,8 @@ const timeline = createTimeline({
|
|
|
359
383
|
|
|
360
384
|
`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
385
|
|
|
386
|
+
Its accessibility requirements — one row per requirement, each naming the Quint invariant, browser test or manual AT row that holds it, and the gaps still open — are `docs/requirements/tabs.md`, written on the model in `docs/requirements/README.md`: a behaviour goes to the spec and its walk, a DOM attribute or a layout value to a browser test, and what a screen reader says to the assistive-technology table.
|
|
387
|
+
|
|
362
388
|
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
389
|
|
|
364
390
|
```tsx
|
|
@@ -597,6 +623,57 @@ onSettled(() => toc.attach()); // returns the teardown
|
|
|
597
623
|
- **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
624
|
- **"On this page" is English.** Spread `navProps` first and override it after: `<nav {...toc.navProps} aria-label={t("onThisPage")}>`.
|
|
599
625
|
|
|
626
|
+
## Command palette
|
|
627
|
+
|
|
628
|
+
The first **composition** on the primitives — no new widget, no new spec: `createCommandPalette` is `createDialogModal` (a modal dialog) + `createTextInput` (the field) + `createStatus` (the count), wired into the shape [docs/research/compositions-2026-10.md](docs/research/compositions-2026-10.md) settles.
|
|
629
|
+
|
|
630
|
+
```tsx
|
|
631
|
+
const palette = createCommandPalette({
|
|
632
|
+
commands, // ReadonlyArray<{ id, label, group?, keywords?, run(): void | Promise<void> }>
|
|
633
|
+
hotkey: "mod+k", // what attach() binds; this is the default
|
|
634
|
+
labels: { title: t("palette"), placeholder: t("search"), empty: t("empty") },
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
onSettled(() => palette.attach()); // one document hotkey; returns the teardown
|
|
638
|
+
|
|
639
|
+
const groups = () => [...new Set(palette.results().map((result) => result.group ?? ""))];
|
|
640
|
+
|
|
641
|
+
<button
|
|
642
|
+
aria-haspopup="dialog"
|
|
643
|
+
aria-expanded={palette.open()}
|
|
644
|
+
onClick={() => palette.openPalette()}
|
|
645
|
+
>
|
|
646
|
+
Commands
|
|
647
|
+
</button>
|
|
648
|
+
<div {...palette.backdropProps} />
|
|
649
|
+
<div {...palette.dialogProps}>
|
|
650
|
+
<h2 {...palette.titleProps}>{palette.labels.title}</h2>
|
|
651
|
+
<input {...palette.inputProps} />
|
|
652
|
+
<div {...palette.listProps}>
|
|
653
|
+
<For each={groups()}>
|
|
654
|
+
{(group) => (
|
|
655
|
+
<section>
|
|
656
|
+
<h3>{group}</h3>
|
|
657
|
+
<For each={palette.results().filter((result) => result.group === group)}>
|
|
658
|
+
{(result) => <div {...palette.optionProps(result.id)}>{result.label}</div>}
|
|
659
|
+
</For>
|
|
660
|
+
</section>
|
|
661
|
+
)}
|
|
662
|
+
</For>
|
|
663
|
+
</div>
|
|
664
|
+
<p {...palette.emptyProps}>{palette.labels.empty}</p>
|
|
665
|
+
<div {...palette.statusProps}>{palette.status()}</div>
|
|
666
|
+
</div>;
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
- **A dialog, not a combobox.** Choosing a row runs a command; it does not complete the field. So the field is `input[type=text][role=searchbox]` with no `aria-autocomplete` and no `aria-haspopup`, the container is `role=dialog` (a trigger says `aria-haspopup="dialog"`), and the list is a `role=listbox` named by the title. Nothing is changed by typing.
|
|
670
|
+
- **Focus stays in the field.** `aria-activedescendant` names the highlighted option; ↓ past the last and ↑ before the first put the highlight back on the field (the attribute is absent), and ↓/↑ from there re-enter the list at the first/last row. Home/End move the caret while the field has text and are list keys (first/last) only while it is empty. Enter runs the highlighted command; Escape closes through the dialog; Tab is not intercepted, so the dialog's own focus policy applies.
|
|
671
|
+
- **Filtering is a plain case-insensitive substring match** on `label` and `keywords`. No fuzzy scoring: a palette holds tens of commands, and ranking (recents, scopes, weighted keywords) is a policy the host owns — a typo hiding a command is worse than an extra row. `results()` keeps host order.
|
|
672
|
+
- **Groups, empty state, one announcement.** `result.group` (or `undefined`) is what the host groups by; it renders the headings and the CSS. `emptyProps.hidden` is false when nothing matches, and `labels.empty` is the text to show. The count lands in one polite region (`statusProps`, `status()`), written once per query change — moving the highlight does not re-announce it.
|
|
673
|
+
- **Reset on close**: the query and highlight are cleared on close, so the next open starts at the full list (unlike a combobox, which keeps its value). Focus returns to the element that had it when the palette opened.
|
|
674
|
+
- **The host's**: rendering, grouping and CSS; async commands (`run` may return a promise, and moving the highlight to the next command is the host's call); closing on anything but Escape or a command; every visible string through `labels`; and the dialog's title element, which carries `titleProps` (`aria-labelledby` points at it).
|
|
675
|
+
- **Not in this PR**: the filterable menu (`filter: true` on `createMenu`, with the IME guard and the arrow loop to the field) and the date picker are later compositions on the same research; this one is the palette only.
|
|
676
|
+
|
|
600
677
|
## Commands
|
|
601
678
|
|
|
602
679
|
```sh
|