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