cssints 0.17.0 → 0.18.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 +94 -1
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1465,7 +1465,100 @@ 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
- f812d87e (headless: a data table with sortable headers and row selection)
1468
+
1469
+ ### Headless: `createHotkeys` (`@cssints/headless`)
1470
+
1471
+ One registry for the chords every app hand-rolls (`/` focuses search, Cmd/Ctrl+K opens it, Cmd/Ctrl+S saves). **A platform utility rather than a widget**: no APG pattern exists for a keyboard registry, so it has no role, no element, no styling, no Quint spec and no ported scenarios; `packages/headless/test/hotkeys.browser.test.tsx` is its oracle.
1472
+
1473
+ ```ts skip
1474
+ const hotkeys = createHotkeys({ isApple: false }); // isApple is injectable; default reads the platform
1475
+ const unbind = hotkeys.bind({
1476
+ keys: "mod+k", // mod = Cmd on macOS, Ctrl elsewhere; then ctrl / alt / shift / meta, then one key
1477
+ onTrigger: (event) => search.focus(),
1478
+ when: () => dialog.open === false, // gates it: false makes the next matching binding fire
1479
+ preventDefault: true, // default; false lets the browser act too
1480
+ allowInEditable: false, // default; true fires with the focus in an input / textarea / contenteditable
1481
+ scope: panel, // only events from inside this element; it adds no listener
1482
+ });
1483
+ unbind(); // idempotent
1484
+ hotkeys.dispose(); // unbinds all and removes the document listener; a later bind is a no-op
1485
+ ```
1486
+
1487
+ `keys` is a chord string: optional modifiers `mod`, `ctrl`, `alt`, `shift`, `meta`, then exactly one key (`k`, `/`, `?`, `escape`, `enter`, `arrowup`), joined with `+`. `mod` is resolved at `createHotkeys` time, once. A shifted character implies Shift (`"?"` fires from Shift+/, `"/"` does not), and the other modifiers must match the event exactly, so a chord without `shift` does not fire while Shift is held.
1488
+
1489
+ 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
+
1491
+
1492
+ ### Headless: `createScrollspy` (`@cssints/headless`)
1493
+
1494
+ 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.
1495
+
1496
+ ```ts skip
1497
+ createScrollspy(options: {
1498
+ entries: ReadonlyArray<{ id: string; label: string }>; // document order; the ids are the headings'
1499
+ offset?: number | undefined; // px below the scrollport's top edge where a heading counts as current; 0
1500
+ root?: (() => HTMLElement | undefined) | undefined; // the scroll container; the document by default
1501
+ onChange?(id: string | undefined): void;
1502
+ }): {
1503
+ active(): string | undefined; // the last heading at or above the offset, else the first entry
1504
+ navProps: { "aria-label": string }; // "On this page"; override after the spread to translate
1505
+ listProps: { role: "list" };
1506
+ itemProps(id: string): { role: "listitem" };
1507
+ linkProps(id: string): { href: string; "aria-current": "location" | undefined }; // exactly one link is current
1508
+ attach(): () => void; // one passive scroll listener; returns the teardown
1509
+ };
1510
+ ```
1511
+
1512
+ `attach()` adds **one passive `scroll` listener** to `root()`, which measures each heading's `getBoundingClientRect`, syncs once (a page opened mid-scroll starts correct), and returns the teardown. Not an `IntersectionObserver`: "the last heading at or above the offset" is a total order over all headings, while an observer reports only what crossed a band, so it has to rebuild that order from enter/leave state and misreports after a jump. Smooth scrolling is the host's (`scroll-behavior: smooth`, and any `prefers-reduced-motion` override): the links are plain `#id` targets and the primitive never moves the scrollport. The first entry is current before any scroll and while nothing has passed the line.
1513
+
1514
+ f812d87e (headless: a data table with sortable headers and row selection)
1515
+
1516
+ ### Headless: `createPanZoom` (`@cssints/headless`)
1517
+
1518
+ **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.
1519
+
1520
+ ```ts skip
1521
+ createPanZoom(options?: {
1522
+ min?: number | undefined; // 1, and where reset returns
1523
+ max?: number | undefined; // 4
1524
+ step?: number | undefined; // the + / - increment, 0.25
1525
+ defaultScale?: number | undefined;
1526
+ scale?: number | undefined; // controlled: the host owns the scale
1527
+ onScaleChange?(scale: number): void;
1528
+ onPanChange?(offset: { x: number; y: number }): void;
1529
+ bounds?: "clamp" | "free" | undefined; // "clamp": the image never leaves a gap
1530
+ labels?: { viewport?: string | undefined } | undefined; // the viewport's name, "Image"
1531
+ }): {
1532
+ scale(): number;
1533
+ offset(): { x: number; y: number };
1534
+ reset(): void; // back to min and centred
1535
+ zoomIn(): void;
1536
+ zoomOut(): void;
1537
+ setScale(scale: number, origin?: { x: number; y: number }): void; // origin in the viewport's own space, its centre by default
1538
+ viewportProps: {
1539
+ tabindex: 0;
1540
+ role: "group";
1541
+ "aria-roledescription": "zoomable image";
1542
+ "aria-label";
1543
+ "aria-describedby";
1544
+ onKeyDown; // + / = / - zoom, arrows pan by 24 px (Shift x4), 0 resets
1545
+ onWheel; // ctrl / meta only: the browser's pinch, zoomed about the cursor
1546
+ onPointerDown;
1547
+ onPointerMove;
1548
+ onPointerUp;
1549
+ onPointerCancel;
1550
+ onDblClick;
1551
+ };
1552
+ imageProps: { draggable: false; style: { transform: string } }; // translate(..) scale(..)
1553
+ valueProps: { id; role: "status"; "aria-live": "polite"; "aria-atomic": "true" };
1554
+ value(): string; // "150%"
1555
+ };
1556
+ ```
1557
+
1558
+ 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.
1559
+
1560
+ 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
+
1469
1562
  ### Publishing a library (decided 2026-10-02, cssints-0l3f)
1470
1563
 
1471
1564
  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.17.0",
3
+ "version": "0.18.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.17.0",
75
+ "@cssints/css-grammar": "^0.18.0",
76
76
  "culori": "^4.0.2",
77
77
  "effect": "4.0.6",
78
78
  "happy-dom": "^20.14.5",