cssints 0.26.0 → 0.27.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 +125 -1
- package/package.json +2 -2
package/docs/API.md
CHANGED
|
@@ -1245,6 +1245,45 @@ More plugins on `scope()`, each a package of its own with its rules and limits i
|
|
|
1245
1245
|
| [`@cssints/skeleton`](../packages/skeleton/README.md) | `skeleton().radius().color().highlight()` | Loading placeholders whose shimmer is in phase across the page (one clock on `:root`), static under reduced motion |
|
|
1246
1246
|
| [`@cssints/reactive`](../packages/reactive/README.md) | `state()`, `toggle()`, `set()`, `value()`, `checked()`, `persist()`, `equals()`, `either()`, `on()`, `emit()`, `bind()`, `when()`, `inside()` | State as custom properties, events as data on the element: writes only in events, conditions and DOM output in plain CSS, wired by a 1.4 kB runtime shipped to every target; `value()`/`checked()` take the field's value, calls of `on()`, `bind()` or `state()` in one `cn()` append, `persist(value, key)` keeps a state across a reload, and `bind()` takes a source, a comparison (`equals()`), a choice of two literals (`either()`) or a literal — the one expression the runtime evaluates, because an attribute on the element cannot be computed in CSS (2026-10-31, cssints-ac3o). `emit(name)` is the one hand-off to JavaScript: `on({ submit: emit("save") })` writes no state and dispatches a bubbling `reactive:save` from the element the native event fired on, with `{ event, owner }` in its detail, which `listen(target, name, handler)` from `@cssints/reactive/runtime` hears and whose disposer it returns (2026-10-31, cssints-4s8d) |
|
|
1247
1247
|
|
|
1248
|
+
### A state machine for UI protocols: `@cssints/machine` (cssints-im52)
|
|
1249
|
+
|
|
1250
|
+
A signal holds state, not a protocol. `@cssints/machine` is the protocol: a state machine for the behaviour a component grows the moment it animates — a `closing` and an `opening` in one frame, a cancelled animation that still reports back, an interrupted close that reverses from where the box is, a burst of pointer events, a debounce that must not swallow the last intent. It has no dependencies; `./solid` is the only file that imports anything, and solid-js is its optional peer. The README has the animated-disclosure example and the five properties; `test/machine.mjs` asserts them in plain node.
|
|
1251
|
+
|
|
1252
|
+
```ts skip
|
|
1253
|
+
defineMachine(config: {
|
|
1254
|
+
initial: Context; // the context, inferred from this object
|
|
1255
|
+
states: { [name]: {
|
|
1256
|
+
on?: { [event]: transition | transition[] }; // the event union; the first guard that passes wins
|
|
1257
|
+
after?: { [ms]: transition }; // cleared when the state is left
|
|
1258
|
+
effect?: EffectSpec[]; // issued on entry, cancelled on exit
|
|
1259
|
+
}};
|
|
1260
|
+
start?: StateName; // the first key of states by default
|
|
1261
|
+
debounce?: { [event]: ms };
|
|
1262
|
+
}): MachineDefinition;
|
|
1263
|
+
|
|
1264
|
+
defineMachine<Payloads>()(config) // typed payloads: { close: undefined; finish: { id: number } }
|
|
1265
|
+
createMachine(definition, { clock?, autoStart?, onEffect?, onCancel?, onTransition? }): Machine
|
|
1266
|
+
|
|
1267
|
+
transition = StateName | { target, guard?(context, event), effect?, assign? }
|
|
1268
|
+
|
|
1269
|
+
Machine: getState() · subscribe(listener) · subscribe(selector, listener) · send(event, { debounce? }) ·
|
|
1270
|
+
flush() · cancel(type?) · effects() · onEffect/onCancel/onTransition · start() · destroy()
|
|
1271
|
+
|
|
1272
|
+
harness(definition): { machine, clock, log, send, enqueue, deliver, deliverAll, advance, race, interleave,
|
|
1273
|
+
pendingEffects, flushEffects, cancelled }
|
|
1274
|
+
|
|
1275
|
+
createMachineStore(machine, { settled?, intent? }): { state, optimistic, projection, send } // ./solid
|
|
1276
|
+
```
|
|
1277
|
+
|
|
1278
|
+
- **Events are a union derived from the `on` keys**, the context is inferred from `initial`, and a `send` the states do not accept is a type error (`test/types/machine.ts`). A payload map, declared once as the type argument, types the payloads; a guard or an `assign` narrows on `event.type`.
|
|
1279
|
+
- **Effects are data**, not callbacks: `{ type: "animate", ... }`. The machine issues a handle per effect (`{ id, type, spec, state }`) and cancels it when the state is left or the transition is superseded. A completion is an event carrying the handle's numeric `id`: a stale id is dropped, a live one completes without a cancellation. `effects()` is what the host should be running now.
|
|
1280
|
+
- **Debounce** coalesces a burst by event type; `flush()` delivers the final intent, `cancel(type)` drops a superseded one. The same clock drives `after` and a transition's timestamp.
|
|
1281
|
+
- **The clock is injected** (`systemClock` is the default); the harness's fake clock moves only on `advance(ms)`, and the harness logs `{ at, event, from, to, effect, context, cancelled }` per delivery (and per effect it issued).
|
|
1282
|
+
- **The state is an external store**: `getState()`, `subscribe(listener)` and `subscribe(selector, listener)`, the last one firing only when the slice changes.
|
|
1283
|
+
- **`./solid`**: `state` is a `createStore` of `{ ...context, phase }` written once per transition, mutating only the changed fields; `optimistic` is a derived `createOptimisticStore` whose intent, written inside a Solid `action`, reverts to the committed snapshot at settle (a superseded intent rolls back); `projection` is a `createProjection` over `state`, so a reader of a view's field does not re-run when another field changes. A projection reads the committed frame, not an optimistic override, so the visual phase is read off `optimistic` directly. One store write per transition is the atomicity the reactive runtime gets from its one flush (cssints-tm9j).
|
|
1284
|
+
|
|
1285
|
+
Limits: one state at a time (no parallel states, history states or nested machines), effects are data the host runs, and a numeric event `id` is reserved for an effect handle. There is no Quint spec for this runtime yet; the harness is the oracle.
|
|
1286
|
+
|
|
1248
1287
|
### Headless: `createField` and `createConfirm` (`@cssints/headless`)
|
|
1249
1288
|
|
|
1250
1289
|
Two primitives with no APG pattern behind them, so no Quint spec: `createField` is the ARIA contract of a form field, `createConfirm` a promise over `createDialogModal({ role: "alertdialog" })`. Like the rest of the package they return state and attribute objects, no elements, no styling; the README has the examples.
|
|
@@ -1319,8 +1358,10 @@ createToast(options?: {
|
|
|
1319
1358
|
description?: string | undefined;
|
|
1320
1359
|
tone?: "info" | "success" | "error" | undefined; // "info"
|
|
1321
1360
|
}): string; // the id
|
|
1322
|
-
dismiss(id: string): void;
|
|
1361
|
+
dismiss(id: string): void; // removes now, skipping whatever a renderer would animate
|
|
1323
1362
|
dismissAll(): void;
|
|
1363
|
+
requestDismiss(id: string): void; // asks the renderer to remove it; removes at once when none claims it
|
|
1364
|
+
onDismissRequest(handler: (id: string) => boolean): () => void; // the expiry: true when this row owns it, then dismiss
|
|
1324
1365
|
regionProps: { role: "region"; "aria-label": string }; // the F6 target
|
|
1325
1366
|
toastProps(id: string): {
|
|
1326
1367
|
role: "alert" | "status"; // "alert" for tone "error", "status" otherwise
|
|
@@ -1337,6 +1378,8 @@ createToast(options?: {
|
|
|
1337
1378
|
|
|
1338
1379
|
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.
|
|
1339
1380
|
|
|
1381
|
+
The countdown does not remove a toast itself: its expiry calls every `onDismissRequest` handler and the removal is the renderer's — a handler returns `true` when it owns that row and calls `dismiss(id)` once the row has finished leaving, so a departure can be animated. With no handler, none that claims the id (a row that has not mounted yet), and for `requestDismiss(id)`, the toast leaves at once, as `dismiss(id)` always does; `dismissAll()` does too, and so does the ceiling, which replaces the dropped row with the new one.
|
|
1382
|
+
|
|
1340
1383
|
### Headless: `createPopover` (`@cssints/headless`)
|
|
1341
1384
|
|
|
1342
1385
|
**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).
|
|
@@ -1553,6 +1596,40 @@ createScrollspy(options: {
|
|
|
1553
1596
|
|
|
1554
1597
|
f812d87e (headless: a data table with sortable headers and row selection)
|
|
1555
1598
|
|
|
1599
|
+
### Headless: `createVirtualizer` (`@cssints/headless`)
|
|
1600
|
+
|
|
1601
|
+
A window over a list of thousands: the host renders the visible rows plus an overscan, and two spacer boxes stand in for the rest, so the declared DOM, the content height and the scroll's clamp and thumb are the whole list's. It ports the rules the native renderer had (`native/solid/src/virtual.ts`: `fixedWindow`, `measuredWindow`, `OVERSCAN` and the gap-compensated spacers); the DOM adds the scrollport (`attach`), a `ResizeObserver` (the native renderer reads a row's box from `ui.bounds` after layout, which the DOM has no equivalent of) and the ARIA pair on every declared row. **No APG pattern exists for virtualisation and no Quint machine**: the state is pixels, not items, so `packages/headless/test/virtual.browser.test.tsx` is the oracle.
|
|
1602
|
+
|
|
1603
|
+
```ts skip
|
|
1604
|
+
createVirtualizer(options: {
|
|
1605
|
+
count: number | (() => number); // the whole list, read on every window calculation
|
|
1606
|
+
itemSize?: number | ((index: number) => number) | undefined; // a fixed px height, or a per-row estimate; omit to measure
|
|
1607
|
+
gap?: number | undefined; // the column's gap, px; 0
|
|
1608
|
+
overscan?: number | undefined; // declared either side of the viewport; OVERSCAN (2)
|
|
1609
|
+
estimate?: number | undefined; // one height for the unmeasured rows; the mean of the measured, else 190
|
|
1610
|
+
}): {
|
|
1611
|
+
window(): { first: number; last: number; above: number; below: number; content: number };
|
|
1612
|
+
rangeCount(): number; // last - first + 1, or 0 (nothing to declare)
|
|
1613
|
+
scrollProps: { onScroll(event: Event): void }; // spread on the scrollport; attach already listens
|
|
1614
|
+
itemProps(index: number): {
|
|
1615
|
+
"aria-setsize": number; // the whole count
|
|
1616
|
+
"aria-posinset": number; // index + 1
|
|
1617
|
+
ref(element: HTMLElement): void; // the measured mode's observer wiring; a no-op when fixed
|
|
1618
|
+
};
|
|
1619
|
+
spacerProps(position: "start" | "end"): { "aria-hidden": "true"; style: { height: string } };
|
|
1620
|
+
scrollToIndex(index: number): void; // the least scroll that shows the row
|
|
1621
|
+
attach(element: HTMLElement): () => void; // the scrollport and the observers; returns the teardown
|
|
1622
|
+
};
|
|
1623
|
+
```
|
|
1624
|
+
|
|
1625
|
+
A number `itemSize` is the arithmetic window: every row is that tall and row `n` starts at `n·(itemSize + gap)`. A function, or no `itemSize` at all, is the measured mode: a height per index arrives from the `ResizeObserver` on the declared rows, and an unmeasured row is the per-row estimate, else `estimate`, else the mean of what has been measured, else 190. The measured mode **measures once, then virtualises**: while any row's height is unknown the whole column is declared (one frame pays the whole list's layout), and the frame after it has every height, so the content height, the clamp and the thumb do not move as rows are first seen. `count` shrinking drops only the cached heights past the new count; growing it again leaves the new indexes unknown and the pass runs for them.
|
|
1626
|
+
|
|
1627
|
+
The items and the spacers are children of **one gap-spaced column** (`display: flex; flex-direction: column; row-gap: gap`, or a grid with `row-gap`), and a spacer's height is already the gap short (`above = first·(itemSize + gap) - gap`), so the first declared row starts at its real place. At an end there is **no** spacer: a zero-height box would still add the column's gap, so a host renders one only while its height is positive — `window().above > 0` / `window().below > 0`, or the `spacerProps` height.
|
|
1628
|
+
|
|
1629
|
+
Every declared row carries `aria-setsize` (the whole count) and `aria-posinset` (index + 1), so a screen reader announces "row 4,321 of 10,000" while only a screenful exists. A table host adds `aria-rowindex` itself: it counts the header row, which this primitive cannot know (and `createTable` already carries `aria-rowcount`). **Focus is the host's**: a focused row that leaves the window unmounts, so the host calls `scrollToIndex` when its keys move focus, before the new row must exist — the list declares the target, the host focuses it. `scrollToIndex` is the least scroll that shows the row (no move when it is already fully visible). In the measured mode its first offset is only an estimate for a row not measured yet, so the primitive keeps the target and lands again as each height arrives, until every height is in; a scroll the host makes by hand cancels the pending jump, because the reader has taken over.
|
|
1630
|
+
|
|
1631
|
+
`attach` only records the element, adds a passive `scroll` listener, observes the scrollport's size and (measured mode) the rows' heights, and returns the teardown: it **creates no computation**, so a host may call it from a `ref`, as `createPopover` learned to allow. The measured mode requires it; the fixed mode works from `scrollProps` alone but then a resized scrollport is not seen. `scrollProps.onScroll` is the same sync as a prop, for a host that wires scrolling itself. The scrollport and its column must have no padding (the arithmetic starts at the content's 0), horizontal virtualisation and a sticky header are not here, and the measured window rebuilds a prefix sum per call (fine for a feed, not for a dataset).
|
|
1632
|
+
|
|
1556
1633
|
### Headless: `createCommandPalette` (`@cssints/headless`)
|
|
1557
1634
|
|
|
1558
1635
|
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`.
|
|
@@ -2167,6 +2244,53 @@ createDiagramFocus(options: {
|
|
|
2167
2244
|
- **The lists are getters.** Pass `() => nodes()` and the arrays are read at attribute-read time: an async model arrives without rebuilding the primitive, and the tab stop keeps its node while that node exists (else it falls back to the first). A node id that is not in `nodes` is a no-op in `nodeProps` and `focus`; an edge whose endpoint is missing is ignored everywhere.
|
|
2168
2245
|
- **The host owns the DOM.** Nothing is measured, nothing is positioned and nothing is focused by the primitive; the host renders the nodes where its layout put them, the edges on its SVG layer, and the `role="status"` paragraph.
|
|
2169
2246
|
|
|
2247
|
+
### Headless: `createRearrangeableListbox` (`@cssints/headless`)
|
|
2248
|
+
|
|
2249
|
+
The APG rearrangeable listbox: two lists that share one option set, with the option's index as its identity. Single-select, selection follows the focus and Alt+Up / Alt+Down reorder the focused list; multi-select adds Space to toggle and Enter / Delete to transfer the selected options to the other list, where they arrive deselected. Its behaviour is `packages/headless/test/listbox-rearrangeable.browser.test.tsx`, a differential walk against `specs/apg/listbox-rearrangeable.qnt` in six configurations (single and multi, uncontrolled, controlled accepting and controlled holding a request).
|
|
2250
|
+
|
|
2251
|
+
```ts skip
|
|
2252
|
+
createRearrangeableListbox(options: {
|
|
2253
|
+
items: ReadonlyArray<string>; // labels; the option id is the index, and every option starts in list A
|
|
2254
|
+
multi?: boolean; // false: one selection, which follows the focus
|
|
2255
|
+
value?: ReadonlyArray<number> | undefined; // controlled selection (option ids); undefined = uncontrolled
|
|
2256
|
+
defaultValue?: ReadonlyArray<number>; // the selection at mount
|
|
2257
|
+
id?: string; // base of `<id>-option-<i>`; generated when absent
|
|
2258
|
+
labels?: Readonly<Record<"a" | "b", string>>; // each list's aria-label
|
|
2259
|
+
onValueChange?(selected: ReadonlyArray<number>): void; // a request the host may ignore
|
|
2260
|
+
}): {
|
|
2261
|
+
items: ReadonlyArray<string>;
|
|
2262
|
+
order(list: "a" | "b"): ReadonlyArray<number>; // the option ids a list holds, in order
|
|
2263
|
+
active(list: "a" | "b"): number | null; // the active option, null when the list is empty
|
|
2264
|
+
focusedList(): "a" | "b" | null;
|
|
2265
|
+
selected(): ReadonlyArray<number>;
|
|
2266
|
+
multi(): boolean;
|
|
2267
|
+
focus(list: "a" | "b"): void;
|
|
2268
|
+
blur(): void;
|
|
2269
|
+
keyDown(key: string, modifiers?: { shift?: boolean; alt?: boolean; ctrl?: boolean; meta?: boolean }): void;
|
|
2270
|
+
move(direction: "up" | "down"): void; // swap the focused list's active option with its neighbour
|
|
2271
|
+
transfer(): void; // move the focused list's selected options to the other list
|
|
2272
|
+
setMulti(flag: boolean): void;
|
|
2273
|
+
listProps(list: "a" | "b"): {
|
|
2274
|
+
role: "listbox";
|
|
2275
|
+
tabindex: 0;
|
|
2276
|
+
"aria-multiselectable": "true" | "false";
|
|
2277
|
+
"aria-activedescendant": string | undefined; // only while this list has the primitive's focus
|
|
2278
|
+
"aria-label": string | undefined;
|
|
2279
|
+
onFocus;
|
|
2280
|
+
onBlur;
|
|
2281
|
+
onKeyDown;
|
|
2282
|
+
};
|
|
2283
|
+
optionProps(optionId: number): { id: string; role: "option"; "aria-selected": "true" | "false"; onClick };
|
|
2284
|
+
};
|
|
2285
|
+
```
|
|
2286
|
+
|
|
2287
|
+
- **One option set, two orders.** `items` is read once; `order("a")` and `order("b")` give the two lists. An option is never copied: a transfer removes it from one order and appends it to the other. A transferred option arrives deselected.
|
|
2288
|
+
- **Selection follows the focus in single-select.** Focusing a list makes its active option the selection; ArrowUp / ArrowDown move the active option (and the selection with it), Home / End jump to the ends, and Alt+ArrowUp / Alt+ArrowDown swap the active option with its neighbour. `move` is that swap as a method, for a host's own button or drag.
|
|
2289
|
+
- **Multi-select is the APG's second example.** `Space` toggles the active option, Enter or Delete transfers every selected option of the focused list, and the orders survive the move.
|
|
2290
|
+
- **Controlled, a transfer waits for the host.** With `value` set, the list surgery is applied only when the `value` prop comes back equal to the request's intent; a host that holds the value moves nothing. Reordering (`move`) is not gated: it changes the primitive's own order arrays, not the selection.
|
|
2291
|
+
- **The primitive owns the ARIA of a listbox, not the position or the drag.** It writes `role`, `tabindex`, `aria-multiselectable`, `aria-activedescendant`, `aria-selected` and the handlers; it does not write `aria-posinset` / `aria-setsize` (a listbox's order is implicit), and it has no pointer drag. A host that wants a drag translates it to `move`; the `reorder` starter in `apps/ui` does exactly that, and adds the position attributes itself.
|
|
2292
|
+
- **Both lists or one.** A host renders `listProps("a")` and `listProps("b")` for the full pattern, or the first alone for a reorderable list. With one list, Enter and Delete have nowhere to transfer, so the host ignores them.
|
|
2293
|
+
|
|
2170
2294
|
### Publishing a library (decided 2026-10-02, cssints-0l3f)
|
|
2171
2295
|
|
|
2172
2296
|
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.27.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.27.0",
|
|
76
76
|
"culori": "^4.0.2",
|
|
77
77
|
"effect": "4.0.6",
|
|
78
78
|
"happy-dom": "^20.14.5",
|