cssints 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/docs/API.md +150 -0
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1469,6 +1469,8 @@ Selection is over ids, so it survives the host's re-sort. `rowProps` carries `ar
1469
1469
 
1470
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
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
+
1472
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.
1473
1475
 
1474
1476
  ```ts skip
@@ -1546,6 +1548,48 @@ createScrollspy(options: {
1546
1548
 
1547
1549
  f812d87e (headless: a data table with sortable headers and row selection)
1548
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
+
1549
1593
  ### Headless: `createPanZoom` (`@cssints/headless`)
1550
1594
 
1551
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.
@@ -1592,6 +1636,67 @@ Wheel zooms **only with `ctrl` / `meta`** held: the browser's pinch gesture arri
1592
1636
 
1593
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).
1594
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
+
1595
1700
  ### Headless: `createSlider` and `createSpinbutton` (`@cssints/headless`)
1596
1701
 
1597
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.
@@ -1691,6 +1796,51 @@ createSpinbutton(options?: {
1691
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;
1692
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.
1693
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
+
1694
1844
  ### Headless: `createDropZone` (`@cssints/headless`)
1695
1845
 
1696
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssints",
3
- "version": "0.19.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.19.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",