cssints 0.14.0 → 0.17.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 +132 -0
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1291,6 +1291,49 @@ createConfirm(options?: { id?: string | undefined; onOpenChange?(open: boolean):
1291
1291
 
1292
1292
  `confirm()` resolves `true` on confirm and `false` on cancel, Escape and a backdrop click. A second request while one is pending rejects with `ConfirmPendingError` (it is not queued). Focus goes to the cancel button on open and back to the element that had it when `confirm()` was called. While it is open the dialog has a shell: Tab and Shift+Tab cycle inside it, `overflow: hidden` is set on `document.documentElement` (restored on close; counted, so nested confirms restore it once), and Escape is read from the document in the capture phase, so it works with the focus outside the dialog. Confirms from different `createConfirm` calls may be open together: the last opened gets Escape and Tab. The default labels stay English; a translating host passes `confirmLabel` and `cancelLabel` with each request.
1293
1293
 
1294
+ ### Headless: `createToast` and `createStatus` (`@cssints/headless`)
1295
+
1296
+ Two live regions with no APG pattern and no Quint spec: `createStatus` is one polite message whose text is replaced, `createToast` queues toasts whose countdown pauses. Their oracle is `packages/headless/test/toast.browser.test.tsx`.
1297
+
1298
+ ```ts skip
1299
+ createStatus(options?: { id?: string | undefined }): {
1300
+ message(): string | undefined;
1301
+ show(text: string): void; // replaces the text; nothing is queued
1302
+ clear(): void;
1303
+ statusProps: { id; role: "status"; "aria-live": "polite"; "aria-atomic": "true" };
1304
+ };
1305
+
1306
+ createToast(options?: {
1307
+ timeout?: number | undefined; // ms, 5000; 0 dismisses on the next tick
1308
+ max?: number | undefined; // visible toasts, 3; a new one past it drops the oldest
1309
+ pauseOnHover?: boolean | undefined; // true; focus always pauses
1310
+ labels?: { region?: string | undefined } | undefined; // "Notifications"
1311
+ onDismiss?(id: string): void; // every departure: timeout, dismiss, dismissAll, ceiling
1312
+ }): {
1313
+ toasts(): readonly ToastItem[]; // oldest first
1314
+ show(input: {
1315
+ title: string;
1316
+ description?: string | undefined;
1317
+ tone?: "info" | "success" | "error" | undefined; // "info"
1318
+ }): string; // the id
1319
+ dismiss(id: string): void;
1320
+ dismissAll(): void;
1321
+ regionProps: { role: "region"; "aria-label": string }; // the F6 target
1322
+ toastProps(id: string): {
1323
+ role: "alert" | "status"; // "alert" for tone "error", "status" otherwise
1324
+ "aria-atomic": "true";
1325
+ onMouseEnter(): void;
1326
+ onMouseLeave(): void;
1327
+ onFocusIn(): void;
1328
+ onFocusOut(): void;
1329
+ };
1330
+ };
1331
+ ```
1332
+
1333
+ `createStatus` renders one `role=status` region that the host keeps mounted; `show()` replaces its text, so a burst of "Link copied" / "Saved" reads as the last state, not as a queue. A status is never for errors: an error is `createAlert` (`role=alert`), and a titled message with a timeout is a toast.
1334
+
1335
+ A toast is its own live region inside the `role=region` landmark: `role=alert` for `tone: "error"` (assertive) and `role=status` otherwise (polite). The host renders the `title` and `description` handed to `show` inside that element, so the toast's own text is the announcement — `aria-atomic` reads all of it, a close button's label included, so keep a toast to its title and description and give a close button no more than an icon with a label. The role sits on the toast rather than on one container per tone: the container is the `region` landmark, so it cannot also be the live region, and a role per toast needs no second region from the host. One timer per toast honours `timeout`; hover pauses it when `pauseOnHover` is true and focus always pauses, and both resume with the time that was left rather than restarting the deadline. `max` drops the oldest toast past the ceiling, and `onDismiss` fires for every toast that leaves. F6 into the region is the host's handler (hotkeys are a separate bean, cssints-h0d5): give `regionProps` an element with `tabindex="-1"` and focus it, remembering where focus was.
1336
+
1294
1337
  ### Headless: `createPopover` (`@cssints/headless`)
1295
1338
 
1296
1339
  **No APG pattern exists** for a popover (APG has Dialog, which is modal), so it has no Quint spec and no ported scenarios: `packages/headless/test/popover.browser.test.tsx` is its oracle. The behaviour is copied from Base UI's Popover (a trigger click toggles, Enter / Space / ArrowDown open, an outside `pointerdown` light-dismisses without stealing focus) and React Aria's `usePopover` (focus into the content on open and back to the trigger on close, `role="dialog"` without `aria-modal`, a placement that flips at the viewport edge).
@@ -1334,6 +1377,95 @@ createPopover(options?: {
1334
1377
 
1335
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`.
1336
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
+ f812d87e (headless: a data table with sortable headers and row selection)
1337
1469
  ### Publishing a library (decided 2026-10-02, cssints-0l3f)
1338
1470
 
1339
1471
  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.14.0",
3
+ "version": "0.17.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.14.0",
75
+ "@cssints/css-grammar": "^0.17.0",
76
76
  "culori": "^4.0.2",
77
77
  "effect": "4.0.6",
78
78
  "happy-dom": "^20.14.5",