cssints 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/API.md +363 -1
- package/package.json +2 -2
package/docs/API.md
CHANGED
|
@@ -1465,6 +1465,42 @@ createTable(options: {
|
|
|
1465
1465
|
|
|
1466
1466
|
Selection is over ids, so it survives the host's re-sort. `rowProps` carries `aria-selected` and a click that selects; `toggleRow` / `toggleAll` are the programmatic path, and the row's checkbox stops its click from reaching the row so the two controls never toggle the same row twice. `selectAllProps` and `rowCheckboxProps` are `undefined` unless `selection` is `"multiple"` (one control over one selection is meaningless), and the select-all is a `role="checkbox"` rather than a native `input` because the mixed state a screen reader must hear is `aria-checked="mixed"`, not the DOM-only `indeterminate` property. It toggles the rows the table renders: a windowed table toggles its window and keeps the rest of the selection. A table that renders all of its rows and columns carries no `aria-rowcount` / `aria-colcount` (the markup already says it); pass them, and every cell gets its `aria-colindex`, only when the host windows. `numeric` is a layout hint for the host's skin: HTML has no attribute for it.
|
|
1467
1467
|
|
|
1468
|
+
### Headless: `createTabs` (`@cssints/headless`)
|
|
1469
|
+
|
|
1470
|
+
The APG tablist as state and attribute objects: roving tabindex, wrapping arrows, Home/End, automatic or manual activation, disabled tabs, and a selection that is a request (`value` + `onValueChange`) or uncontrolled (`defaultSelected`). `packages/apg-specs/specs/apg/tabs.qnt` is its model; `packages/headless/test/tabs.browser.test.tsx` replays it in Chromium.
|
|
1471
|
+
|
|
1472
|
+
Its accessibility requirements are catalogued, gap by gap, in `packages/headless/docs/requirements/tabs.md` (the model is `packages/headless/docs/requirements/README.md`).
|
|
1473
|
+
|
|
1474
|
+
Close and rename are an **extension** (bean `cssints-gdv2`, the docs playground's file tabs), off by default so the plain tabs are untouched. The primitive owns no list: it asks (`onClose`, `onRename`), the host removes or renames. `packages/headless/test/tabs-close.browser.test.tsx` is their oracle.
|
|
1475
|
+
|
|
1476
|
+
```ts skip
|
|
1477
|
+
createTabs(options?: {
|
|
1478
|
+
...; // the plain tabs: items, value, defaultSelected, orientation, activation, disabled, focusablePanels, id, onValueChange
|
|
1479
|
+
closable?: boolean; // false: closeProps and the Delete key; the host removes from onClose
|
|
1480
|
+
renamable?: boolean; // false: a double click edits the label inline; the host renames from onRename
|
|
1481
|
+
label?(index: number): string | undefined; // the tab's name: the buttons' labels and the rename draft
|
|
1482
|
+
onClose?(index: number): void; // a request: the host removes the tab and may ignore it
|
|
1483
|
+
onRename?(index: number, label: string): void; // a request with a non-empty label
|
|
1484
|
+
}): {
|
|
1485
|
+
...;
|
|
1486
|
+
editing(): number | null; // the tab being renamed inline
|
|
1487
|
+
focusAfterClose(index: number): number; // the tab to focus once the removal landed
|
|
1488
|
+
closeProps(index): { "aria-label": string; tabindex: -1; onClick: () => void };
|
|
1489
|
+
renameProps(index): {
|
|
1490
|
+
onDblClick: () => void;
|
|
1491
|
+
editing(): boolean;
|
|
1492
|
+
begin(index: number): void;
|
|
1493
|
+
commit(label: string): void;
|
|
1494
|
+
cancel(): void;
|
|
1495
|
+
inputProps: { type: "text"; value; "aria-label"; onInput; onKeyDown; onBlur };
|
|
1496
|
+
};
|
|
1497
|
+
};
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
- **Close.** `closeProps(index)` is the tab's close button: `aria-label="Close <label>"` and `tabindex=-1`, because a tablist's tab stops are its tabs; the keyboard path is Delete on the focused tab. Focus after a close goes to the tab that slid into the closed one's place, or to the previous tab when the last one closed: `focusAfterClose(index)` names that index in the shortened list (call it before the host removes the tab). Closing the last remaining tab is refused, as the spec refuses an empty tablist; a closed tab that was selected asks for the tab that takes the focus.
|
|
1501
|
+
- **Rename.** A double click begins; the tab renders `inputProps` instead of its label. Enter commits, Escape reverts, blur commits, and a label that trims to nothing cancels. While editing, the input owns the keys: the tablist's roving does not move. `begin(index)` is the programmatic start, for a tab the host has just added (append it, then `focusTab(index)` and `begin(index)`).
|
|
1502
|
+
- **The host's list.** `items` is read from the option on every read, so the bounds, the roving tab stop and `firstEnabled` follow a host that shrinks its list. `label` is read lazily too, so a renamed tab renames its buttons with it.
|
|
1503
|
+
- The spec models a `deletable` flavour (Delete, focus to the next tab, the last refused) but neither a rename nor the callbacks, so the browser test, not the model, holds the extension.
|
|
1468
1504
|
|
|
1469
1505
|
### Headless: `createHotkeys` (`@cssints/headless`)
|
|
1470
1506
|
|
|
@@ -1488,7 +1524,6 @@ hotkeys.dispose(); // unbinds all and removes the document listener; a later bin
|
|
|
1488
1524
|
|
|
1489
1525
|
There is one `keydown` listener on the document in the capture phase, added when the first binding is bound and removed when the last is unbound or on `dispose`. The most recently bound match is the only one that fires, so a modal's `/` shadows the page's and unbinding it brings the page's back; a binding whose `when` returns false falls through to the next match instead of blocking it. By default a binding does not fire while the focus is in an `input`, a `textarea` or a `contenteditable` (an inherited one included) unless it sets `allowInEditable`. An unknown modifier or a chord with no key throws a `TypeError` at `bind`.
|
|
1490
1526
|
|
|
1491
|
-
|
|
1492
1527
|
### Headless: `createScrollspy` (`@cssints/headless`)
|
|
1493
1528
|
|
|
1494
1529
|
A table of contents whose current entry is the last heading to reach the top of the scrollport, marked `aria-current="location"` on the link. **No APG pattern exists for a scrollspy**, so it has no Quint spec and no ported scenarios: the contract is WAI-ARIA (the `aria-current` value, `role="list"` / `role="listitem"`, a named navigation landmark) and HTML, and `packages/headless/test/scrollspy.browser.test.tsx` is its oracle.
|
|
@@ -1513,6 +1548,48 @@ createScrollspy(options: {
|
|
|
1513
1548
|
|
|
1514
1549
|
f812d87e (headless: a data table with sortable headers and row selection)
|
|
1515
1550
|
|
|
1551
|
+
### Headless: `createCommandPalette` (`@cssints/headless`)
|
|
1552
|
+
|
|
1553
|
+
The first **composition**, not a new primitive and not a new spec: `createDialogModal` + `createTextInput` + `createStatus` wired into a palette. Its oracle is `packages/headless/test/command-palette.browser.test.tsx`. A palette is a dialog, **not a combobox**: choosing a row runs a command, so the field is `input[type=text][role=searchbox]` (no `aria-autocomplete`, no `aria-haspopup`), the container is `role=dialog` and the list is a `role=listbox`.
|
|
1554
|
+
|
|
1555
|
+
```ts skip
|
|
1556
|
+
createCommandPalette(options: {
|
|
1557
|
+
commands: ReadonlyArray<{
|
|
1558
|
+
id: string;
|
|
1559
|
+
label: string;
|
|
1560
|
+
group?: string | undefined; // the heading the host renders
|
|
1561
|
+
keywords?: ReadonlyArray<string> | undefined; // also matched
|
|
1562
|
+
run(): void | Promise<void>;
|
|
1563
|
+
}>;
|
|
1564
|
+
open?: boolean | undefined; // controlled: the host owns the open state
|
|
1565
|
+
onOpenChange?(open: boolean): void;
|
|
1566
|
+
hotkey?: string | undefined; // the chord attach() binds; "mod+k"
|
|
1567
|
+
labels?: Partial<{
|
|
1568
|
+
title: string; // "Command palette"
|
|
1569
|
+
placeholder: string; // "Type a command"
|
|
1570
|
+
empty: string; // "No commands found"
|
|
1571
|
+
results(count: number): string; // "4 commands"
|
|
1572
|
+
}> | undefined;
|
|
1573
|
+
}): {
|
|
1574
|
+
open(): boolean;
|
|
1575
|
+
openPalette(): void;
|
|
1576
|
+
closePalette(): void;
|
|
1577
|
+
query(): string;
|
|
1578
|
+
setQuery(text: string): void;
|
|
1579
|
+
active(): number; // index into results(); -1 = the field holds the highlight
|
|
1580
|
+
setActive(index: number): void;
|
|
1581
|
+
results(): ReadonlyArray<{ id: string; label: string; group: string | undefined }>;
|
|
1582
|
+
labels: { title: string; placeholder: string; empty: string; results(count: number): string };
|
|
1583
|
+
status(): string | undefined; // the count, in the polite region
|
|
1584
|
+
// inputProps, listProps, optionProps(id), emptyProps, statusProps,
|
|
1585
|
+
// dialogProps, backdropProps and titleProps: the attribute objects.
|
|
1586
|
+
runActive(): void | Promise<void>;
|
|
1587
|
+
attach(): () => void; // binds the hotkey on the document; returns the teardown
|
|
1588
|
+
};
|
|
1589
|
+
```
|
|
1590
|
+
|
|
1591
|
+
Focus stays in the field and `aria-activedescendant` names the highlighted option: ↓ on the last and ↑ on the first return the highlight to the field, and ↓/↑ from there re-enter at the first/last row. Home/End move the caret while the field has text and are list keys only while it is empty. Enter runs the highlight, Escape closes through the dialog, Tab is not intercepted. Filtering is a plain case-insensitive substring match over `label` and `keywords` — no fuzzy scoring, because a palette is short and ranking (recents, scopes) is the host's policy. `results()` keeps host order; `group` is what the host groups by; `emptyProps.hidden` and `labels.empty` are the empty state; the count is written to `statusProps` once per query, not on every arrow. The query and highlight reset on close, focus returns to the element that had it, and `runActive()` closes before `run()` so a command may open its own UI. Host's: rendering, grouping and CSS; async commands and their errors; every visible string via `labels`; any close other than Escape or a command. The filterable menu and the date picker are later compositions on the same research.
|
|
1592
|
+
|
|
1516
1593
|
### Headless: `createPanZoom` (`@cssints/headless`)
|
|
1517
1594
|
|
|
1518
1595
|
**No APG pattern exists for pan-zoom**, so there is no Quint spec and no ported scenarios: `packages/headless/test/panzoom.browser.test.tsx`, a real Chromium viewport, is its oracle. The accessible contract is the three parts it returns: a focusable viewport (`role=group`, `tabindex=0`, `aria-roledescription="zoomable image"`, `aria-describedby` the value), keyboard operations that stand in for every gesture, and a polite live value. The pointer — drag to pan, two pointers to pinch, a double click to reset — is an addition, never the only way in. It is not `createCanvas`: that is a camera over an infinite world, this is one image fitted to a box.
|
|
@@ -1559,6 +1636,291 @@ Wheel zooms **only with `ctrl` / `meta`** held: the browser's pinch gesture arri
|
|
|
1559
1636
|
|
|
1560
1637
|
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: inertia, rotation, and pinching on platforms without `PointerEvent` (the browser's own pinch is the `ctrl+wheel` path).
|
|
1561
1638
|
|
|
1639
|
+
### Headless: `createMenu` (`@cssints/headless`)
|
|
1640
|
+
|
|
1641
|
+
The APG menu family (`createMenu` for the menu button and the checkable menus, `createMenubar` for the roving bar) as state and attribute objects. Its behaviour is `specs/apg/menu-button.qnt` and `menu-checkable.qnt`, and `packages/headless/test/menu-button.browser.test.tsx` replays the generated models, ported scenarios and seeded differential walks against it.
|
|
1642
|
+
|
|
1643
|
+
```ts skip
|
|
1644
|
+
createMenu(options: {
|
|
1645
|
+
items: ReadonlyArray<string | { label: string; kind?: "item" | "checkbox" | "radio"; checked?: boolean; disabled?: boolean }>;
|
|
1646
|
+
variant?: "plain" | "checkbox-items" | "radio-items" | "mixed" | undefined; // "plain"
|
|
1647
|
+
open?: boolean | undefined; // controlled: the host owns the open state
|
|
1648
|
+
defaultOpen?: boolean; // uncontrolled starting state
|
|
1649
|
+
onOpenChange?(open: boolean): void; // a request the host may ignore
|
|
1650
|
+
value?: number | undefined; // controlled checked items, packed (checkboxItem0 * 5 + radioChoice, 1-based)
|
|
1651
|
+
defaultValue?: number;
|
|
1652
|
+
onCheckedValueChange?(value: number): void;
|
|
1653
|
+
triggerMode?: "outside" | "held" | undefined; // where the focus sits while the menu is closed
|
|
1654
|
+
disabled?: ReadonlyArray<number>; // focusable, never activated
|
|
1655
|
+
id?: string | undefined;
|
|
1656
|
+
onItemClick?(index: number): void;
|
|
1657
|
+
}): {
|
|
1658
|
+
open(): boolean;
|
|
1659
|
+
focusLevel(): 0 | 1 | 2; // 0 outside, 1 on the trigger, 2 on an item
|
|
1660
|
+
focusIndex(): number;
|
|
1661
|
+
openMenu(): void; // a request, like every intent
|
|
1662
|
+
closeMenu(): void;
|
|
1663
|
+
keyDown(key: string): void;
|
|
1664
|
+
// …items, triggerProps, menuProps, itemProps(index), type(), setFocusItem()
|
|
1665
|
+
};
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
**The open state is a request/commit pair**, like every other controlled primitive: `open` (+ `onOpenChange`) makes the host own it, `defaultOpen` starts it. Every intent that opens or closes — the trigger's click and its keys, Escape, an item's activation, Tab, an outside click in a kit, the programmatic `openMenu` / `closeMenu` — is a request, so a host that holds `open` keeps the menu open and the focus where it was; `menu-button.qnt` reverts the whole transition of a rejected request, focus included. A write the host makes on its own is not a request: it takes the focus to the first item when it opens and to the trigger when it closes, and a write of the value it already has moves nothing (no widget can observe it).
|
|
1669
|
+
|
|
1670
|
+
### Headless: `createCheckboxGroup` (`@cssints/headless`)
|
|
1671
|
+
|
|
1672
|
+
The APG Checkbox pattern's tri-state group controller: exactly three child values, a controller whose `aria-checked` is derived from them (all / none / mixed) with the optional "remember the last mixed combination" cycle, checked against `specs/apg/checkbox-group.qnt`. Its name, description and error are `createField`'s contract on the `role="group"` element, which the model leaves out on purpose (its header omits labelling), so `packages/headless/test/checkbox-group.browser.test.tsx` is their oracle.
|
|
1673
|
+
|
|
1674
|
+
```ts skip
|
|
1675
|
+
createCheckboxGroup(options: {
|
|
1676
|
+
values: ReadonlyArray<T>; // exactly three
|
|
1677
|
+
value?: ReadonlyArray<T> | undefined; // controlled checked subset
|
|
1678
|
+
defaultValue?: ReadonlyArray<T>;
|
|
1679
|
+
parent?: boolean; // the "all" controller is a focus stop
|
|
1680
|
+
remember?: boolean; // the third activation restores the last mixed combination
|
|
1681
|
+
id?: string | undefined; // base of `<id>-control` (the group element) and `<id>-error`
|
|
1682
|
+
label?: string | undefined; // the group's name, as aria-label
|
|
1683
|
+
labelledby?: string | undefined; // the id of the host's own label element; wins over `label`
|
|
1684
|
+
describedby?: string | undefined; // host-owned description ids, after the error
|
|
1685
|
+
invalid?: boolean | string | undefined; // like createField: a string is the flag and the message
|
|
1686
|
+
error?: string | undefined; // the error shown; implies invalid, `invalid: false` silences it
|
|
1687
|
+
onValueChange?(checked: ReadonlyArray<T>): void;
|
|
1688
|
+
}): {
|
|
1689
|
+
invalid(): boolean;
|
|
1690
|
+
error(): string | undefined; // only while invalid
|
|
1691
|
+
describedIds(): readonly string[]; // the error id while shown, then describedby
|
|
1692
|
+
groupProps: { id; role: "group"; "aria-label"; "aria-labelledby"; "aria-describedby"; "aria-invalid"; onKeyDown };
|
|
1693
|
+
errorProps: { id; hidden; role }; // role "alert" while shown; render it always
|
|
1694
|
+
// …children, parentChecked, checkedValues(), click(target), keySpace(), groupProps' onKeyDown
|
|
1695
|
+
};
|
|
1696
|
+
```
|
|
1697
|
+
|
|
1698
|
+
The group is not a `<label for>` target: pass `labelledby` naming the host's own label element, or `label` without one. The error id is in `aria-describedby` only while the error is shown, `aria-invalid` only while the group is invalid, and the group element's own id is there for a controller that names it with `aria-controls`. The checked set is unchanged by all of this: the request/commit split and the spec stay as they were.
|
|
1699
|
+
|
|
1700
|
+
### Headless: `createSlider` and `createSpinbutton` (`@cssints/headless`)
|
|
1701
|
+
|
|
1702
|
+
The two number controls. Their behaviour is `packages/apg-specs/specs/apg/slider.qnt` and `spinbutton.qnt`, checked against the real DOM by the differential walk in `packages/headless/test/slider.browser.test.tsx` and `spinbutton.browser.test.tsx`. Options are plain and read lazily (a getter is fine); `value` + `onValueChange` make the widget controlled, `defaultValue` is the uncontrolled start.
|
|
1703
|
+
|
|
1704
|
+
```ts skip
|
|
1705
|
+
createSlider(options?: {
|
|
1706
|
+
min?: number; // 0
|
|
1707
|
+
max?: number; // 10
|
|
1708
|
+
step?: number; // 1
|
|
1709
|
+
largeStep?: number; // 3: the jump of PageUp / PageDown
|
|
1710
|
+
value?: number | undefined; // controlled; undefined = uncontrolled
|
|
1711
|
+
defaultValue?: number; // 5, clamped into the range
|
|
1712
|
+
orientation?: "horizontal" | "vertical"; // "horizontal"
|
|
1713
|
+
direction?: "ltr" | "rtl"; // "ltr": under rtl the horizontal arrows flip
|
|
1714
|
+
disabled?: boolean; // aria-disabled on the thumb; a key or rail press changes nothing
|
|
1715
|
+
id?: string; // base of the thumb id
|
|
1716
|
+
onValueChange?(value: number): void; // a request the host may ignore
|
|
1717
|
+
}): {
|
|
1718
|
+
value(): number;
|
|
1719
|
+
focused(): boolean;
|
|
1720
|
+
setValue(value: number): void;
|
|
1721
|
+
focus(): void;
|
|
1722
|
+
blur(): void;
|
|
1723
|
+
keyDown(key: string): boolean; // true when the key was consumed (preventDefault it)
|
|
1724
|
+
pointerDown(position: number): void; // a rail press at a 0..1 fraction of the track
|
|
1725
|
+
thumbProps: {
|
|
1726
|
+
id;
|
|
1727
|
+
role: "slider";
|
|
1728
|
+
"aria-valuenow";
|
|
1729
|
+
"aria-valuemin";
|
|
1730
|
+
"aria-valuemax";
|
|
1731
|
+
"aria-valuetext";
|
|
1732
|
+
"aria-orientation";
|
|
1733
|
+
"aria-disabled": "true" | undefined;
|
|
1734
|
+
tabindex: 0;
|
|
1735
|
+
onFocus;
|
|
1736
|
+
onBlur;
|
|
1737
|
+
onKeyDown;
|
|
1738
|
+
};
|
|
1739
|
+
trackProps: { onPointerDown };
|
|
1740
|
+
};
|
|
1741
|
+
```
|
|
1742
|
+
|
|
1743
|
+
```ts skip
|
|
1744
|
+
createSpinbutton(options?: {
|
|
1745
|
+
min?: number; // 1
|
|
1746
|
+
max?: number; // 12
|
|
1747
|
+
step?: number; // 1: the arrows and the buttons
|
|
1748
|
+
largeStep?: number; // 3: PageUp / PageDown
|
|
1749
|
+
value?: number | undefined; // controlled; undefined = uncontrolled
|
|
1750
|
+
defaultValue?: number; // 5, clamped into the range
|
|
1751
|
+
wrap?: boolean; // false: clamp at the bounds (true wraps)
|
|
1752
|
+
disabled?: boolean; // aria-disabled and the native disabled; keys, typing and both steppers change nothing
|
|
1753
|
+
id?: string; // base of the input and button ids
|
|
1754
|
+
onValueChange?(value: number): void; // a request the host may ignore
|
|
1755
|
+
}): {
|
|
1756
|
+
value(): number;
|
|
1757
|
+
focused(): boolean;
|
|
1758
|
+
editing(): boolean;
|
|
1759
|
+
draft(): string;
|
|
1760
|
+
displayed(): string; // the draft while editing, else the value
|
|
1761
|
+
setValue(value: number): void;
|
|
1762
|
+
focus(): void;
|
|
1763
|
+
blur(): void; // commits a valid draft
|
|
1764
|
+
keyDown(key: string): boolean;
|
|
1765
|
+
typeInput(text: string): void; // replaces the draft; needs focus
|
|
1766
|
+
commit(): void;
|
|
1767
|
+
revert(): void;
|
|
1768
|
+
increment(): void;
|
|
1769
|
+
decrement(): void;
|
|
1770
|
+
setWrap(wrap: boolean): void;
|
|
1771
|
+
inputProps: {
|
|
1772
|
+
id;
|
|
1773
|
+
role: "spinbutton";
|
|
1774
|
+
"aria-valuenow";
|
|
1775
|
+
"aria-valuemin";
|
|
1776
|
+
"aria-valuemax";
|
|
1777
|
+
"aria-valuetext";
|
|
1778
|
+
"aria-invalid": "true" | undefined;
|
|
1779
|
+
"aria-disabled": "true" | undefined;
|
|
1780
|
+
disabled: boolean; // this is an <input>: the native attribute is a form control's disabled
|
|
1781
|
+
tabindex: 0;
|
|
1782
|
+
value;
|
|
1783
|
+
onFocus;
|
|
1784
|
+
onBlur;
|
|
1785
|
+
onKeyDown;
|
|
1786
|
+
onInput;
|
|
1787
|
+
};
|
|
1788
|
+
incrementProps: { role: "button"; tabindex: -1; "aria-label"; "aria-disabled"; onMouseDown; onClick };
|
|
1789
|
+
decrementProps: { role: "button"; tabindex: -1; "aria-label"; "aria-disabled"; onMouseDown; onClick };
|
|
1790
|
+
};
|
|
1791
|
+
```
|
|
1792
|
+
|
|
1793
|
+
`disabled` is **a widget-level extension of the APG patterns**, and the specs say so: `slider.qnt` lists it under its deliberate omissions, and `spinbutton.qnt` models the steppers' at-bound state only (`DisabledOnlyAtBound`). Neither machine has a `disabled` var, so the DOM tests, not the model, hold the primitive to these rules:
|
|
1794
|
+
|
|
1795
|
+
- a disabled slider **keeps the thumb as its tab stop** (`tabindex="0"`, the same choice `createButton` makes with `aria-disabled`) and announces `aria-disabled="true"` on the thumb; every key and every rail press is dropped before it becomes a request, so `onValueChange` never fires and `keyDown` returns `false` (nothing to preventDefault);
|
|
1796
|
+
- a disabled spinbutton's field announces `aria-disabled` **and carries the native `disabled`** (the field is an `<input>`, so the platform's own form-control rule applies); the spin keys, the typed draft (Enter and blur included) and both steppers change nothing, and **both** steppers announce `aria-disabled="true"` — the at-bound rule of the spec stays, and a disabled widget adds the other side;
|
|
1797
|
+
- both read the option on every attribute read, so a host may flip `disabled` on a mounted widget and the attributes and the guards follow in the same tick.
|
|
1798
|
+
|
|
1799
|
+
### Headless: `createKnob` (`@cssints/headless`)
|
|
1800
|
+
|
|
1801
|
+
A virtual knob after GarageBand's ([How Apple Designs a Virtual Knob](https://jherrm.github.io/knobs/)): spin around the centre, or slide vertically / horizontally, with one gesture locked per drag. **No APG pattern and no ARIA role exist for a dial**, so there is no Quint spec and no ported scenarios: `packages/headless/test/knob.browser.test.tsx` (real Chromium) is the oracle. The knob is a `role=slider` — the closest role WAI-ARIA has — with a polite live value; a numeric field must sit next to it, because a dial is never the only control. Options are plain and read lazily; `value` + `onValueChange` make the widget controlled.
|
|
1802
|
+
|
|
1803
|
+
```ts skip
|
|
1804
|
+
createKnob(options?: {
|
|
1805
|
+
min?: number; // 0
|
|
1806
|
+
max?: number; // 100
|
|
1807
|
+
step?: number; // 1
|
|
1808
|
+
value?: number | undefined; // controlled; undefined = uncontrolled
|
|
1809
|
+
defaultValue?: number; // the middle of the range, quantised to step
|
|
1810
|
+
wrap?: boolean | undefined; // false: clamp at the bounds; true: continuous, past max wraps to min
|
|
1811
|
+
gesture?: "spin" | "slide" | "both" | undefined; // "both": locked per drag from the first movement
|
|
1812
|
+
labels?: { knob?: string | undefined } | undefined; // aria-label on the knob, "Knob" by default
|
|
1813
|
+
format?: ((value: number) => string) | undefined; // aria-valuetext, String by default
|
|
1814
|
+
id?: string | undefined; // base of the value region id
|
|
1815
|
+
onValueChange?(value: number): void; // a request the host may ignore
|
|
1816
|
+
}): {
|
|
1817
|
+
value(): number;
|
|
1818
|
+
setValue(value: number): void; // a request
|
|
1819
|
+
percentOf(value: number): number; // the rotation fraction, 0..1 (× 360 for degrees)
|
|
1820
|
+
stepBy(delta: number): void; // a request
|
|
1821
|
+
knobProps: {
|
|
1822
|
+
role: "slider";
|
|
1823
|
+
tabindex: 0;
|
|
1824
|
+
"aria-valuemin";
|
|
1825
|
+
"aria-valuemax";
|
|
1826
|
+
"aria-valuenow";
|
|
1827
|
+
"aria-valuetext";
|
|
1828
|
+
"aria-label";
|
|
1829
|
+
onKeyDown;
|
|
1830
|
+
onPointerDown;
|
|
1831
|
+
onPointerMove;
|
|
1832
|
+
onPointerUp;
|
|
1833
|
+
onPointerCancel;
|
|
1834
|
+
onWheel;
|
|
1835
|
+
};
|
|
1836
|
+
valueProps: { id; role: "status"; "aria-live": "polite" };
|
|
1837
|
+
};
|
|
1838
|
+
```
|
|
1839
|
+
|
|
1840
|
+
**Gestures.** The first movement past 6 px picks the mode once: movement that stays on one axis (the other under 35% of it) is a slide, anything else is a turn around the centre, and `gesture: "spin"` / `"slide"` disables the other. The direction is the reference's "which side you turn": a vertical slide down turns clockwise (up in value) on the right half and counter-clockwise on the left half; a horizontal slide uses the same left/right side (the reference picks top/bottom for that one). A spin's value per degree shrinks with the pointer's distance from the centre — farther is finer. `percentOf` maps the value to the fraction of a full turn for the skin; `wrap` makes the dial continuous instead of clamped.
|
|
1841
|
+
|
|
1842
|
+
**Keyboard.** Arrows step, PageUp/PageDown move ten steps, Home/End jump to the bounds (no typing: the dial has no text entry). The wheel steps once per event and `preventDefault`s while the pointer is over the knob.
|
|
1843
|
+
|
|
1844
|
+
### Headless: `createDropZone` (`@cssints/headless`)
|
|
1845
|
+
|
|
1846
|
+
A file drop target. **There is no APG pattern and no Quint spec for a drop zone** — the contract is HTML's file input plus WAI-ARIA's live region — so `packages/headless/test/dropzone.browser.test.tsx` (real Chromium) is its oracle. It is the replacement for the bare `<input type="file">` fields of cms-admin's image field and kocharin's dashboard upload.
|
|
1847
|
+
|
|
1848
|
+
```ts skip
|
|
1849
|
+
createDropZone(options: {
|
|
1850
|
+
accept?: string | undefined; // the HTML accept grammar: ".png", "image/png", "image/*"
|
|
1851
|
+
multiple?: boolean | undefined; // the input's multiple, and an accept of several files per drop
|
|
1852
|
+
maxSize?: number | undefined; // bytes; a larger file is rejected with "size"
|
|
1853
|
+
disabled?: boolean | undefined; // zone aria-disabled, native disabled input and button, drops ignored
|
|
1854
|
+
onFiles(files: File[]): void; // the accepted files of one batch; not called when none passed
|
|
1855
|
+
onReject?({ file, reason }: { file: File; reason: "type" | "size" }): void; // once per refused file
|
|
1856
|
+
labels?: { zone?: string | undefined; browse?: string | undefined };
|
|
1857
|
+
}): {
|
|
1858
|
+
dragging(): boolean; // a drag is over the zone; a child's dragleave does not clear it
|
|
1859
|
+
message(): string | undefined; // "2 files, 1 rejected: too large"; undefined before the first batch
|
|
1860
|
+
inputProps: {
|
|
1861
|
+
id; // the id the browse button clicks
|
|
1862
|
+
type: "file";
|
|
1863
|
+
accept: string | undefined;
|
|
1864
|
+
multiple: true | undefined;
|
|
1865
|
+
disabled: true | undefined;
|
|
1866
|
+
hidden: true;
|
|
1867
|
+
onChange;
|
|
1868
|
+
};
|
|
1869
|
+
zoneProps: {
|
|
1870
|
+
role: "group";
|
|
1871
|
+
"aria-label": string | undefined;
|
|
1872
|
+
"aria-disabled": "true" | undefined;
|
|
1873
|
+
onDragEnter;
|
|
1874
|
+
onDragOver;
|
|
1875
|
+
onDragLeave;
|
|
1876
|
+
onDrop;
|
|
1877
|
+
};
|
|
1878
|
+
browseProps: { type: "button"; disabled: true | undefined; "aria-disabled"; "aria-label"; onClick };
|
|
1879
|
+
statusProps: { role: "status"; "aria-live": "polite"; "aria-atomic": "true" };
|
|
1880
|
+
};
|
|
1881
|
+
```
|
|
1882
|
+
|
|
1883
|
+
Principles:
|
|
1884
|
+
|
|
1885
|
+
- **The zone is never drag-only.** `browseProps` spreads onto a real `<button type="button">` that clicks the hidden input, so the picker is the keyboard path (Enter and Space are the platform's) and a pointer user can click too. The drop handlers add nothing the button cannot do.
|
|
1886
|
+
- **Validation happens once per file**, in the order type then size, with a reason the host can show: `onReject` gets the file and `"type" | "size"`, and `onFiles` gets the rest in one batch. A batch with nothing accepted calls `onFiles` not at all — not with an empty array.
|
|
1887
|
+
- **A folder or an empty `DataTransfer` is a no-op** (Chromium marks a dropped directory as a `file` item whose `webkitGetAsEntry().isDirectory` is true): no callback, no rejection, and `message()` keeps the previous sentence.
|
|
1888
|
+
- **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so `dragging()` is a depth counter: it clears when the drag leaves the zone, or on a drop. It is the styling hook; the primitive writes no `data-dragging`.
|
|
1889
|
+
- **`message()` is a default sentence for the host's status region** ("2 files, 1 rejected: too large", `undefined` before the first batch): the primitive tracks the counts of the last batch, the host decides the words. Render `statusProps` once and keep it mounted, or a change of its text is not announced.
|
|
1890
|
+
- Picking through the input is the same path as a drop and clears `input.value` afterwards, so choosing the same file twice fires `change` again.
|
|
1891
|
+
|
|
1892
|
+
### Headless: `createTimeline` (`@cssints/headless`)
|
|
1893
|
+
|
|
1894
|
+
Two modes over one axis. Without `rows` it is the package's demo tree of five fixed spans (collapsing, a zoom window, checked against `specs/apg/timeline.qnt` by the differential walk in `packages/headless/test/timeline.browser.test.tsx`). With `rows` it is a trace waterfall: real span data laid out in percentages of the rows' own extent. The rows mode has **no APG pattern** to follow, so its contract is `packages/headless/test/timeline-rows.browser.test.tsx`.
|
|
1895
|
+
|
|
1896
|
+
```ts skip
|
|
1897
|
+
createTimeline(options: {
|
|
1898
|
+
rows: ReadonlyArray<{ id: string; label: string; start: number; end: number; depth?: number; status?: "ok" | "error" | "unset" }>; // in display order
|
|
1899
|
+
axis?: { unit?: "ms" | "us" | "s" } | undefined; // "ms": the ruler's labels
|
|
1900
|
+
selected?: string | undefined; // controlled; undefined = uncontrolled
|
|
1901
|
+
onSelect?(id: string): void; // a request the host may ignore
|
|
1902
|
+
}): {
|
|
1903
|
+
extent(): { start: number; end: number }; // min start, max end; { 0, 0 } when empty
|
|
1904
|
+
depthOf(id: string): number; // 0 for a root span
|
|
1905
|
+
listProps: { role: "list"; "aria-label": string };
|
|
1906
|
+
rowProps(id: string): { role: "listitem"; "aria-level": number; "aria-current": "true" | undefined; "data-status"; tabindex; onClick; onKeyDown };
|
|
1907
|
+
barProps(id: string): { "aria-hidden": "true"; style: { left; width; "--depth" } };
|
|
1908
|
+
labelProps(id: string): { style: { "--depth" } };
|
|
1909
|
+
rulerProps: { role: "presentation"; "aria-hidden": "true"; ticks: ReadonlyArray<{ value: number; left: number; label: string }> };
|
|
1910
|
+
select(id: string): void;
|
|
1911
|
+
selectedId(): string | undefined;
|
|
1912
|
+
};
|
|
1913
|
+
```
|
|
1914
|
+
|
|
1915
|
+
`createTimeline()` with no `rows` keeps the tree API exactly as it was (`rows()`, `treeProps`, `rowProps`, zoom, collapse).
|
|
1916
|
+
|
|
1917
|
+
- **Role.** A waterfall is neither a tree (nothing collapses in rows mode) nor a grid (nothing is edited), and `aria-selected` is not allowed on a `listitem`. So the rows are `role="list"` / `role="listitem"`, the depth is `aria-level` (`depth + 1`, which `listitem` supports) and the selected row is `aria-current="true"`, which any element may carry. The demo tree keeps `role="tree"` / `"treeitem"` because it does collapse.
|
|
1918
|
+
- **Geometry.** `extent()` is across every row, so a child span is a percentage of the whole trace: `barProps(id).style.left` / `.width` are percentages of it, and a zero-length span (`start === end`) still paints because every bar has a **0.5% width floor** — 2 px on a 400 px track, the floor the o11y dashboard used when it hand-rolled these rows. An empty extent (`{ 0, 0 }`) puts every bar at `left: 0%` with that floor rather than dividing by zero.
|
|
1919
|
+
- **Depth is `--depth`** on `barProps` and `labelProps`, not nested DOM: the host indents with `padding-inline-start: calc(var(--depth) * 0.75rem)`, and the bar keeps its track when the panel is narrow.
|
|
1920
|
+
- **Ruler.** `ticks` steps the extent by 1, 2 or 5 times a power of ten (about five ticks), each with `value` on the axis, `left` in percent and a `label` in the axis unit. The ruler element is decorative; the host renders ticks as its children.
|
|
1921
|
+
- **Selection.** A click selects and moves the tab stop; ArrowUp / ArrowDown / Home / End move a roving tab stop; Enter or Space selects the focused row. The host moves real DOM focus after an arrow key, as the demo fixture does; `selected` + `onSelect` make it controlled, `select(id)` is the programmatic request.
|
|
1922
|
+
- **`status` is `data-status`** on the row, a styling hook for the host's colour. The primitive never invents the label: the host's data holds it.
|
|
1923
|
+
|
|
1562
1924
|
### Publishing a library (decided 2026-10-02, cssints-0l3f)
|
|
1563
1925
|
|
|
1564
1926
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cssints",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "CSS in TypeScript: typed css.* calls compiled to atomic CSS at build time (a Vite plugin)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
}
|
|
73
73
|
},
|
|
74
74
|
"dependencies": {
|
|
75
|
-
"@cssints/css-grammar": "^0.
|
|
75
|
+
"@cssints/css-grammar": "^0.20.0",
|
|
76
76
|
"culori": "^4.0.2",
|
|
77
77
|
"effect": "4.0.6",
|
|
78
78
|
"happy-dom": "^20.14.5",
|