cssints 0.15.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 +182 -0
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1377,6 +1377,188 @@ createPopover(options?: {
1377
1377
 
1378
1378
  `attach` is the DOM contract: on open it measures and places the content (and again on resize, scroll and a `ResizeObserver`), focuses the content's first focusable element (else the content, which is `tabindex="-1"`), and on close returns the focus to the trigger unless the close came from an outside `pointerdown` — that one leaves the focus where the page put it. The page behind stays interactive: there is no `inert`, no scroll lock and no Tab cycle, and the content is not a menu (no roving focus: `createMenu` in a popover owns its items). The rects and the viewport passed to `position` must share a space; `attach` measures in client space and writes page space for `position: absolute`.
1379
1379
 
1380
+ ### Headless: `createDrawer` (`@cssints/headless`)
1381
+
1382
+ The drawer / sheet shell: an edge-anchored panel over Dialog (Modal), with the focus, Escape, scroll-lock and backdrop behaviour of `createConfirm`. **No APG pattern exists for a drawer or a bottom sheet**; [packages/headless/docs/bottom-sheet.md](../packages/headless/docs/bottom-sheet.md) settles that the library composes instead of adding a sheet primitive (the semantics are Dialog, the position and the snap points are native CSS), and this is the shell that decision left to exist. Its oracle is `packages/headless/test/drawer.browser.test.tsx`.
1383
+
1384
+ ```ts skip
1385
+ createDrawer(options?: {
1386
+ id?: string | undefined;
1387
+ open?: boolean | undefined; // controlled: the host owns the state
1388
+ defaultOpen?: boolean | undefined;
1389
+ side?: "left" | "right" | "bottom" | undefined; // "right"; presentation only
1390
+ modal?: boolean | undefined; // true; read at every attribute read (the breakpoint switch)
1391
+ breakpoint?: number | undefined; // px; documented only: the host watches it and passes side / modal
1392
+ dismissable?: boolean | undefined; // true; a backdrop click or an outside pointerdown
1393
+ labelledby?: string | undefined; // the panel needs a name
1394
+ onOpenChange?(open: boolean): void;
1395
+ }): {
1396
+ open(): boolean;
1397
+ side(): "left" | "right" | "bottom";
1398
+ triggerProps: {
1399
+ id;
1400
+ "aria-haspopup": "dialog";
1401
+ "aria-expanded": "true" | "false";
1402
+ "aria-controls": string;
1403
+ onClick(): void; // toggles
1404
+ onKeyDown(event: KeyboardEvent): void; // Enter / Space open
1405
+ };
1406
+ backdropProps: { id; hidden: boolean; onClick(): void }; // always hidden when closed or not modal
1407
+ contentProps: {
1408
+ id;
1409
+ role: "dialog";
1410
+ "aria-modal": "true" | undefined; // by modal, and undefined while closed
1411
+ "aria-labelledby": string | undefined;
1412
+ tabindex: -1;
1413
+ hidden: boolean;
1414
+ onKeyDown(event: KeyboardEvent): void; // Escape closes
1415
+ };
1416
+ handleProps: { "aria-hidden": "true" }; // a bottom sheet's grab handle; decorative
1417
+ };
1418
+ ```
1419
+
1420
+ `side` and `modal` are the whole presentation API: a sheet is `{ side: "bottom", modal: true }`, a side rail is `{ side: "right" }`, and a non-modal rail is `{ side: "right", modal: false }`. `modal` is read at attribute-read time, so a host that watches the breakpoint may flip it while the drawer is open and the backdrop, `aria-modal` and the lock follow; `breakpoint` itself is not read, because the primitive has no viewport and the host owns the `matchMedia`.
1421
+
1422
+ A modal drawer is `aria-modal="true"` with a backdrop, focus moves to the panel's first focusable on open (else the panel, `tabindex="-1"`) and back to the trigger on close, Tab and Shift+Tab cycle inside it, `overflow: hidden` is set on `document.documentElement` (counted, and shared with `createConfirm`, so a confirm over a drawer nests), and Escape is read from the document in the capture phase, so it closes wherever the focus is. A non-modal drawer keeps the page's scroll, focus and clicks: no `aria-modal`, no backdrop, no lock, no Tab trap; an outside `pointerdown` still dismisses it when `dismissable`, and Escape still closes it. `dismissable: false` pins the drawer against the backdrop and the outside pointerdown only.
1423
+
1424
+ **No drag-to-dismiss and no `attach`.** A drag would be a JS re-implementation of the native scroll snap the host writes (see bottom-sheet.md), so `handleProps` is decoration with no pointer handlers, and the ways out are the backdrop, Escape, a close control in the panel and the trigger. The panel is edge-anchored, so nothing is measured: focus and the Tab cycle find it through `contentProps.id`.
1425
+
1426
+ ### Headless: `createTable` (`@cssints/headless`)
1427
+
1428
+ A data table on a **native `<table>`**: sortable column headers and row selection, as state and attribute objects. It has no spec of its own; it composes the two state models the package already has (`grid-sort.qnt`, `grid-selection.qnt`) without the grid's navigation, so `packages/headless/test/table.browser.test.tsx` is its oracle. The host renders `<table>`, `<caption>`, `<thead>`, `<tbody>`, `<th scope="col">` and `<td>`: the primitive adds no `role`, only what the elements cannot say themselves.
1429
+
1430
+ ```ts skip
1431
+ createTable(options: {
1432
+ columns: ReadonlyArray<{ key: string; sortable?: boolean; numeric?: boolean; abbr?: string | undefined }>; // sortable defaults true
1433
+ rows: ReadonlyArray<string>; // row ids in render order; the host sorts its own data
1434
+ selection?: "none" | "single" | "multiple" | undefined; // "none"
1435
+ sortCycle?: "tri-state" | "toggle" | undefined; // "tri-state": ascending -> descending -> unsorted
1436
+ sort?: TableSort | null | undefined; // controlled; null = unsorted, undefined = uncontrolled
1437
+ defaultSort?: TableSort | undefined;
1438
+ onSortChange?(sort: TableSort | undefined): void;
1439
+ selected?: ReadonlyArray<string> | undefined; // controlled
1440
+ defaultSelected?: ReadonlyArray<string> | undefined;
1441
+ onSelectionChange?(selected: ReadonlyArray<string>): void;
1442
+ rowCount?: number | undefined; // only when the host windows its rows
1443
+ columnCount?: number | undefined; // only when it windows its columns
1444
+ labels?: { table?: string | undefined; selectAll?: string | undefined; selectRow?: ((id: string) => string) | undefined } | undefined;
1445
+ id?: string | undefined;
1446
+ }): {
1447
+ selectionMode(): "none" | "single" | "multiple";
1448
+ sort(): TableSort | undefined;
1449
+ selected(): ReadonlyArray<string>;
1450
+ isSelected(id: string): boolean;
1451
+ tableProps: { "aria-label"; "aria-rowcount"; "aria-colcount" }; // the counts only while windowed
1452
+ captionProps: { id };
1453
+ headerCellProps(key: string): { scope: "col"; "aria-sort": "ascending" | "descending" | undefined; abbr };
1454
+ headerButtonProps(key: string): { type: "button"; onClick } | undefined; // undefined when not sortable
1455
+ rowProps(id: string): { "aria-selected"; onClick };
1456
+ cellProps(rowId: string, key: string): { "aria-colindex" }; // only while windowed
1457
+ selectAllProps: { role: "checkbox"; "aria-checked": "true" | "false" | "mixed"; "aria-label"; tabindex: 0; onClick; onKeyDown } | undefined;
1458
+ rowCheckboxProps(id: string): { role: "checkbox"; "aria-checked"; "aria-label"; tabindex: 0; onClick; onKeyDown } | undefined;
1459
+ toggleAll(): void;
1460
+ toggleRow(id: string): void;
1461
+ };
1462
+ ```
1463
+
1464
+ `aria-sort` is carried by the sorted column only; a sortable header that is not sorted has none (ARIA's "not sorted" default), and a `sortable: false` column gets no button from `headerButtonProps` at all, so it is not a tab stop. The header button cycles `ascending` -> `descending` -> unsorted by default (so the table can be put back in its own order) and `ascending` <-> `descending` with `sortCycle: "toggle"` (what the APG grid example does); a new column always starts `ascending`, and only one is sorted. Sorting is the host's data: the primitive carries the descriptor and announces it, it never reorders a row.
1465
+
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
+
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
+
1380
1562
  ### Publishing a library (decided 2026-10-02, cssints-0l3f)
1381
1563
 
1382
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.15.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.15.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",