@vielzeug/codex 1.0.2 → 1.0.4
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/data/.cache.json +32 -31
- package/data/llms-full.txt +1433 -1678
- package/data/llms.txt +5 -1
- package/data/vielzeug-data.json +5563 -3999
- package/package.json +3 -3
package/data/llms-full.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vielzeug — Full Documentation
|
|
2
2
|
|
|
3
|
-
> Complete documentation for all
|
|
3
|
+
> Complete documentation for all 31 Vielzeug packages. Version: 1.0.3
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -1211,6 +1211,569 @@ const price = currency({ amount: 123456n, currency: 'USD' }); // $1,234.56
|
|
|
1211
1211
|
- Type checking utilities (id: `typed-is`)
|
|
1212
1212
|
|
|
1213
1213
|
|
|
1214
|
+
---
|
|
1215
|
+
|
|
1216
|
+
## @vielzeug/assay
|
|
1217
|
+
|
|
1218
|
+
**Category:** testing
|
|
1219
|
+
**Keywords:** testing, dom, events, queries, waitfor, custom-elements, jsdom
|
|
1220
|
+
**Key exports:** within, query, queryAll, queryByTestId, queryAllByTestId, queryByText, queryAllByText, queryInShadow, queryAllInShadow, queryPart, getSlotted, fire (+7 more)
|
|
1221
|
+
**Related:** ore, refine
|
|
1222
|
+
|
|
1223
|
+
### Overview
|
|
1224
|
+
|
|
1225
|
+
## Why Assay?
|
|
1226
|
+
|
|
1227
|
+
Testing DOM-level code (custom elements, vanilla event handlers, framework-rendered output) usually means hand-rolling `dispatchEvent` boilerplate and ad-hoc polling loops, or pulling in a full testing-library dependency tied to one framework's rendering model. Assay extracts just the generic, framework-agnostic pieces — scoped queries, event dispatch, async waiting — as a standalone, zero-dependency package.
|
|
1228
|
+
|
|
1229
|
+
```ts
|
|
1230
|
+
// Before — hand-rolled event dispatch and polling in every test file
|
|
1231
|
+
const btn = panel.querySelector('button.submit')!;
|
|
1232
|
+
|
|
1233
|
+
btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
1234
|
+
|
|
1235
|
+
const deadline = Date.now() + 1000;
|
|
1236
|
+
while (Date.now() setTimeout(r, 50));
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
// After
|
|
1240
|
+
import { fire, waitFor, within } from '@vielzeug/assay';
|
|
1241
|
+
|
|
1242
|
+
const { query, queryByText } = within(panel);
|
|
1243
|
+
|
|
1244
|
+
fire.click(query('button.submit')!);
|
|
1245
|
+
await waitFor(() => queryByText('Saved') !== null);
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
| Feature | Assay | @testing-library/dom |
|
|
1249
|
+
| ----------------------- | ------------------------------------------------ | ---------------------------------------- |
|
|
1250
|
+
| Bundle size | | ~15 kB |
|
|
1251
|
+
| Framework-agnostic | | |
|
|
1252
|
+
| Scoped queries | `within()` | |
|
|
1253
|
+
| Low-level event dispatch| `fire.*` | Partial (`fireEvent`) |
|
|
1254
|
+
| Deterministic `waitFor` | | |
|
|
1255
|
+
| Zero dependencies | | |
|
|
1256
|
+
|
|
1257
|
+
**Use Assay when** you're testing custom elements or vanilla DOM code and want scoped queries, synchronous event dispatch, and async waiting without adopting a framework-specific testing library.
|
|
1258
|
+
|
|
1259
|
+
**Consider alternatives when** you're already standardized on `@testing-library/dom` (or a framework-specific wrapper around it) and don't need to avoid that dependency.
|
|
1260
|
+
|
|
1261
|
+
## Installation
|
|
1262
|
+
|
|
1263
|
+
Assay is a testing dependency — install it alongside your test runner.
|
|
1264
|
+
|
|
1265
|
+
```sh [pnpm]
|
|
1266
|
+
pnpm add -D @vielzeug/assay
|
|
1267
|
+
```
|
|
1268
|
+
|
|
1269
|
+
```sh [npm]
|
|
1270
|
+
npm install -D @vielzeug/assay
|
|
1271
|
+
```
|
|
1272
|
+
|
|
1273
|
+
```sh [yarn]
|
|
1274
|
+
yarn add -D @vielzeug/assay
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1277
|
+
## Quick Start
|
|
1278
|
+
|
|
1279
|
+
```ts
|
|
1280
|
+
import { fire, waitFor, within } from '@vielzeug/assay';
|
|
1281
|
+
|
|
1282
|
+
const panel = document.querySelector('.panel')!;
|
|
1283
|
+
const { query, queryByText } = within(panel);
|
|
1284
|
+
|
|
1285
|
+
fire.click(query('button.submit')!);
|
|
1286
|
+
|
|
1287
|
+
await waitFor(() => queryByText('Saved') !== null);
|
|
1288
|
+
```
|
|
1289
|
+
|
|
1290
|
+
## Features
|
|
1291
|
+
|
|
1292
|
+
- `within(element)` — scoped `query`/`queryAll`/`queryByText`/`queryAllByText`/`queryByTestId`/`queryAllByTestId` for any element or shadow root, each also available as a free function (`query(root, selector)`, etc.) for when you already have a root.
|
|
1293
|
+
- `queryInShadow`/`queryAllInShadow`/`queryPart` — shadow-DOM-aware queries that return `null`/`[]` instead of throwing when the host has no shadow root.
|
|
1294
|
+
- `getSlotted(host, slotName?)` — light-DOM children assigned to a named slot, or every slotted child.
|
|
1295
|
+
- `fire.*` — low-level synchronous DOM event dispatchers (`click`, `input`, `keyDown`, `pointerDown`/`pointerMove`/`pointerCancel`, `custom`, and more), each with sensible `bubbles`/`cancelable` defaults and a consistent `boolean` return value — no exceptions.
|
|
1296
|
+
- `waitFor(fn, options?)` — polls until a callback returns truthy or a bare `expect()` call doesn't throw; always rejects with `AssayTimeoutError` on timeout, with the original failure preserved as `.cause`.
|
|
1297
|
+
- `waitForEvent(element, name, timeout?)` — resolves with the next matching event, or rejects with `AssayTimeoutError`.
|
|
1298
|
+
- `nextTick()`/`wait(ms?)` — microtask and macrotask timing helpers for reactive updates and debounced code.
|
|
1299
|
+
- `AssayError` / `AssayTimeoutError` — a single error hierarchy for every timeout this package raises.
|
|
1300
|
+
- No DOM-framework coupling — works with vanilla custom elements, `@vielzeug/ore` components, or any other DOM output.
|
|
1301
|
+
|
|
1302
|
+
## Documentation
|
|
1303
|
+
|
|
1304
|
+
- [Usage Guide](./usage.md)
|
|
1305
|
+
- [API Reference](./api.md)
|
|
1306
|
+
|
|
1307
|
+
## See Also
|
|
1308
|
+
|
|
1309
|
+
- [Ore](/ore/) — web-component authoring library whose `@vielzeug/ore/testing` sub-path re-exports Assay's query/event/wait primitives
|
|
1310
|
+
- [Refine](/refine/) — accessible component library built on Ore, testable with the same Assay primitives
|
|
1311
|
+
|
|
1312
|
+
### API Reference
|
|
1313
|
+
|
|
1314
|
+
## API Overview
|
|
1315
|
+
|
|
1316
|
+
| Symbol | Purpose | Execution mode | Common gotcha |
|
|
1317
|
+
| -------------------- | ----------------------------------------------------- | -------------- | -------------- |
|
|
1318
|
+
| `within(element)` | Scoped query helpers for one element/subtree | Sync | Returns a fresh `QueryScope` per call — cheap, but don't cache it across DOM mutations you care about |
|
|
1319
|
+
| `query()` / `queryAll()` | Free-function equivalents of `within(root).query`/`.queryAll` | Sync | Use these when you already have a root and don't need the rest of `QueryScope` |
|
|
1320
|
+
| `queryByTestId()` / `queryAllByTestId()` | Match a `data-testid` attribute | Sync | Free-function equivalents of `within(root).queryByTestId`/`.queryAllByTestId` |
|
|
1321
|
+
| `queryByText()` | First element matching trimmed text content | Sync | Matches exact trimmed text, not substrings |
|
|
1322
|
+
| `queryAllByText()` | Every element matching trimmed text content | Sync | Same exact-match caveat as `queryByText()` |
|
|
1323
|
+
| `queryInShadow()` / `queryAllInShadow()` | Query inside a host's shadow root | Sync | Returns `null`/`[]` (not a throw) when the host has no shadow root |
|
|
1324
|
+
| `queryPart()` | Query a shadow-DOM element by its `part` attribute | Sync | Shorthand for `queryInShadow(host, '[part="x"]')` |
|
|
1325
|
+
| `getSlotted()` | Light-DOM children assigned to a slot | Sync | Only direct children (`:scope >`) — doesn't recurse into further-nested slots |
|
|
1326
|
+
| `fire.*` | Dispatch a DOM event synchronously | Sync | Doesn't wait for anything — pair with `waitFor()`/`await` for async reactions |
|
|
1327
|
+
| `createPointerEvent()` | Build a `PointerEvent`, falling back to `MouseEvent` | Sync | Only needed if you're constructing an event by hand instead of using `fire.pointer*` |
|
|
1328
|
+
| `waitFor()` | Poll until a callback returns truthy or resolves | Async | Always rejects with `AssayTimeoutError` on timeout — the original failure is `.cause`, never the thrown error's own type |
|
|
1329
|
+
| `waitForEvent()` | Resolve on the next matching event | Async | Rejects with `AssayTimeoutError`, not a plain `Error`, on timeout |
|
|
1330
|
+
| `nextTick()` | Resolve after one microtask tick | Async | Doesn't wait for `setTimeout`-scheduled work — use `wait()` for that |
|
|
1331
|
+
| `wait()` | Resolve after a fixed millisecond delay | Async | A fixed delay, not a condition — prefer `waitFor()` when you can express a condition instead |
|
|
1332
|
+
|
|
1333
|
+
## Package Entry Point
|
|
1334
|
+
|
|
1335
|
+
| Import | Purpose |
|
|
1336
|
+
| -------------------- | ------------------------------------------------------------------- |
|
|
1337
|
+
| `@vielzeug/assay` | The entire public API — queries, event dispatch, async waiting |
|
|
1338
|
+
|
|
1339
|
+
## Query Helpers
|
|
1340
|
+
|
|
1341
|
+
### `within(element)`
|
|
1342
|
+
|
|
1343
|
+
Creates query helpers scoped to a single element or shadow root.
|
|
1344
|
+
|
|
1345
|
+
**Parameters**
|
|
1346
|
+
|
|
1347
|
+
| Name | Type | Description |
|
|
1348
|
+
| --------- | ---------- | ------------------------------------- |
|
|
1349
|
+
| `element` | `Element` | The root to scope every query to |
|
|
1350
|
+
|
|
1351
|
+
**Returns:** `QueryScope`
|
|
1352
|
+
|
|
1353
|
+
**Example**
|
|
1354
|
+
|
|
1355
|
+
```ts
|
|
1356
|
+
import { within } from '@vielzeug/assay';
|
|
1357
|
+
|
|
1358
|
+
const { query, queryAll, queryByTestId, queryAllByTestId, queryByText, queryAllByText } = within(panel);
|
|
1359
|
+
```
|
|
1360
|
+
|
|
1361
|
+
`QueryScope` methods:
|
|
1362
|
+
|
|
1363
|
+
| Method | Returns |
|
|
1364
|
+
| ---------------------------------- | ----------------- |
|
|
1365
|
+
| `query(selector)` | `Element \| null` |
|
|
1366
|
+
| `queryAll(selector)` | `Element[]` |
|
|
1367
|
+
| `queryByText(text, selector?)` | `Element \| null` |
|
|
1368
|
+
| `queryAllByText(text, selector?)` | `Element[]` |
|
|
1369
|
+
| `queryByTestId(testId)` | `Element \| null` |
|
|
1370
|
+
| `queryAllByTestId(testId)` | `Element[]` |
|
|
1371
|
+
|
|
1372
|
+
`selector` defaults to `'*'` for the text-matching methods.
|
|
1373
|
+
|
|
1374
|
+
---
|
|
1375
|
+
|
|
1376
|
+
### `query(root, selector)` / `queryAll(root, selector)`
|
|
1377
|
+
|
|
1378
|
+
Free-function equivalents of `within(root).query`/`.queryAll` — a thin wrapper over `root.querySelector(All)`, exported directly so every `QueryScope` method has both a scoped and unscoped form.
|
|
1379
|
+
|
|
1380
|
+
**Returns:** `Element | null` / `Element[]`
|
|
1381
|
+
|
|
1382
|
+
---
|
|
1383
|
+
|
|
1384
|
+
### `queryByTestId(root, testId)` / `queryAllByTestId(root, testId)`
|
|
1385
|
+
|
|
1386
|
+
Free-function equivalents of `within(root).queryByTestId`/`.queryAllByTestId` — matches a `data-testid` attribute.
|
|
1387
|
+
|
|
1388
|
+
**Returns:** `Element | null` / `Element[]`
|
|
1389
|
+
|
|
1390
|
+
---
|
|
1391
|
+
|
|
1392
|
+
### `queryByText(root, text, selector)`
|
|
1393
|
+
|
|
1394
|
+
The unscoped function `within()` is built on — useful when you already have a root and don't need the rest of `QueryScope`.
|
|
1395
|
+
|
|
1396
|
+
**Parameters**
|
|
1397
|
+
|
|
1398
|
+
| Name | Type | Description |
|
|
1399
|
+
| ---------- | ---------------------- | ---------------------------------------------- |
|
|
1400
|
+
| `root` | `Element \| ShadowRoot`| Subtree to search |
|
|
1401
|
+
| `text` | `string` | Exact text to match against trimmed `textContent` |
|
|
1402
|
+
| `selector` | `string` | CSS selector narrowing candidate elements |
|
|
1403
|
+
|
|
1404
|
+
**Returns:** `Element | null` — the first matching element, or `null`.
|
|
1405
|
+
|
|
1406
|
+
---
|
|
1407
|
+
|
|
1408
|
+
### `queryAllByText(root, text, selector)`
|
|
1409
|
+
|
|
1410
|
+
Same matching rules as `queryByText`, returning every match.
|
|
1411
|
+
|
|
1412
|
+
**Returns:** `Element[]`
|
|
1413
|
+
|
|
1414
|
+
---
|
|
1415
|
+
|
|
1416
|
+
### `queryInShadow(host, selector)` / `queryAllInShadow(host, selector)`
|
|
1417
|
+
|
|
1418
|
+
Query inside `host.shadowRoot`. Returns `null` (or `[]` for the `All` variant) instead of throwing when `host` has no shadow root — safe to call without an `if (host.shadowRoot)` guard.
|
|
1419
|
+
|
|
1420
|
+
**Returns:** `Element | null` / `Element[]`
|
|
1421
|
+
|
|
1422
|
+
---
|
|
1423
|
+
|
|
1424
|
+
### `queryPart(host, part)`
|
|
1425
|
+
|
|
1426
|
+
Shorthand for `queryInShadow(host, '[part="' + part + '"]')`.
|
|
1427
|
+
|
|
1428
|
+
**Returns:** `Element | null`
|
|
1429
|
+
|
|
1430
|
+
---
|
|
1431
|
+
|
|
1432
|
+
### `getSlotted(host, slotName?)`
|
|
1433
|
+
|
|
1434
|
+
Returns the light-DOM children assigned to a named slot, or every slotted child (`:not([slot])`) when `slotName` is omitted.
|
|
1435
|
+
|
|
1436
|
+
**Returns:** `Element[]`
|
|
1437
|
+
|
|
1438
|
+
## Event Dispatch
|
|
1439
|
+
|
|
1440
|
+
### `fire`
|
|
1441
|
+
|
|
1442
|
+
An object of synchronous event dispatchers. Every method calls `element.dispatchEvent(...)` with an appropriately-typed `Event` subclass and sensible defaults, and returns `dispatchEvent`'s own boolean result.
|
|
1443
|
+
|
|
1444
|
+
```ts
|
|
1445
|
+
import { fire } from '@vielzeug/assay';
|
|
1446
|
+
|
|
1447
|
+
fire.click(el, opts?: PointerEventInit);
|
|
1448
|
+
fire.blur(el, opts?: FocusEventInit);
|
|
1449
|
+
fire.change(el, opts?: EventInit);
|
|
1450
|
+
fire.custom(el, name, opts?: CustomEventInit);
|
|
1451
|
+
fire.event(el, event: Event);
|
|
1452
|
+
fire.focus(el, name?, opts?: FocusEventInit);
|
|
1453
|
+
fire.input(el, opts?: EventInit);
|
|
1454
|
+
fire.keyboard(el, type, opts?: KeyboardEventInit);
|
|
1455
|
+
fire.keyDown(el, opts?: KeyboardEventInit);
|
|
1456
|
+
fire.keyUp(el, opts?: KeyboardEventInit);
|
|
1457
|
+
fire.mouse(el, type, opts?: MouseEventInit);
|
|
1458
|
+
fire.pointerCancel(el, opts?: PointerEventInit);
|
|
1459
|
+
fire.pointerDown(el, opts?: PointerEventInit);
|
|
1460
|
+
fire.pointerEnter(el, opts?: PointerEventInit);
|
|
1461
|
+
fire.pointerLeave(el, opts?: PointerEventInit);
|
|
1462
|
+
fire.pointerMove(el, opts?: PointerEventInit);
|
|
1463
|
+
fire.pointerUp(el, opts?: PointerEventInit);
|
|
1464
|
+
fire.submit(el, opts?: EventInit);
|
|
1465
|
+
fire.touch(el, type, opts?: EventInit);
|
|
1466
|
+
```
|
|
1467
|
+
|
|
1468
|
+
`fire.touch` falls back to `CustomEvent` in environments without a `TouchEvent` constructor. `fire.pointer*` methods route through `createPointerEvent()`, so they fall back to `MouseEvent` the same way.
|
|
1469
|
+
|
|
1470
|
+
---
|
|
1471
|
+
|
|
1472
|
+
### `createPointerEvent(type, init?)`
|
|
1473
|
+
|
|
1474
|
+
Builds a `PointerEvent`, or a `MouseEvent` when `PointerEvent` isn't available in the current environment.
|
|
1475
|
+
|
|
1476
|
+
**Returns:** `Event`
|
|
1477
|
+
|
|
1478
|
+
## Async Waiting
|
|
1479
|
+
|
|
1480
|
+
### `waitFor(fn, options?)`
|
|
1481
|
+
|
|
1482
|
+
Polls `fn` until it returns truthy, returns `undefined` (a bare `expect()` call that didn't throw), or the timeout elapses.
|
|
1483
|
+
|
|
1484
|
+
**Parameters**
|
|
1485
|
+
|
|
1486
|
+
| Name | Type | Default | Description |
|
|
1487
|
+
| ------------------ | ------------------- | ------- | ---------------------------------------------- |
|
|
1488
|
+
| `fn` | `() => unknown` | — | Condition to poll |
|
|
1489
|
+
| `options.timeout` | `number` | `1000` | Maximum wait time in ms |
|
|
1490
|
+
| `options.interval` | `number` | `50` | Delay between polling attempts in ms |
|
|
1491
|
+
| `options.message` | `string` | — | Prefixed in front of the default timing summary in the timeout error — never replaces it |
|
|
1492
|
+
|
|
1493
|
+
**Returns:** `Promise`
|
|
1494
|
+
|
|
1495
|
+
**Throws:** `AssayTimeoutError` on timeout, unconditionally — regardless of whether the last polling attempt returned a falsy value or threw. The original failure (a thrown error, or its message) is preserved as `.cause` and folded into the timeout message; `fn`'s thrown error itself is never mutated.
|
|
1496
|
+
|
|
1497
|
+
**Example**
|
|
1498
|
+
|
|
1499
|
+
```ts
|
|
1500
|
+
await waitFor(() => queryByText('Saved') !== null);
|
|
1501
|
+
await waitFor(() => expect(spy).toHaveBeenCalled(), { timeout: 2000 });
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
---
|
|
1505
|
+
|
|
1506
|
+
### `waitForEvent(element, name, timeout?)`
|
|
1507
|
+
|
|
1508
|
+
Resolves with the next event of the given name.
|
|
1509
|
+
|
|
1510
|
+
**Parameters**
|
|
1511
|
+
|
|
1512
|
+
| Name | Type | Default | Description |
|
|
1513
|
+
| --------- | --------- | ------- | --------------------------------- |
|
|
1514
|
+
| `element` | `Element` | — | Element to listen on |
|
|
1515
|
+
| `name` | `string` | — | Event name |
|
|
1516
|
+
| `timeout` | `number` | `1000` | Maximum wait time in ms |
|
|
1517
|
+
|
|
1518
|
+
**Returns:** `Promise` — the event instance, typed as `T extends Event` (defaults to `Event`).
|
|
1519
|
+
|
|
1520
|
+
---
|
|
1521
|
+
|
|
1522
|
+
### `nextTick()`
|
|
1523
|
+
|
|
1524
|
+
Resolves after one microtask tick (`queueMicrotask`) — for waiting on reactivity (signal effects, promise-chain continuations) without moving into the macrotask queue.
|
|
1525
|
+
|
|
1526
|
+
**Returns:** `Promise`
|
|
1527
|
+
|
|
1528
|
+
---
|
|
1529
|
+
|
|
1530
|
+
### `wait(ms?)`
|
|
1531
|
+
|
|
1532
|
+
Resolves after `ms` milliseconds (default `0`).
|
|
1533
|
+
|
|
1534
|
+
**Returns:** `Promise`
|
|
1535
|
+
|
|
1536
|
+
## Types
|
|
1537
|
+
|
|
1538
|
+
```ts
|
|
1539
|
+
interface QueryScope {
|
|
1540
|
+
query(selector: string): E | null;
|
|
1541
|
+
queryAll(selector: string): E[];
|
|
1542
|
+
queryByText(text: string, selector?: string): E | null;
|
|
1543
|
+
queryAllByText(text: string, selector?: string): E[];
|
|
1544
|
+
queryByTestId(testId: string): E | null;
|
|
1545
|
+
queryAllByTestId(testId: string): E[];
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
interface WaitOptions {
|
|
1549
|
+
timeout?: number;
|
|
1550
|
+
interval?: number;
|
|
1551
|
+
message?: string;
|
|
1552
|
+
}
|
|
1553
|
+
```
|
|
1554
|
+
|
|
1555
|
+
## Errors
|
|
1556
|
+
|
|
1557
|
+
| Error | Thrown by | Notable properties |
|
|
1558
|
+
| -------------------- | ------------------------------ | -------------------- |
|
|
1559
|
+
| `AssayError` | Base class for every Assay error — use `instanceof AssayError` to catch any of them | `AssayError.is(err)` static type guard |
|
|
1560
|
+
| `AssayTimeoutError` | `waitFor()`, `waitForEvent()` when the timeout elapses | Extends `AssayError` |
|
|
1561
|
+
|
|
1562
|
+
### Usage Guide
|
|
1563
|
+
|
|
1564
|
+
## Basic Usage
|
|
1565
|
+
|
|
1566
|
+
`within(element)` scopes queries to a subtree — useful for slotted content, shadow roots, or any container you don't want to re-select from `document` on every assertion.
|
|
1567
|
+
|
|
1568
|
+
```ts
|
|
1569
|
+
import { within } from '@vielzeug/assay';
|
|
1570
|
+
|
|
1571
|
+
const panel = document.querySelector('.panel')!;
|
|
1572
|
+
const { query, queryAll, queryByText } = within(panel);
|
|
1573
|
+
|
|
1574
|
+
query('.title'); // Element | null, scoped to panel
|
|
1575
|
+
queryAll('.item'); // Element[], scoped to panel
|
|
1576
|
+
queryByText('Save'); // matches trimmed textContent
|
|
1577
|
+
```
|
|
1578
|
+
|
|
1579
|
+
`queryByTestId`/`queryAllByTestId` match a `data-testid` attribute — useful when text content or CSS structure is likely to change but a stable test hook is worth keeping:
|
|
1580
|
+
|
|
1581
|
+
```ts
|
|
1582
|
+
const { queryByTestId } = within(panel);
|
|
1583
|
+
|
|
1584
|
+
queryByTestId('save-button')?.click();
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
Every `QueryScope` method is also available as a free function taking the root as its first argument — reach for these when you already have a root in scope and don't need the rest of `within()`'s object:
|
|
1588
|
+
|
|
1589
|
+
```ts
|
|
1590
|
+
import { query, queryAll, queryByTestId, queryAllByTestId } from '@vielzeug/assay';
|
|
1591
|
+
|
|
1592
|
+
query(panel, '.title');
|
|
1593
|
+
queryAll(panel, '.item');
|
|
1594
|
+
queryByTestId(panel, 'save-button');
|
|
1595
|
+
queryAllByTestId(panel, 'item-row');
|
|
1596
|
+
```
|
|
1597
|
+
|
|
1598
|
+
For a custom element's shadow DOM, use `queryInShadow`/`queryAllInShadow`/`queryPart` instead — they return `null`/`[]` rather than throwing when the host has no shadow root (e.g. a `shadow: false` component), so you don't need an `if (host.shadowRoot)` guard at every call site:
|
|
1599
|
+
|
|
1600
|
+
```ts
|
|
1601
|
+
import { queryAllInShadow, queryInShadow, queryPart } from '@vielzeug/assay';
|
|
1602
|
+
|
|
1603
|
+
queryInShadow(customEl, '.internal-label');
|
|
1604
|
+
queryAllInShadow(customEl, '.option');
|
|
1605
|
+
queryPart(customEl, 'trigger'); // shorthand for queryInShadow(host, '[part="trigger"]')
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
`getSlotted()` reads the *light*-DOM children a host projects into a named slot (or every slotted child with no argument) — the complement to the shadow-DOM helpers above:
|
|
1609
|
+
|
|
1610
|
+
```ts
|
|
1611
|
+
import { getSlotted } from '@vielzeug/assay';
|
|
1612
|
+
|
|
1613
|
+
const slides = getSlotted(carousel); // default slot
|
|
1614
|
+
const actions = getSlotted(dialog, 'footer'); // named slot
|
|
1615
|
+
```
|
|
1616
|
+
|
|
1617
|
+
## Firing Events
|
|
1618
|
+
|
|
1619
|
+
`fire.*` dispatches real DOM events synchronously — no framework-specific event simulation layer, just `dispatchEvent` with sensible defaults (`bubbles: true`, `cancelable: true` where that matches the real event's behavior). Every method returns the same `boolean` `dispatchEvent` itself returns (`false` when a listener called `preventDefault()`), with no exceptions — useful for asserting a handler actually intercepted the event:
|
|
1620
|
+
|
|
1621
|
+
```ts
|
|
1622
|
+
import { fire } from '@vielzeug/assay';
|
|
1623
|
+
|
|
1624
|
+
fire.click(button);
|
|
1625
|
+
fire.input(textInput); // fires a plain 'input' Event
|
|
1626
|
+
fire.keyDown(textInput, { key: 'Enter' });
|
|
1627
|
+
fire.custom(el, 'value-change', { detail: { value: 42 } });
|
|
1628
|
+
|
|
1629
|
+
const notPrevented = fire.click(link); // false if a listener called preventDefault()
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
Pointer events fall back to `MouseEvent` in environments without a `PointerEvent` constructor (some older jsdom versions) — Assay logs a one-time `console.warn` the first time this happens, so an environment gap doesn't show up as a confusing, unrelated test failure instead:
|
|
1633
|
+
|
|
1634
|
+
```ts
|
|
1635
|
+
fire.pointerDown(slider);
|
|
1636
|
+
fire.pointerMove(slider, { clientX: 120 });
|
|
1637
|
+
fire.pointerUp(slider);
|
|
1638
|
+
```
|
|
1639
|
+
|
|
1640
|
+
Dispatch a pre-built event instance directly with `fire.event` when you need full control over the event's properties:
|
|
1641
|
+
|
|
1642
|
+
```ts
|
|
1643
|
+
fire.event(el, new CustomEvent('ready', { bubbles: true, detail: { ok: true } }));
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
## Waiting for Async Conditions
|
|
1647
|
+
|
|
1648
|
+
`waitFor()` polls a callback until it returns truthy (or doesn't throw, for a bare `expect()` call) — use it for anything that updates asynchronously (a reactive framework's next render, a debounced input handler, a `fetch` completing).
|
|
1649
|
+
|
|
1650
|
+
```ts
|
|
1651
|
+
import { waitFor } from '@vielzeug/assay';
|
|
1652
|
+
|
|
1653
|
+
await waitFor(() => panel.querySelector('.status')?.textContent === 'Ready');
|
|
1654
|
+
|
|
1655
|
+
// Works with `expect()` assertions too — a thrown assertion error means "not yet",
|
|
1656
|
+
// and its message is folded into the AssayTimeoutError thrown if the timeout is reached
|
|
1657
|
+
// (see below — the assertion's own error type never escapes waitFor() directly).
|
|
1658
|
+
await waitFor(() => expect(callback).toHaveBeenCalled());
|
|
1659
|
+
```
|
|
1660
|
+
|
|
1661
|
+
Tune the polling interval and timeout per call — the defaults (1000ms timeout, 50ms interval) suit most reactive UI updates:
|
|
1662
|
+
|
|
1663
|
+
```ts
|
|
1664
|
+
await waitFor(() => queue.isEmpty(), { interval: 10, timeout: 5000 });
|
|
1665
|
+
```
|
|
1666
|
+
|
|
1667
|
+
`waitForEvent()` resolves with the next matching event instead of polling — prefer it when you're waiting on something that's guaranteed to fire an event rather than settle into an observable DOM state:
|
|
1668
|
+
|
|
1669
|
+
```ts
|
|
1670
|
+
import { fire, waitForEvent } from '@vielzeug/assay';
|
|
1671
|
+
|
|
1672
|
+
const promise = waitForEvent>(el, 'item-added');
|
|
1673
|
+
|
|
1674
|
+
fire.click(addButton);
|
|
1675
|
+
|
|
1676
|
+
const event = await promise;
|
|
1677
|
+
console.log(event.detail.id);
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1680
|
+
Both `waitFor()` and `waitForEvent()` reject with `AssayTimeoutError` on timeout — unconditionally, even if the last attempt inside `waitFor()` threw a different error type (an `expect()` assertion, say). Catch `AssayTimeoutError` specifically when you want to distinguish "the condition never became true" from other failures; the original failure is preserved on `.cause` if you need it:
|
|
1681
|
+
|
|
1682
|
+
```ts
|
|
1683
|
+
import { AssayTimeoutError, waitForEvent } from '@vielzeug/assay';
|
|
1684
|
+
|
|
1685
|
+
try {
|
|
1686
|
+
await waitForEvent(el, 'never-fires', 100);
|
|
1687
|
+
} catch (err) {
|
|
1688
|
+
if (err instanceof AssayTimeoutError) {
|
|
1689
|
+
// expected — assert on the timeout itself, or inspect err.cause for the original failure
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
`nextTick()` and `wait()` cover the two other common timing needs: waiting for reactivity to settle, and waiting for a real macrotask delay.
|
|
1695
|
+
|
|
1696
|
+
```ts
|
|
1697
|
+
import { nextTick, wait } from '@vielzeug/assay';
|
|
1698
|
+
|
|
1699
|
+
signal.value = 'updated';
|
|
1700
|
+
await nextTick(); // let a microtask-scheduled effect run
|
|
1701
|
+
|
|
1702
|
+
await wait(300); // wait out a debounce timer — prefer nextTick()/waitFor() where possible
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
## Testing Custom Elements
|
|
1706
|
+
|
|
1707
|
+
Assay has no opinion about how your DOM was produced — it works the same way against a vanilla custom element, a framework-rendered component, or plain `document.createElement` output.
|
|
1708
|
+
|
|
1709
|
+
```ts
|
|
1710
|
+
import { fire, waitFor, within } from '@vielzeug/assay';
|
|
1711
|
+
|
|
1712
|
+
customElements.define(
|
|
1713
|
+
'my-counter',
|
|
1714
|
+
class extends HTMLElement {
|
|
1715
|
+
connectedCallback() {
|
|
1716
|
+
this.innerHTML = `+10`;
|
|
1717
|
+
this.querySelector('button')!.addEventListener('click', () => {
|
|
1718
|
+
const span = this.querySelector('.count')!;
|
|
1719
|
+
|
|
1720
|
+
span.textContent = String(Number(span.textContent) + 1);
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1723
|
+
},
|
|
1724
|
+
);
|
|
1725
|
+
|
|
1726
|
+
const el = document.createElement('my-counter');
|
|
1727
|
+
|
|
1728
|
+
document.body.appendChild(el);
|
|
1729
|
+
|
|
1730
|
+
const { query } = within(el);
|
|
1731
|
+
|
|
1732
|
+
fire.click(query('button')!);
|
|
1733
|
+
|
|
1734
|
+
await waitFor(() => query('.count')?.textContent === '1');
|
|
1735
|
+
|
|
1736
|
+
el.remove();
|
|
1737
|
+
```
|
|
1738
|
+
|
|
1739
|
+
## Working with Other Vielzeug Libraries
|
|
1740
|
+
|
|
1741
|
+
`@vielzeug/ore`'s `./testing` sub-path re-exports Assay's `within`, `fire`, `createPointerEvent`, `waitFor`, and `waitForEvent` directly — if you're already testing Ore components, you don't need a separate Assay import:
|
|
1742
|
+
|
|
1743
|
+
```ts
|
|
1744
|
+
import { fire, mount, waitFor } from '@vielzeug/ore/testing';
|
|
1745
|
+
|
|
1746
|
+
const { query } = await mount(() => html` {}}>Click`);
|
|
1747
|
+
|
|
1748
|
+
fire.click(query('button')!);
|
|
1749
|
+
```
|
|
1750
|
+
|
|
1751
|
+
`@vielzeug/refine`'s `./testing` sub-path re-exports Assay's `queryInShadow`, `queryAllInShadow`, `queryPart`, `getSlotted`, `nextTick`, and `wait` the same way, alongside refine-specific helpers (ARIA attribute assertions, form-associated helpers) that stay in refine because they're specific to testing ore/refine component *contracts*, not generic DOM interaction:
|
|
1752
|
+
|
|
1753
|
+
```ts
|
|
1754
|
+
import { getAriaState, getSlotted, nextTick } from '@vielzeug/refine/testing';
|
|
1755
|
+
```
|
|
1756
|
+
|
|
1757
|
+
Import from `@vielzeug/assay` directly when testing DOM code that has nothing to do with Ore or Refine — a vanilla custom element, a framework-rendered component, or plain event-handler logic.
|
|
1758
|
+
|
|
1759
|
+
## Best Practices
|
|
1760
|
+
|
|
1761
|
+
- Prefer `within(element)` over repeated `element.querySelector(...)` calls — it reads better at call sites with multiple assertions against the same subtree.
|
|
1762
|
+
- Use `queryByTestId` for elements whose text or structure is expected to change; use `queryByText` when the visible text itself is what you're asserting on.
|
|
1763
|
+
- Reach for `waitForEvent()` over `waitFor()` when the thing you're waiting on is guaranteed to dispatch an event — it resolves on the first matching event instead of polling.
|
|
1764
|
+
- Catch `AssayTimeoutError` specifically (not a bare `Error`) when a test needs to branch on "the condition never happened" versus any other failure.
|
|
1765
|
+
- Keep `fire.*` calls synchronous where the real user interaction would be — `fire.click()` doesn't await anything; `await` the assertion that follows it instead.
|
|
1766
|
+
- Remove elements you create directly with `document.createElement` (`el.remove()`) at the end of each test — Assay itself has no auto-cleanup registry, unlike `@vielzeug/ore/testing`'s `mount()`/`cleanup()`.
|
|
1767
|
+
- Prefer `queryInShadow`/`queryAllInShadow` over `host.shadowRoot!.querySelector(...)` — the non-null assertion breaks the moment a component is tested with `shadow: false`, while `queryInShadow` degrades to `null` instead of throwing.
|
|
1768
|
+
|
|
1769
|
+
### Examples
|
|
1770
|
+
|
|
1771
|
+
## Examples
|
|
1772
|
+
|
|
1773
|
+
- [Custom Element Interaction](./examples/custom-element-interaction.md)
|
|
1774
|
+
- [Waiting for Async Updates](./examples/waiting-for-async-updates.md)
|
|
1775
|
+
|
|
1776
|
+
|
|
1214
1777
|
---
|
|
1215
1778
|
|
|
1216
1779
|
## @vielzeug/clockwork
|
|
@@ -1687,6 +2250,7 @@ type InterpretOptions = {
|
|
|
1687
2250
|
onDebug?: (event: DebugEvent) => void;
|
|
1688
2251
|
persistence?: PersistenceAdapter;
|
|
1689
2252
|
snapshot?: MachineSnapshot;
|
|
2253
|
+
validateHydratedContext?: boolean;
|
|
1690
2254
|
traceLimit?: number;
|
|
1691
2255
|
};
|
|
1692
2256
|
```
|
|
@@ -1699,6 +2263,7 @@ type InterpretOptions = {
|
|
|
1699
2263
|
| `onDebug` | `undefined` | Callback for all debug events (guards, transitions, invokes, skips). Auto-enables a 50-entry trace buffer unless `traceLimit` is set. |
|
|
1700
2264
|
| `persistence` | `undefined` | Save/load adapter for snapshot persistence |
|
|
1701
2265
|
| `snapshot` | `undefined` | Snapshot to hydrate from on startup (takes priority over persistence) |
|
|
2266
|
+
| `validateHydratedContext`| `false` | When `true`, runs `validateContext` against hydrated context during startup; when `false`, hydrated context is trusted and only validated on transitions. |
|
|
1702
2267
|
| `traceLimit` | auto (`50`/`0`) | Ring buffer capacity for `getTrace()`. Defaults to `50` when `onDebug` is set; `0` (disabled) otherwise. Set explicitly to override. |
|
|
1703
2268
|
|
|
1704
2269
|
---
|
|
@@ -1826,7 +2391,7 @@ interface MachineInstance {
|
|
|
1826
2391
|
| `matches(...states)` | `boolean` | `true` if the current state is one of the given values or a descendant of any (e.g. `matches('loading')` matches `'loading.pending'`). Returns `false` when disposed. |
|
|
1827
2392
|
| `dispose()` | `void` | Aborts active invokes, clears after-timers, and disposes reactive signals. Idempotent. Does **not** clear persisted state. Equivalent to `using m = createMachine(config).start()`. |
|
|
1828
2393
|
| `send(event)` | `SendResult` | Dispatches the event. Returns a `SendResult` with `.status`: `'transitioned'`, `'queued'`, or `'rejected'` (also when the machine is already disposed). |
|
|
1829
|
-
| `subscribe(fn)` | `() => void` | Subscribes to state/context changes. Returns an unsubscribe function. Fires only when state or context changes — **not** on the initial value. Use `getSnapshot()` to read the current state immediately. |
|
|
2394
|
+
| `subscribe(fn)` | `() => void` | Subscribes to state/context changes. Returns an unsubscribe function. Fires only when state or context changes — **not** on the initial value. Callback receives an isolated snapshot (`context` is cloned). Use `getSnapshot()` to read the current state immediately. |
|
|
1830
2395
|
| `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations. |
|
|
1831
2396
|
|
|
1832
2397
|
---
|
|
@@ -1878,7 +2443,7 @@ type PersistenceAdapter = {
|
|
|
1878
2443
|
};
|
|
1879
2444
|
```
|
|
1880
2445
|
|
|
1881
|
-
`save` is called after every committed transition. `load` is called once during startup if no `snapshot` option is provided.
|
|
2446
|
+
`save` is called after every committed transition. `load` is called once during startup if no `snapshot` option is provided. Hydrated context is not validated at startup unless `validateHydratedContext: true` is set.
|
|
1882
2447
|
|
|
1883
2448
|
---
|
|
1884
2449
|
|
|
@@ -2472,7 +3037,9 @@ On startup, `createMachine().start()` checks `options.snapshot` first, then `per
|
|
|
2472
3037
|
|
|
2473
3038
|
`m[Symbol.dispose]()` does **not** clear persisted state. The machine may be recreated (e.g. after HMR or component remount) and should resume from the last saved state. To reset persistence, call your adapter's storage API directly.
|
|
2474
3039
|
|
|
2475
|
-
|
|
3040
|
+
Hydrated context is trusted by default for backward compatibility. To enforce `validateContext` during hydration, pass `validateHydratedContext: true` to `.start(...)`.
|
|
3041
|
+
|
|
3042
|
+
If context is loaded from untrusted sources (e.g. `localStorage`), enable `validateHydratedContext` or validate inside `persistence.load()` before returning the snapshot.
|
|
2476
3043
|
|
|
2477
3044
|
## Interceptors
|
|
2478
3045
|
|
|
@@ -2547,6 +3114,7 @@ unsub(); // stop listening
|
|
|
2547
3114
|
```
|
|
2548
3115
|
|
|
2549
3116
|
The callback fires only when `state` or `context` reference changes — not on every signal read.
|
|
3117
|
+
Each callback receives an isolated snapshot object; mutating the callback payload does not mutate machine state.
|
|
2550
3118
|
|
|
2551
3119
|
## Debugging and Tracing
|
|
2552
3120
|
|
|
@@ -7052,7 +7620,7 @@ Creates a query client with caching, deduplication, prefix invalidation, and rea
|
|
|
7052
7620
|
| `getState` | `(key) => QueryState \| null` | Full state snapshot |
|
|
7053
7621
|
| `observeMany` | `(keys: QueryKey[]) => SyncStore[]>` | Observe multiple keys as one combined store; updates on any key change |
|
|
7054
7622
|
| `invalidate` | `(key) => void` | Evict or background-revalidate a key/prefix |
|
|
7055
|
-
| `remove` | `(key: QueryKey) => void` | Evict a single entry; aborts any in-flight fetch; resets observers to
|
|
7623
|
+
| `remove` | `(key: QueryKey) => void` | Evict a single entry; aborts any in-flight fetch; resets observers to `'loading'` if active |
|
|
7056
7624
|
| `cancel` | `(key) => void` | Cancel an in-flight fetch; entry returns to `'loading'` or retains prior success data |
|
|
7057
7625
|
| `clear` | `() => void` | Clear all entries; active subscribers see `'loading'` |
|
|
7058
7626
|
| `refetchStale` | `() => void` | Manually revalidate all stale observed entries |
|
|
@@ -7113,7 +7681,7 @@ Creates a standalone, observable mutation handle.
|
|
|
7113
7681
|
| `peek` | `() => MutationState` | Read current state snapshot |
|
|
7114
7682
|
| `subscribe` | `(cb: () => void) => () => void` | Subscribe to state changes; returns unsubscribe fn |
|
|
7115
7683
|
| `store` | `SyncStore>` (property) | Framework-friendly external store; stable reference |
|
|
7116
|
-
| `reset` | `() => void` | Reset back to the
|
|
7684
|
+
| `reset` | `() => void` | Reset back to the `'loading'` baseline state |
|
|
7117
7685
|
| `dispose` | `() => void` | Abort active run, clear observers, and mark as disposed |
|
|
7118
7686
|
| `disposed` | `boolean` (getter) | Whether `dispose()` has been called |
|
|
7119
7687
|
| `[Symbol.dispose]` | — | Delegates to `dispose()`; enables `using` declarations |
|
|
@@ -8285,7 +8853,7 @@ const state = qc.getState(['users', 1]);
|
|
|
8285
8853
|
```ts
|
|
8286
8854
|
const store = qc.watchKey(['users', 1]);
|
|
8287
8855
|
|
|
8288
|
-
const initial = store.peek(); //
|
|
8856
|
+
const initial = store.peek(); // loading baseline if not yet fetched
|
|
8289
8857
|
const stop = store.subscribe(() => {
|
|
8290
8858
|
console.log(store.peek());
|
|
8291
8859
|
});
|
|
@@ -8324,7 +8892,7 @@ const store = qc.observe({
|
|
|
8324
8892
|
});
|
|
8325
8893
|
|
|
8326
8894
|
// Synchronously read the current state
|
|
8327
|
-
console.log(store.peek().status); // '
|
|
8895
|
+
console.log(store.peek().status); // 'loading' (or 'success' / 'error' if already cached)
|
|
8328
8896
|
console.log(store.peek().data); // placeholderData while fetching
|
|
8329
8897
|
|
|
8330
8898
|
// Subscribe to future changes
|
|
@@ -8368,7 +8936,7 @@ function useUser(id: number) {
|
|
|
8368
8936
|
For entries **without active subscribers**, invalidation evicts the cache entry immediately. For entries **with active subscribers**:
|
|
8369
8937
|
|
|
8370
8938
|
- If the entry has a stored query function (registered via `fetch()`), it is background-revalidated.
|
|
8371
|
-
- If the entry was only populated via `set()`, it resets to `
|
|
8939
|
+
- If the entry was only populated via `set()`, it resets to `'loading'`.
|
|
8372
8940
|
|
|
8373
8941
|
Supports **prefix matching**: invalidating `['users']` affects `['users', 1]`, `['users', 2]`, and so on.
|
|
8374
8942
|
|
|
@@ -8379,7 +8947,7 @@ qc.invalidate(['users']);
|
|
|
8379
8947
|
|
|
8380
8948
|
### `cancel(key)`
|
|
8381
8949
|
|
|
8382
|
-
Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'
|
|
8950
|
+
Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'loading'`.
|
|
8383
8951
|
|
|
8384
8952
|
```ts
|
|
8385
8953
|
qc.cancel(['users', 1]);
|
|
@@ -8387,7 +8955,7 @@ qc.cancel(['users', 1]);
|
|
|
8387
8955
|
|
|
8388
8956
|
### `clear()`
|
|
8389
8957
|
|
|
8390
|
-
Clears every cache entry. Active subscribers are notified with
|
|
8958
|
+
Clears every cache entry. Active subscribers are notified with a `'loading'` state.
|
|
8391
8959
|
|
|
8392
8960
|
```ts
|
|
8393
8961
|
qc.clear();
|
|
@@ -8968,7 +9536,7 @@ effect(() => console.log('user:', userStore.value.user?.name));
|
|
|
8968
9536
|
|
|
8969
9537
|
**Category:** ui-interaction
|
|
8970
9538
|
**Keywords:** drag-drop, sortable, file-upload, drop-zone, dnd, reorder
|
|
8971
|
-
**Key exports:** createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept
|
|
9539
|
+
**Key exports:** createDropZone, createSortable, createSortableScope, createTouchDragShim, applyReorder, matchesAccept
|
|
8972
9540
|
**Related:** ore, scroll, refine
|
|
8973
9541
|
|
|
8974
9542
|
### Overview
|
|
@@ -9016,6 +9584,7 @@ const zone = createDropZone({
|
|
|
9016
9584
|
| Sortable lists | | | |
|
|
9017
9585
|
| Drag handles | | | |
|
|
9018
9586
|
| `using` support | | | |
|
|
9587
|
+
| Touch support | Opt-in shim | | |
|
|
9019
9588
|
| Zero dependencies | | | |
|
|
9020
9589
|
|
|
9021
9590
|
**Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.
|
|
@@ -9088,6 +9657,7 @@ using sortable = createSortable({
|
|
|
9088
9657
|
- **`sortable.revert()`** — register a revert function via `event.setRevert(fn)` inside `onReorder`; `sortable.revert()` invokes it and clears it for rolling back optimistic updates on server failure
|
|
9089
9658
|
- **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally
|
|
9090
9659
|
- **Explicit connected scopes** — lists only exchange items when they share a `createSortableScope()` instance
|
|
9660
|
+
- **Touch support via `createTouchDragShim()`** — bridges `touchstart`/`touchmove`/`touchend`/`touchcancel` into the same synthetic `DragEvent` sequence `createSortable`/`createDropZone` already listen for; one `document`-level instance covers the whole app
|
|
9091
9661
|
- **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers
|
|
9092
9662
|
- **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup
|
|
9093
9663
|
- **Reactive-friendly options** — `disabled` is re-read on each event (reassign `options.disabled = true` to toggle); `accept` captures the array reference, so push/splice mutations are reflected without recreating the zone
|
|
@@ -9114,6 +9684,7 @@ using sortable = createSortable({
|
|
|
9114
9684
|
| `createDropZone()` | Create a typed drop-zone controller | Sync | Remember to destroy the controller during teardown |
|
|
9115
9685
|
| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |
|
|
9116
9686
|
| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |
|
|
9687
|
+
| `createTouchDragShim()` | Bridge touch gestures to synthetic DragEvents | Sync | Create once per app — it's a single `document`-level listener set |
|
|
9117
9688
|
| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |
|
|
9118
9689
|
| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |
|
|
9119
9690
|
| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |
|
|
@@ -9217,6 +9788,15 @@ declare function createSortableScope(): SortableScope;
|
|
|
9217
9788
|
|
|
9218
9789
|
Creates an explicit connection scope for multi-container sorting. Containers only exchange items when they share the same scope instance.
|
|
9219
9790
|
|
|
9791
|
+
### `TouchDragOptions`
|
|
9792
|
+
|
|
9793
|
+
```ts
|
|
9794
|
+
interface TouchDragOptions {
|
|
9795
|
+
disabled?: boolean;
|
|
9796
|
+
draggableSelector?: string;
|
|
9797
|
+
}
|
|
9798
|
+
```
|
|
9799
|
+
|
|
9220
9800
|
## `createDropZone()`
|
|
9221
9801
|
|
|
9222
9802
|
```ts
|
|
@@ -9323,7 +9903,7 @@ declare function createSortable(options: SortableOptions): Sortable;
|
|
|
9323
9903
|
|
|
9324
9904
|
Makes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.
|
|
9325
9905
|
|
|
9326
|
-
`createSortable` sets `draggable="true"
|
|
9906
|
+
`createSortable` sets `draggable="true"`, `role="listitem"`, and `touch-action: none` (inline style) on qualifying children and sets `role="list"` on the container at initialization. After DOM mutations, call `sortable.sync()` to re-apply sortable attributes explicitly.
|
|
9327
9907
|
|
|
9328
9908
|
- `element`: `HTMLElement`, required. The container whose children become sortable.
|
|
9329
9909
|
- `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.
|
|
@@ -9449,6 +10029,7 @@ Dnd reads and writes the following DOM attributes:
|
|
|
9449
10029
|
- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.
|
|
9450
10030
|
- `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.
|
|
9451
10031
|
- `aria-hidden="true"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.
|
|
10032
|
+
- `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), cleared by `dispose()`. Opts the element out of the browser's default touch gestures (scroll/pan/zoom) so a mobile browser never hijacks a drag gesture as a page scroll before `createTouchDragShim`'s own logic runs — see Usage's "Why draggable items get `touch-action: none`". No effect on mouse/pointer input.
|
|
9452
10033
|
|
|
9453
10034
|
## CSS Classes
|
|
9454
10035
|
|
|
@@ -9456,6 +10037,34 @@ Dnd reads and writes the following DOM attributes:
|
|
|
9456
10037
|
| ----------------- | ---------------------------- | ------------------------------------------------------------- |
|
|
9457
10038
|
| `dnd-placeholder` | `` inserted by sortable | While an item is being dragged, in the placeholder's position |
|
|
9458
10039
|
|
|
10040
|
+
## `createTouchDragShim()`
|
|
10041
|
+
|
|
10042
|
+
```ts
|
|
10043
|
+
declare function createTouchDragShim(options?: TouchDragOptions): Disposable;
|
|
10044
|
+
```
|
|
10045
|
+
|
|
10046
|
+
Bridges touch gestures to the synthetic `DragEvent` sequence `createSortable()`/`createDropZone()` already listen for — `touchstart`/`touchmove`/`touchend`/`touchcancel` become `dragstart`/`dragover`/`drop`/`dragend` on the same `document`. HTML5 drag-and-drop has no native touch equivalent otherwise.
|
|
10047
|
+
|
|
10048
|
+
| Option | Type | Default | Description |
|
|
10049
|
+
| -------------------- | --------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
10050
|
+
| `disabled` | `boolean` | — | When `true`, touch gestures are ignored. Read live off the same options object on each `touchstart`, like `SortableOptions.disabled`. |
|
|
10051
|
+
| `draggableSelector` | `string` | `'[draggable="true"]'` | CSS selector identifying draggable elements under the touch point. The default matches what `createSortable`/`createDropZone` already set. |
|
|
10052
|
+
| `showDragPreview` | `boolean` | `true` | Renders a floating clone of the dragged element that follows the touch point for the whole gesture. A native mouse drag gets this for free from the browser's own drag image; this shim's `dragstart` is synthetic, so without it the dragged element would simply vanish (hidden by `createSortable`'s own `scheduleHide()`) with no visual feedback at all. Set to `false` to render fully custom feedback instead. |
|
|
10053
|
+
|
|
10054
|
+
**Returns:** `Disposable`
|
|
10055
|
+
|
|
10056
|
+
Notes:
|
|
10057
|
+
|
|
10058
|
+
- Listens at the `document` level — create one instance per app, not one per sortable/drop-zone.
|
|
10059
|
+
- Dispatched events carry a plain object as `dataTransfer` (`dropEffect`/`effectAllowed`/`getData`/`setData`/`setDragImage`), never a real `DataTransfer` — a genuine `DataTransfer` created outside an active native drag is permanently in the spec's "disabled mode", where `dropEffect` writes are silently ignored, which `createSortable`/`createDropZone`'s own commit-vs-cancel check would otherwise always read as a cancellation.
|
|
10060
|
+
- The floating preview is a `cloneNode(true)` of the dragged element — it only clones light-DOM content, so an item whose visible content lives inside a shadow root will preview as an empty shell.
|
|
10061
|
+
|
|
10062
|
+
```ts
|
|
10063
|
+
import { createTouchDragShim } from '@vielzeug/dnd';
|
|
10064
|
+
|
|
10065
|
+
using touchDrag = createTouchDragShim();
|
|
10066
|
+
```
|
|
10067
|
+
|
|
9459
10068
|
## `matchesAccept()`
|
|
9460
10069
|
|
|
9461
10070
|
```ts
|
|
@@ -9868,6 +10477,61 @@ try {
|
|
|
9868
10477
|
}
|
|
9869
10478
|
```
|
|
9870
10479
|
|
|
10480
|
+
## Touch Support
|
|
10481
|
+
|
|
10482
|
+
HTML5 drag-and-drop has no native touch story — touch devices never fire `dragstart`/`dragover`/`drop`. `createTouchDragShim` bridges `touchstart`/`touchmove`/`touchend`/`touchcancel` into that same synthetic `DragEvent` sequence at the `document` level, so `createSortable`/`createDropZone` work on touch with no per-instance wiring.
|
|
10483
|
+
|
|
10484
|
+
```ts
|
|
10485
|
+
import { createTouchDragShim } from '@vielzeug/dnd';
|
|
10486
|
+
|
|
10487
|
+
// Call once at app startup — one instance covers the whole page.
|
|
10488
|
+
using touchDrag = createTouchDragShim();
|
|
10489
|
+
```
|
|
10490
|
+
|
|
10491
|
+
### Custom draggable selector
|
|
10492
|
+
|
|
10493
|
+
Defaults to `[draggable="true"]` — the attribute `createSortable`/`createDropZone` already set on managed elements. Override it if you're bridging touch to elements you manage draggability on yourself.
|
|
10494
|
+
|
|
10495
|
+
```ts
|
|
10496
|
+
createTouchDragShim({ draggableSelector: '.my-drag-handle' });
|
|
10497
|
+
```
|
|
10498
|
+
|
|
10499
|
+
### Drag preview
|
|
10500
|
+
|
|
10501
|
+
A native mouse-driven drag gets a floating drag image for free — the browser snapshots the dragged element the moment `dragstart` fires and keeps that image under the cursor for the whole gesture. `createTouchDragShim`'s `dragstart` is a synthetic event, so no such snapshot ever exists; without a preview of its own, the dragged element would simply disappear (hidden by `createSortable`'s own scheduled hide) with no visual feedback until the drop. `createTouchDragShim` renders one automatically — a `cloneNode(true)` of the dragged element, positioned `fixed` and translated to follow the touch point — enabled by default.
|
|
10502
|
+
|
|
10503
|
+
```ts
|
|
10504
|
+
// Opt out to render fully custom feedback instead (e.g. toggling a class from your own
|
|
10505
|
+
// dragstart/dragend listeners):
|
|
10506
|
+
createTouchDragShim({ showDragPreview: false });
|
|
10507
|
+
```
|
|
10508
|
+
|
|
10509
|
+
Note the preview only clones light-DOM content — an item whose visible content lives inside a shadow root will preview as an empty shell.
|
|
10510
|
+
|
|
10511
|
+
### Why draggable items get `touch-action: none`
|
|
10512
|
+
|
|
10513
|
+
`createSortable` sets `touch-action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set) — no configuration needed. Without it, a mobile browser can decide the very first bit of finger movement on a draggable item is a page scroll/pan — a decision made independently of, and before, `createTouchDragShim`'s own drag-start threshold and `preventDefault()` calls ever run — and hand the rest of the gesture to native scrolling. Once that happens the item never receives the `dragover` sequence needed to update the drop target, so the drop commits back to wherever it started, which looks identical to the drop simply reverting. This is most visible dragging between two containers that require any real finger travel (e.g. a Kanban column stacked below the source column on a narrow viewport) — a short in-place reorder rarely travels far enough to trigger the browser's scroll-intent heuristic, which is why this class of bug can pass casual same-container testing and only show up cross-container.
|
|
10514
|
+
|
|
10515
|
+
This has no effect on mouse/pointer input — `touch-action` is touch-only — so it's safe even for `createSortable` instances that never pair with `createTouchDragShim`.
|
|
10516
|
+
|
|
10517
|
+
### Disabled state
|
|
10518
|
+
|
|
10519
|
+
```ts
|
|
10520
|
+
const options = { disabled: false };
|
|
10521
|
+
const touchDrag = createTouchDragShim(options);
|
|
10522
|
+
|
|
10523
|
+
// options.disabled is read live on each touch event — mutate to toggle:
|
|
10524
|
+
options.disabled = true;
|
|
10525
|
+
```
|
|
10526
|
+
|
|
10527
|
+
### Cleanup
|
|
10528
|
+
|
|
10529
|
+
```ts
|
|
10530
|
+
touchDrag.dispose();
|
|
10531
|
+
// or:
|
|
10532
|
+
using touchDrag = createTouchDragShim();
|
|
10533
|
+
```
|
|
10534
|
+
|
|
9871
10535
|
## Framework Integration
|
|
9872
10536
|
|
|
9873
10537
|
```tsx [React]
|
|
@@ -9957,19 +10621,22 @@ Use Dnd in custom web components by attaching behavior in component lifecycle ho
|
|
|
9957
10621
|
|
|
9958
10622
|
```ts
|
|
9959
10623
|
import { createSortable } from '@vielzeug/dnd';
|
|
9960
|
-
import { define,
|
|
10624
|
+
import { define, getHost, html, onMounted } from '@vielzeug/ore';
|
|
9961
10625
|
|
|
9962
10626
|
define('task-list', {
|
|
9963
|
-
setup(_props
|
|
10627
|
+
setup(_props) {
|
|
10628
|
+
const el = getHost();
|
|
10629
|
+
|
|
9964
10630
|
onMounted(() => {
|
|
9965
10631
|
const sortable = createSortable({
|
|
9966
|
-
element:
|
|
10632
|
+
element: el,
|
|
9967
10633
|
getKey: (el) => el.dataset.sortId!,
|
|
9968
10634
|
onReorder: ({ ids }) => save(ids),
|
|
9969
10635
|
});
|
|
9970
10636
|
return () => sortable.dispose();
|
|
9971
10637
|
});
|
|
9972
|
-
|
|
10638
|
+
|
|
10639
|
+
return html``;
|
|
9973
10640
|
},
|
|
9974
10641
|
});
|
|
9975
10642
|
```
|
|
@@ -9983,12 +10650,14 @@ define('task-list', {
|
|
|
9983
10650
|
- Use `createSortableScope()` only when items should genuinely move between containers.
|
|
9984
10651
|
- Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.
|
|
9985
10652
|
- Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.
|
|
10653
|
+
- Call `createTouchDragShim()` once at app startup if you support touch devices — it's a single `document`-level bridge, not something to attach per `createSortable`/`createDropZone` instance.
|
|
9986
10654
|
|
|
9987
10655
|
### Examples
|
|
9988
10656
|
|
|
9989
10657
|
## Examples
|
|
9990
10658
|
|
|
9991
10659
|
- [Sortable List](./examples/sortable-list.md)
|
|
10660
|
+
- [Touch-Enabled Sortable List](./examples/touch-enabled-sortable-list.md)
|
|
9992
10661
|
- [File Upload Drop Zone](./examples/file-upload-drop-zone.md)
|
|
9993
10662
|
- [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md)
|
|
9994
10663
|
- [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md)
|
|
@@ -12882,17 +13551,15 @@ if (!submission.ok && submission.type === 'validation') {
|
|
|
12882
13551
|
|
|
12883
13552
|
- Typed field paths with compile-time value inference
|
|
12884
13553
|
- Explicit validation API: `validate()`, `validate(name)`, and `validate(fields[])`
|
|
12885
|
-
- Streaming validation with `validateStream()` — yields each field result as it resolves, read-only
|
|
12886
13554
|
- Per-connection validation triggers via `connect()` with `ValidationModes` presets
|
|
12887
13555
|
- `connect()` bindings own independent debounce timers; call `binding.dispose()` on unmount
|
|
12888
13556
|
- `submit(handler)` — returns `{ ok: true, value }` or `{ ok: false, errors }`
|
|
12889
13557
|
- Schema integration: pass any `safeParse`-compatible schema directly to `validator`
|
|
12890
|
-
- `scope(prefix)` — memoized scoped sub-forms that share parent state with relative field paths
|
|
12891
|
-
- `
|
|
12892
|
-
- `snapshot()` / `restore()` — capture and replay complete form state
|
|
13558
|
+
- `scope(prefix)` — memoized scoped sub-forms that share parent state with relative field paths; `subscribe()` on a scoped form filters to just that prefix
|
|
13559
|
+
- `history.snapshot()` / `history.restore()` — capture and replay complete form state
|
|
12893
13560
|
- `form.fields.remove(name)` — clean conditional field lifecycle
|
|
12894
13561
|
- Full array helpers: `append`, `prepend`, `insert`, `remove`, `move`, `swap`, `replace`
|
|
12895
|
-
- Explicit synchronous subscriptions: `subscribe
|
|
13562
|
+
- Explicit synchronous subscriptions: `subscribe` (form or scoped-form state) and `subscribeField` (one field)
|
|
12896
13563
|
- Stable frozen snapshots for `form.state` and `form.field(name)` (external-store friendly)
|
|
12897
13564
|
- Explicit touched and error controls: `touch`, `untouch`, `touchAll`, `untouchAll`, `setError`, `resetErrors`
|
|
12898
13565
|
- Mutation batching with `batch(fn)` and dynamic field validators via `fields.setValidator`
|
|
@@ -12900,7 +13567,7 @@ if (!submission.ok && submission.type === 'validation') {
|
|
|
12900
13567
|
- Browser-first utility: `toFormData`
|
|
12901
13568
|
- Framework-agnostic core — wire into React, Vue, Svelte, or vanilla JS with `subscribe()`/`connect()`, no dedicated adapter package
|
|
12902
13569
|
- `@vielzeug/forge/validators` adapter: `fieldValidator` and `composeValidators`
|
|
12903
|
-
- `@vielzeug/forge/devtools`: opt-in `
|
|
13570
|
+
- `@vielzeug/forge/devtools`: opt-in `debugForm()` for `console.debug` state-transition logging, tree-shaken from production
|
|
12904
13571
|
|
|
12905
13572
|
## Documentation
|
|
12906
13573
|
|
|
@@ -12924,13 +13591,12 @@ if (!submission.ok && submission.type === 'validation') {
|
|
|
12924
13591
|
| `form.get()` / `form.set()` | Read/write field values by dot-path | Sync | `set()` after `dispose()` throws |
|
|
12925
13592
|
| `form.field()` / `form.state` | Read field and form snapshots | Sync | Returns a stable frozen snapshot; re-read on each subscriber call |
|
|
12926
13593
|
| `form.validate()` | Run validation — all fields, a subset, or a single field | Async | Each call re-runs validators from scratch |
|
|
12927
|
-
| `form.validateStream()` | Streaming validation — yields each field result as it resolves | Async (iterator) | Read-only — does not write errors to form state |
|
|
12928
13594
|
| `form.submit()` | Deterministic submit flow returning a `SubmitResult` | Async | Rejects if called while already submitting — guard with `form.isSubmitting` |
|
|
12929
13595
|
| `form.connect()` | Live field binding with DOM event handlers and live getters | Sync | Do not destructure — live getters lose context; call `dispose()` on unmount |
|
|
12930
13596
|
| `form.scope()` | Memoized scoped sub-form with relative field paths | Sync | Returns the same object for repeated calls with the same prefix; `state` is scoped — flags reflect only prefix fields |
|
|
12931
13597
|
| `form.array()` | Array mutation helpers | Sync | Returns a cached helper — call once and reuse |
|
|
12932
|
-
| `form.subscribe()` / `form.subscribeField()`
|
|
12933
|
-
| `form.snapshot()` / `form.restore()`
|
|
13598
|
+
| `form.subscribe()` / `form.subscribeField()` | Synchronous form and field subscriptions | Sync | On a scoped form, `subscribe()` is already prefix-filtered — callbacks receive frozen snapshots |
|
|
13599
|
+
| `form.history.snapshot()` / `form.history.restore()` | Capture and replay complete form state | Sync | Useful for undo/redo and draft saving |
|
|
12934
13600
|
| `form.batch()` | Group mutations into one notification | Sync | Nested `batch()` calls are safe — only the outermost flush notifies |
|
|
12935
13601
|
| `form.touch()` / `form.touchAll()` | Mark fields touched | Sync | `touchAll()` marks every key currently in the store |
|
|
12936
13602
|
| `form.setError()` / `form.clearError()` / `form.resetErrors()` | Manual error management | Sync | `setError()` bypasses validators; cleared on next `validate()` run for that field |
|
|
@@ -12947,7 +13613,7 @@ if (!submission.ok && submission.type === 'validation') {
|
|
|
12947
13613
|
| ---------------------------- | ---------------------------------------------------------------------------------- |
|
|
12948
13614
|
| `@vielzeug/forge` | `createForm`, `toFormData`, `ValidationModes`, `FORM_ERROR`, and all types |
|
|
12949
13615
|
| `@vielzeug/forge/validators` | `fieldValidator`, `composeValidators` — schema and validator composition helpers |
|
|
12950
|
-
| `@vielzeug/forge/devtools` | `
|
|
13616
|
+
| `@vielzeug/forge/devtools` | `debugForm` — opt-in `console.debug` logging for form state transitions |
|
|
12951
13617
|
|
|
12952
13618
|
## createForm()
|
|
12953
13619
|
|
|
@@ -13243,11 +13909,6 @@ subscribeField>(
|
|
|
13243
13909
|
options?: SubscribeOptions,
|
|
13244
13910
|
): Unsubscribe
|
|
13245
13911
|
|
|
13246
|
-
subscribeScoped(
|
|
13247
|
-
listener: (state: FormState) => void,
|
|
13248
|
-
options?: SubscribeOptions,
|
|
13249
|
-
): Unsubscribe
|
|
13250
|
-
|
|
13251
13912
|
type SubscribeOptions = { sync?: boolean };
|
|
13252
13913
|
type Unsubscribe = () => void;
|
|
13253
13914
|
```
|
|
@@ -13256,17 +13917,17 @@ Pass `{ sync: true }` to also receive the current snapshot immediately upon subs
|
|
|
13256
13917
|
|
|
13257
13918
|
Subscriptions fire synchronously whenever the form mutates. Because state snapshots are stable (frozen, reference-equal between mutations), these integrate directly with React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol.
|
|
13258
13919
|
|
|
13259
|
-
###
|
|
13920
|
+
### subscribe on a scoped form
|
|
13260
13921
|
|
|
13261
|
-
`
|
|
13922
|
+
`subscribe` behaves differently depending on which form object it's called on:
|
|
13262
13923
|
|
|
13263
13924
|
- **On a scoped form** — filters `errors`, `touchedFields`, and `validatingFields` to paths within the scope's prefix (remapped to relative paths). The listener is **only called when the scoped projection changes** — mutations outside the scope are suppressed. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form.
|
|
13264
|
-
- **On a root form** —
|
|
13925
|
+
- **On a root form** — no filtering is applied; every mutation notifies the listener.
|
|
13265
13926
|
|
|
13266
13927
|
```ts
|
|
13267
13928
|
const address = form.scope('address');
|
|
13268
13929
|
|
|
13269
|
-
address.
|
|
13930
|
+
address.subscribe((state) => {
|
|
13270
13931
|
// state.errors uses relative keys: { city: '...' } not { 'address.city': '...' }
|
|
13271
13932
|
// only fires when an address.* field changes
|
|
13272
13933
|
console.log(state.errors, state.touchedFields);
|
|
@@ -13293,11 +13954,13 @@ type ArrayField = {
|
|
|
13293
13954
|
|
|
13294
13955
|
`append()` and `prepend()` initialize the field as a one-item array when its current value is `undefined` or `null`. If the field already holds a non-array, non-nullish value (e.g. written by `set()`), both are a no-op — they never overwrite an existing scalar with an array. `insert()`, `remove()`, `move()`, `swap()`, and `replace()` are all no-ops when the field's current value is not an array.
|
|
13295
13956
|
|
|
13296
|
-
## Snapshot / Restore
|
|
13957
|
+
## History (Snapshot / Restore)
|
|
13297
13958
|
|
|
13298
13959
|
```ts
|
|
13299
|
-
|
|
13300
|
-
|
|
13960
|
+
history: {
|
|
13961
|
+
snapshot(): FormSnapshot;
|
|
13962
|
+
restore(snap: FormSnapshot): void;
|
|
13963
|
+
}
|
|
13301
13964
|
|
|
13302
13965
|
type FormSnapshot = {
|
|
13303
13966
|
readonly baseline: Partial, unknown>>;
|
|
@@ -13309,44 +13972,17 @@ type FormSnapshot = {
|
|
|
13309
13972
|
};
|
|
13310
13973
|
```
|
|
13311
13974
|
|
|
13312
|
-
- `snapshot()` — captures the complete form state (values, baseline, errors, touched, dirty, submitCount) into a plain object.
|
|
13313
|
-
- `restore(snap)` — replaces all state with the snapshot. Aborts any in-flight validation.
|
|
13975
|
+
- `history.snapshot()` — captures the complete form state (values, baseline, errors, touched, dirty, submitCount) into a plain object.
|
|
13976
|
+
- `history.restore(snap)` — replaces all state with the snapshot. Aborts any in-flight validation.
|
|
13314
13977
|
|
|
13315
|
-
Useful for undo/redo, draft saving, and "discard changes" flows:
|
|
13978
|
+
Namespaced off the main `Form` surface since it's a distinctly less common operation than reading/writing values — grouped the same way `fields` already groups dynamic-field-lifecycle operations. Useful for undo/redo, draft saving, and "discard changes" flows:
|
|
13316
13979
|
|
|
13317
13980
|
```ts
|
|
13318
|
-
const draft = form.snapshot();
|
|
13981
|
+
const draft = form.history.snapshot();
|
|
13319
13982
|
|
|
13320
13983
|
form.set('email', 'changed@example.com');
|
|
13321
13984
|
|
|
13322
|
-
form.restore(draft); // reverts all changes
|
|
13323
|
-
```
|
|
13324
|
-
|
|
13325
|
-
## validateStream()
|
|
13326
|
-
|
|
13327
|
-
```ts
|
|
13328
|
-
validateStream(signal?: AbortSignal): AsyncIterableIterator
|
|
13329
|
-
```
|
|
13330
|
-
|
|
13331
|
-
Runs all field validators in parallel and yields each result as soon as its validator resolves. If a form-level validator is configured, all keys it returns are yielded last — including `field: '_form'` and any field-specific keys returned by the form validator.
|
|
13332
|
-
|
|
13333
|
-
**Read-only**: `validateStream()` does not write to `fieldErrors` or trigger subscriber notifications. Use `validate()` when you want errors applied to form state.
|
|
13334
|
-
|
|
13335
|
-
```ts
|
|
13336
|
-
for await (const { field, error } of form.validateStream()) {
|
|
13337
|
-
if (error) showInlineError(field, error);
|
|
13338
|
-
}
|
|
13339
|
-
// After the loop: form.state.errors is unchanged
|
|
13340
|
-
```
|
|
13341
|
-
|
|
13342
|
-
Pass an `AbortSignal` to cancel the stream:
|
|
13343
|
-
|
|
13344
|
-
```ts
|
|
13345
|
-
const ctrl = new AbortController();
|
|
13346
|
-
for await (const result of form.validateStream(ctrl.signal)) {
|
|
13347
|
-
processResult(result);
|
|
13348
|
-
}
|
|
13349
|
-
ctrl.abort(); // cancels any remaining in-flight validators
|
|
13985
|
+
form.history.restore(draft); // reverts all changes
|
|
13350
13986
|
```
|
|
13351
13987
|
|
|
13352
13988
|
## Baseline and Value Management
|
|
@@ -13471,7 +14107,7 @@ const form = createForm({
|
|
|
13471
14107
|
Opt-in `console.debug` logging for form state transitions. Not exported from the main `@vielzeug/forge` entry point — import from this sub-path so the logging code is tree-shaken from production bundles.
|
|
13472
14108
|
|
|
13473
14109
|
```ts
|
|
13474
|
-
function
|
|
14110
|
+
function debugForm>(
|
|
13475
14111
|
form: Form,
|
|
13476
14112
|
options?: ForgeDevtoolsOptions,
|
|
13477
14113
|
): Unsubscribe;
|
|
@@ -13487,10 +14123,10 @@ Logs one line per observable state transition: per-field `value`/`error`/`touche
|
|
|
13487
14123
|
|
|
13488
14124
|
```ts
|
|
13489
14125
|
import { createForm } from '@vielzeug/forge';
|
|
13490
|
-
import {
|
|
14126
|
+
import { debugForm } from '@vielzeug/forge/devtools';
|
|
13491
14127
|
|
|
13492
14128
|
const form = createForm({ defaultValues: { email: '' } });
|
|
13493
|
-
const detach =
|
|
14129
|
+
const detach = debugForm(form, { label: 'signup' });
|
|
13494
14130
|
// [forge:devtools:signup] field "email" value: "" → "a@b.com"
|
|
13495
14131
|
|
|
13496
14132
|
detach(); // stop logging
|
|
@@ -13542,12 +14178,15 @@ type ForgeDevtoolsOptions
|
|
|
13542
14178
|
|
|
13543
14179
|
// Utility types
|
|
13544
14180
|
type DeepPartial
|
|
13545
|
-
type FlatKeyOf
|
|
14181
|
+
type FlatKeyOf // capped at MAX_TYPED_PATH_DEPTH (5) — see below
|
|
13546
14182
|
type TypeAtPath
|
|
13547
14183
|
type ErrorKeyOf
|
|
13548
14184
|
type ScopedValues
|
|
14185
|
+
const MAX_TYPED_PATH_DEPTH // = 5 — not user-configurable, see rationale below
|
|
13549
14186
|
```
|
|
13550
14187
|
|
|
14188
|
+
`FlatKeyOf` falls back to plain `string` for paths deeper than `MAX_TYPED_PATH_DEPTH` — nothing throws, you just lose autocomplete/type-checking on that specific path (a dev-only console warning fires once per deep key so this is discoverable, not silent). This is a fixed constant, not a per-form generic parameter: it exists purely to protect TypeScript compile time, which doesn't get safer by letting one form's type param regress it for everyone importing that form's module.
|
|
14189
|
+
|
|
13551
14190
|
## Errors
|
|
13552
14191
|
|
|
13553
14192
|
Forge exports a small typed error hierarchy, all extending a common `ForgeError` base:
|
|
@@ -13629,6 +14268,12 @@ console.log(result.valid); // true only if no errors exist after this run
|
|
|
13629
14268
|
console.log(result.errors); // full current error map after the run
|
|
13630
14269
|
```
|
|
13631
14270
|
|
|
14271
|
+
Validation race semantics:
|
|
14272
|
+
|
|
14273
|
+
- Forge aborts superseded runs instead of surfacing them as failures.
|
|
14274
|
+
- The newest validation run owns final field errors.
|
|
14275
|
+
- Aborted runs resolve without throwing in normal API usage.
|
|
14276
|
+
|
|
13632
14277
|
Schema integration — pass a `safeParse`-compatible schema directly to `validator`:
|
|
13633
14278
|
|
|
13634
14279
|
```ts
|
|
@@ -13778,20 +14423,36 @@ await address.validate(); // validates only address.* fields; returns scoped err
|
|
|
13778
14423
|
await address.submit((vals) => vals); // validates and submits only address.* fields
|
|
13779
14424
|
```
|
|
13780
14425
|
|
|
14426
|
+
### Root vs Scoped Path Semantics
|
|
14427
|
+
|
|
14428
|
+
| Surface | Root form (`form`) | Scoped form (`form.scope('address')`) |
|
|
14429
|
+
| -------------------------- | ------------------ | ------------------------------------- |
|
|
14430
|
+
| `get` / `set` / `field` | absolute keys | relative keys |
|
|
14431
|
+
| `errors` in `state` | absolute keys | relative keys |
|
|
14432
|
+
| `touchedFields` in `state` | absolute keys | relative keys |
|
|
14433
|
+
| `validatingFields` in `state` | absolute keys | relative keys |
|
|
14434
|
+
| `validate(name)` input | absolute key | relative key |
|
|
14435
|
+
| `validate()` result keys | absolute keys | relative keys |
|
|
14436
|
+
|
|
14437
|
+
### Recommended Scoped Patterns
|
|
14438
|
+
|
|
14439
|
+
1. Call `const address = form.scope('address')` once per UI/module boundary and pass that around.
|
|
14440
|
+
2. Use `address.validate()` / `address.submit()` instead of manually feeding `state.touchedFields` into `validate(fields[])`.
|
|
14441
|
+
3. Use `address.subscribe(...)` for section UIs — on a scoped form `subscribe` is already prefix-filtered, so sibling/root mutations do not trigger redraws.
|
|
14442
|
+
|
|
13781
14443
|
**Key characteristics:**
|
|
13782
14444
|
|
|
13783
14445
|
- `dispose()` on a scoped form is a no-op — call `parentForm.dispose()` to tear down.
|
|
13784
|
-
- `scope.state` returns a **scoped projection**: `errors`, `touchedFields`, `validatingFields`, `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect only fields within the scope's prefix. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form. Use `scope.validate()` or `scope.submit()` for scoped validity checks; their results
|
|
13785
|
-
- `touchedFields` in `state` contains full-prefixed paths. Prefer `scope.validate()` over `scope.validate([...state.touchedFields])` to avoid double-prefixing.
|
|
14446
|
+
- `scope.state` returns a **scoped projection**: `errors`, `touchedFields`, `validatingFields`, `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect only fields within the scope's prefix and use relative keys. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form. Use `scope.validate()` or `scope.submit()` for scoped validity checks; their results also use relative keys.
|
|
13786
14447
|
|
|
13787
14448
|
### Scoped Subscriptions
|
|
13788
14449
|
|
|
13789
|
-
`
|
|
14450
|
+
On a scoped form, `subscribe()` delivers form state filtered to the scope's prefix. `errors`, `touchedFields`, and `validatingFields` use relative keys. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. The listener **only fires when the scoped projection changes** — mutations outside the prefix are suppressed. (On a root form, `subscribe()` behaves identically to today — no filtering applies.)
|
|
13790
14451
|
|
|
13791
14452
|
```ts
|
|
13792
14453
|
const address = form.scope('address');
|
|
13793
14454
|
|
|
13794
|
-
address.
|
|
14455
|
+
address.subscribe((state) => {
|
|
13795
14456
|
// state.errors → { city: 'Required' } (not 'address.city')
|
|
13796
14457
|
// state.isDirty → true only when an address.* field is dirty
|
|
13797
14458
|
// does not fire when form.set('name', 'Alice') is called
|
|
@@ -13823,29 +14484,18 @@ Snapshot semantics:
|
|
|
13823
14484
|
- Reference identity is preserved until a relevant mutation occurs.
|
|
13824
14485
|
- These are directly compatible with external-store patterns such as React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol.
|
|
13825
14486
|
|
|
13826
|
-
##
|
|
13827
|
-
|
|
13828
|
-
`validateStream()` runs all field validators in parallel and yields each result as it resolves. It is **read-only** — it does not write errors to form state. The form-level validator, if set, is yielded last with `field: '_form'`.
|
|
14487
|
+
## History (Snapshot / Restore)
|
|
13829
14488
|
|
|
13830
|
-
|
|
13831
|
-
for await (const { field, error } of form.validateStream()) {
|
|
13832
|
-
if (error) showInlineError(field, error);
|
|
13833
|
-
}
|
|
13834
|
-
// form.state.errors is unchanged after the loop
|
|
13835
|
-
```
|
|
13836
|
-
|
|
13837
|
-
## Snapshots and Restore
|
|
13838
|
-
|
|
13839
|
-
Capture and replay complete form state for undo/redo or "discard changes" flows:
|
|
14489
|
+
Capture and replay complete form state for undo/redo or "discard changes" flows, via the `history` namespace:
|
|
13840
14490
|
|
|
13841
14491
|
```ts
|
|
13842
|
-
const draft = form.snapshot();
|
|
14492
|
+
const draft = form.history.snapshot();
|
|
13843
14493
|
|
|
13844
14494
|
// ... user edits ...
|
|
13845
14495
|
form.set('email', 'different@example.com');
|
|
13846
14496
|
|
|
13847
14497
|
// Revert all changes, including errors, touched, dirty, and submitCount
|
|
13848
|
-
form.restore(draft);
|
|
14498
|
+
form.history.restore(draft);
|
|
13849
14499
|
```
|
|
13850
14500
|
|
|
13851
14501
|
## Arrays
|
|
@@ -13900,12 +14550,12 @@ After `dispose()`, all mutating APIs throw.
|
|
|
13900
14550
|
|
|
13901
14551
|
## Debugging
|
|
13902
14552
|
|
|
13903
|
-
Import `
|
|
14553
|
+
Import `debugForm()` from the dedicated `/devtools` sub-path to log per-field value/error/touched/dirty changes and submit/loading transitions via `console.debug`:
|
|
13904
14554
|
|
|
13905
14555
|
```ts
|
|
13906
|
-
import {
|
|
14556
|
+
import { debugForm } from '@vielzeug/forge/devtools';
|
|
13907
14557
|
|
|
13908
|
-
const detach =
|
|
14558
|
+
const detach = debugForm(form, { label: 'signup' });
|
|
13909
14559
|
// later, e.g. on unmount:
|
|
13910
14560
|
detach();
|
|
13911
14561
|
```
|
|
@@ -14091,7 +14741,6 @@ const formWithSchema = createForm({
|
|
|
14091
14741
|
- Field & Form Validation (id: `form-validation`)
|
|
14092
14742
|
- Schema Integration - safeParse Auto-detection (id: `schema-integration`)
|
|
14093
14743
|
- Scoped Sub-Forms (scope) (id: `scoped-sub-forms`)
|
|
14094
|
-
- Streaming Validation (validateStream) (id: `validate-stream`)
|
|
14095
14744
|
|
|
14096
14745
|
|
|
14097
14746
|
---
|
|
@@ -17578,7 +18227,7 @@ effect(() => {
|
|
|
17578
18227
|
|
|
17579
18228
|
**Category:** i18n
|
|
17580
18229
|
**Keywords:** internationalization, translations, pluralization, locale, i18n, l10n, async-loading
|
|
17581
|
-
**Key exports:** createI18n, createFormatter,
|
|
18230
|
+
**Key exports:** createI18n, createFormatter, validateCatalog, LinguaError, LinguaDisposedError, LinguaInvalidCountError, LinguaCountInVarsError, LinguaMissingLocaleError, LinguaInvalidLocaleError, LinguaNamespaceMissingError, LinguaRestoreError
|
|
17582
18231
|
**Related:** ripple, wayfinder, courier
|
|
17583
18232
|
|
|
17584
18233
|
### Overview
|
|
@@ -17693,7 +18342,7 @@ i18n.getSupportedLocales();
|
|
|
17693
18342
|
- Namespace lazy loading: `registerNamespace(ns, factory)` + `loadNamespace(ns, locale?)` — or use `extend(ns, factory, locale?)` as a convenience that does both; deduplicates per `ns + locale`; use for per-route or per-feature keys
|
|
17694
18343
|
- Scoped translation helpers: `scope(prefix)` returns a `{ fmt, t, tp, has }` helper bound to a key prefix
|
|
17695
18344
|
- Unified key existence check: `has(key)` returns `true` for leaf keys, branch keys, and pipe-plural base keys in the active fallback chain
|
|
17696
|
-
- Loaded-locale predicate: `isLoaded(locale)` returns `true` when a catalog is fully resolved — safe for `
|
|
18345
|
+
- Loaded-locale predicate: `isLoaded(locale)` returns `true` when a catalog is fully resolved — safe for `getState()` guards
|
|
17697
18346
|
- Registered-locale predicate: `isRegistered(locale)` distinguishes "never configured" from "async loader not yet called"
|
|
17698
18347
|
- Instance disposal: `dispose()` clears all subscribers and catalog state — prevents memory leaks in route-scoped SPA instances
|
|
17699
18348
|
- Typed error handling: every thrown/rejected error is `instanceof LinguaError`, with named subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, `LinguaNamespaceMissingError`, …) for specific `instanceof` branching
|
|
@@ -17702,6 +18351,7 @@ i18n.getSupportedLocales();
|
|
|
17702
18351
|
- Deterministic fallback chain using active locale plus configured fallback locales
|
|
17703
18352
|
- Separate missing handlers: `onMissingKey(key, locale)` and `onMissingVar(varName, key, locale)`
|
|
17704
18353
|
- Formatting via `createFormatter(source)` — exported from the main entry alongside `createI18n`
|
|
18354
|
+
- Automatic dev-mode plural-form validation: every catalog registered or loaded is checked against CLDR rules for its locale, logged via `console.warn` — zero setup, and never bundled into production
|
|
17705
18355
|
|
|
17706
18356
|
## Documentation
|
|
17707
18357
|
|
|
@@ -17741,13 +18391,11 @@ i18n.getSupportedLocales();
|
|
|
17741
18391
|
| `i18n.loadNamespace()` | Load a registered namespace for a locale | Async | Deduplicates concurrent and repeated calls; throws `LinguaNamespaceMissingError` if namespace not registered |
|
|
17742
18392
|
| `i18n.isNamespaceLoaded()` | Check if a namespace is loaded for the active (or given) locale | Sync | Returns `false` if not registered or not yet loaded for this locale |
|
|
17743
18393
|
| `i18n.isNamespaceRegistered()` | Check if a namespace factory has been registered | Sync | `true` after `registerNamespace()` or `extend()`; `false` before |
|
|
17744
|
-
| `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync |
|
|
17745
|
-
| `i18n.restoreState()` | Hydrate instance from serialized state | Sync |
|
|
17746
|
-
| `serializeI18n()` | Serialise loaded catalogs for SSR hydration | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
|
|
17747
|
-
| `hydrateI18n()` | Hydrate a client instance from server-serialised state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
|
|
18394
|
+
| `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
|
|
18395
|
+
| `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
|
|
17748
18396
|
| Error classes | Named error subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, …) | — | All runtime errors are `instanceof LinguaError`; use `instanceof` for specific handling |
|
|
17749
18397
|
| `createFormatter()` | Create a standalone Intl formatter | Sync | Available from the main entry or `@vielzeug/lingua/format` — pass a getter `() => i18n.locale` to follow locale changes |
|
|
17750
|
-
| `validateCatalog()` | Check a catalog for missing CLDR plural forms and missing `{count}` interpolations | Sync | Import from `@vielzeug/lingua/validate` —
|
|
18398
|
+
| `validateCatalog()` | Check a catalog for missing CLDR plural forms and missing `{count}` interpolations | Sync | Import from `@vielzeug/lingua/validate` for CI enforcement — `createI18n()` already runs the same check automatically in dev builds |
|
|
17751
18399
|
|
|
17752
18400
|
## Package Entry Points
|
|
17753
18401
|
|
|
@@ -17945,7 +18593,7 @@ Returns `true` if a namespace factory has been registered under `ns` via `regist
|
|
|
17945
18593
|
getState(): I18nState
|
|
17946
18594
|
```
|
|
17947
18595
|
|
|
17948
|
-
Extracts a serializable snapshot of all **fully loaded** catalogs and the active locale.
|
|
18596
|
+
Extracts a serializable snapshot of all **fully loaded** catalogs and the active locale.
|
|
17949
18597
|
|
|
17950
18598
|
**Warning:** Only fully resolved catalogs are included. Loader-only locales not yet preloaded are omitted. Use `i18n.isLoaded(locale)` to verify before calling.
|
|
17951
18599
|
|
|
@@ -17960,13 +18608,15 @@ const state = i18n.getState();
|
|
|
17960
18608
|
restoreState(state: I18nState): void
|
|
17961
18609
|
```
|
|
17962
18610
|
|
|
17963
|
-
Hydrates this instance from an `I18nState` produced by `getState()
|
|
18611
|
+
Hydrates this instance from an `I18nState` produced by `getState()`.
|
|
17964
18612
|
|
|
17965
18613
|
- Replaces all catalogs with those from `state`.
|
|
17966
18614
|
- Sets the active locale to `state.locale`.
|
|
17967
18615
|
- Clears all namespace loaded-markers so that `extend()` / `loadNamespace()` can re-apply namespaces.
|
|
17968
18616
|
- Notifies subscribers.
|
|
17969
18617
|
|
|
18618
|
+
Unlike `register()` and construction, this does **not** run the automatic dev-mode plural-form check (see [`validateCatalog()`](#validatecatalog)) — `state` is assumed to already have been registered, and therefore already checked, once on whatever system produced it.
|
|
18619
|
+
|
|
17970
18620
|
Throws `LinguaRestoreError` if `state.locale` has no catalog in `state.catalogs`.
|
|
17971
18621
|
Throws `LinguaDisposedError` if called on a disposed instance.
|
|
17972
18622
|
|
|
@@ -18090,13 +18740,13 @@ isLoaded(locale: Locale): boolean
|
|
|
18090
18740
|
|
|
18091
18741
|
Returns `true` if the catalog for `locale` is fully resolved (i.e. not a pending async loader). Returns `false` for unregistered locales, pending loaders, and invalid locale tags — never throws.
|
|
18092
18742
|
|
|
18093
|
-
Primary use case: guarding `
|
|
18743
|
+
Primary use case: guarding `getState()` in SSR to avoid silently omitting locales that were registered as async loaders but never preloaded.
|
|
18094
18744
|
|
|
18095
18745
|
```ts
|
|
18096
18746
|
// SSR guard — ensure all locales are loaded before serialising
|
|
18097
18747
|
const locales = i18n.getSupportedLocales();
|
|
18098
18748
|
await Promise.all(locales.filter((l) => !i18n.isLoaded(l)).map((l) => i18n.preload(l)));
|
|
18099
|
-
const state =
|
|
18749
|
+
const state = i18n.getState(); // now includes all locales
|
|
18100
18750
|
```
|
|
18101
18751
|
|
|
18102
18752
|
### `isRegistered()`
|
|
@@ -18118,7 +18768,7 @@ Use `isRegistered` + `isLoaded` together to distinguish the three states:
|
|
|
18118
18768
|
```ts
|
|
18119
18769
|
if (!i18n.isRegistered('fr')) throw new Error('fr locale not configured');
|
|
18120
18770
|
if (!i18n.isLoaded('fr')) await i18n.preload('fr');
|
|
18121
|
-
const state =
|
|
18771
|
+
const state = i18n.getState(); // 'fr' guaranteed to be present
|
|
18122
18772
|
```
|
|
18123
18773
|
|
|
18124
18774
|
### `disposalSignal`
|
|
@@ -18202,6 +18852,8 @@ Checks a flat or nested message catalog against CLDR plural rules for `locale`.
|
|
|
18202
18852
|
|
|
18203
18853
|
Returns an empty array when there are no issues.
|
|
18204
18854
|
|
|
18855
|
+
**Automatic dev-mode checks:** `createI18n()` already calls this internally, in dev builds only, every time a catalog becomes fully available — at construction (`createI18n({ catalogs })`), via `register()`, or once an async loader resolves — logging any warning through `console.warn`. Call `validateCatalog()` directly only when you want CI to fail the build on a warning rather than just log it; the automatic check already covers everyday authoring feedback with zero setup. The automatic check loads `validate.ts`'s logic as a separate, lazily-fetched chunk — it's never part of your production bundle either way.
|
|
18856
|
+
|
|
18205
18857
|
**Note:** A branch is treated as a plural branch when any of its child keys is a CLDR form (`zero`, `one`, `two`, `few`, `many`, `other`). A mixed-use branch (e.g. `{ count: 'x', one: 'y' }`) will also be flagged and may produce spurious warnings for non-CLDR sibling keys.
|
|
18206
18858
|
|
|
18207
18859
|
`validateCatalog` also checks for a common authoring error: a form template for `other`, `two`, `few`, or `many` that does not contain `{count}`. Since `tp()` injects `count` automatically, omitting it from a non-singleton form is almost always a mistake. These warnings use `form: ':missing-count'` (e.g. `'other:missing-count'`). The `zero` and `one` forms are exempt — intentionally omitting `{count}` is normal there (e.g. `'No messages'`, `'One message'`).
|
|
@@ -18321,7 +18973,7 @@ type I18nState = {
|
|
|
18321
18973
|
};
|
|
18322
18974
|
```
|
|
18323
18975
|
|
|
18324
|
-
Produced by `getState()`
|
|
18976
|
+
Produced by `getState()` and consumed by `restoreState()`. Catalogs are stored as flat dot-notation maps.
|
|
18325
18977
|
|
|
18326
18978
|
### `NamespaceFactory`
|
|
18327
18979
|
|
|
@@ -18480,50 +19132,25 @@ type ListFormatOptions = {
|
|
|
18480
19132
|
};
|
|
18481
19133
|
```
|
|
18482
19134
|
|
|
18483
|
-
##
|
|
19135
|
+
## SSR: `getState()` / `restoreState()`
|
|
18484
19136
|
|
|
18485
|
-
|
|
18486
|
-
import { serializeI18n } from '@vielzeug/lingua';
|
|
18487
|
-
|
|
18488
|
-
serializeI18n(i18n: I18n): I18nState
|
|
18489
|
-
```
|
|
18490
|
-
|
|
18491
|
-
Serialises the current loaded catalogs and active locale into an `I18nState` object. Use this on the server before embedding state in the HTML response. Loader-only locales that have not been preloaded are silently omitted — call `isLoaded()` to verify all locales are resolved before calling `serializeI18n()`.
|
|
19137
|
+
No standalone functions — call these directly on an instance (see [`getState()`](#getstate) / [`restoreState()`](#restorestate) above).
|
|
18492
19138
|
|
|
18493
19139
|
```ts
|
|
18494
19140
|
// Server
|
|
18495
19141
|
const i18n = createI18n({ catalogs: { de: deMessages, en: enMessages }, locale: 'de' });
|
|
18496
|
-
const state =
|
|
19142
|
+
const state = i18n.getState();
|
|
18497
19143
|
// Embed in the HTML response:
|
|
18498
19144
|
// window.__I18N__ = ${JSON.stringify(state)}
|
|
18499
19145
|
```
|
|
18500
19146
|
|
|
18501
|
-
## hydrateI18n
|
|
18502
|
-
|
|
18503
|
-
```ts
|
|
18504
|
-
import { hydrateI18n } from '@vielzeug/lingua';
|
|
18505
|
-
|
|
18506
|
-
hydrateI18n(i18n: I18n, state: I18nState): void
|
|
18507
|
-
```
|
|
18508
|
-
|
|
18509
|
-
Hydrates a client-side instance from server-serialised state. Replaces all catalogs and switches the active locale. Notifies subscribers once after hydration.
|
|
18510
|
-
|
|
18511
|
-
Throws `LinguaRestoreError` if `state.locale` has no corresponding entry in `state.catalogs`.
|
|
18512
|
-
|
|
18513
19147
|
```ts
|
|
18514
19148
|
// Client
|
|
18515
|
-
const
|
|
18516
|
-
|
|
19149
|
+
const client = createI18n();
|
|
19150
|
+
client.restoreState(window.__I18N__);
|
|
18517
19151
|
// Catalogs from state are immediately available; no network request needed.
|
|
18518
19152
|
```
|
|
18519
19153
|
|
|
18520
|
-
**Parameters:**
|
|
18521
|
-
|
|
18522
|
-
| Parameter | Type | Description |
|
|
18523
|
-
| --------- | ----------- | ------------------------------------------ |
|
|
18524
|
-
| `i18n` | `I18n` | The instance to hydrate. |
|
|
18525
|
-
| `state` | `I18nState` | State object produced by `serializeI18n()` |
|
|
18526
|
-
|
|
18527
19154
|
## Error Classes
|
|
18528
19155
|
|
|
18529
19156
|
All errors thrown by the `@vielzeug/lingua` runtime extend `LinguaError`. Use `instanceof LinguaError` to catch any lingua error, or `instanceof` the specific subclass for precise handling.
|
|
@@ -18551,7 +19178,7 @@ try {
|
|
|
18551
19178
|
| `LinguaMissingLocaleError` | `preload()` / `setLocale()` — locale has no registered source |
|
|
18552
19179
|
| `LinguaInvalidLocaleError` | Any API receiving an invalid BCP 47 tag |
|
|
18553
19180
|
| `LinguaNamespaceMissingError` | Namespace requested but not loaded for the current locale |
|
|
18554
|
-
| `LinguaRestoreError` | `
|
|
19181
|
+
| `LinguaRestoreError` | `restoreState()` — `state.locale` absent from `state.catalogs` |
|
|
18555
19182
|
|
|
18556
19183
|
### Usage Guide
|
|
18557
19184
|
|
|
@@ -18715,7 +19342,9 @@ Without `onMissingKey`, missing keys return the key string. Without `onMissingVa
|
|
|
18715
19342
|
|
|
18716
19343
|
## Validating Catalogs
|
|
18717
19344
|
|
|
18718
|
-
|
|
19345
|
+
`createI18n()` already runs this check automatically in dev builds — every time a catalog becomes available (construction, `register()`, or an async loader resolving), it's checked against CLDR plural rules and any issue is logged via `console.warn`. This costs nothing in production: the check runs behind the same dev-only gate as the rest of `@vielzeug/lingua`'s dev warnings, and the validation logic itself only loads as a separate on-demand chunk, never bundled into your app.
|
|
19346
|
+
|
|
19347
|
+
For CI enforcement (failing a build rather than just warning), call `validateCatalog()` directly. Import it from the dedicated `@vielzeug/lingua/validate` entry — never from the main entry or it will end up in your production bundle.
|
|
18719
19348
|
|
|
18720
19349
|
```ts
|
|
18721
19350
|
import { validateCatalog } from '@vielzeug/lingua/validate';
|
|
@@ -18754,7 +19383,7 @@ Key characteristics:
|
|
|
18754
19383
|
|
|
18755
19384
|
## SSR Hydration
|
|
18756
19385
|
|
|
18757
|
-
|
|
19386
|
+
Use the instance methods `getState()` on the server and `restoreState()` on the client:
|
|
18758
19387
|
|
|
18759
19388
|
```ts
|
|
18760
19389
|
import { createI18n } from '@vielzeug/lingua';
|
|
@@ -18771,7 +19400,7 @@ i18n.restoreState(window.__I18N__);
|
|
|
18771
19400
|
// Catalogs from state are immediately available; no network request needed.
|
|
18772
19401
|
```
|
|
18773
19402
|
|
|
18774
|
-
`restoreState()` replaces all catalogs, switches the active locale, clears namespace loaded-markers, and notifies subscribers once.
|
|
19403
|
+
`restoreState()` replaces all catalogs, switches the active locale, clears namespace loaded-markers, and notifies subscribers once.
|
|
18775
19404
|
|
|
18776
19405
|
**Warning:** `getState()` silently omits locales registered as async loaders but not yet preloaded. Use `isLoaded()` to guard:
|
|
18777
19406
|
|
|
@@ -18916,12 +19545,12 @@ router.subscribe(() => {
|
|
|
18916
19545
|
- Keep translation keys flat or one level deep — deeply nested keys are harder to refactor.
|
|
18917
19546
|
- Set `fallback` to a locale with 100% coverage so missing keys degrade gracefully.
|
|
18918
19547
|
- Use `extend(ns, factory, locale?)` or `registerNamespace()` + `loadNamespace()` for per-route or per-feature key sets.
|
|
18919
|
-
- Use `isLoaded(locale)` before `getState()`
|
|
19548
|
+
- Use `isLoaded(locale)` before `getState()` in SSR to avoid silently omitting async-loader locales.
|
|
18920
19549
|
- Use `isRegistered(locale)` to check if a locale is configured; use `isLoaded(locale)` to check if it is ready.
|
|
18921
19550
|
- Call `dispose()` on route-level or request-scoped `fork()` instances when they are no longer needed.
|
|
18922
19551
|
- Use `{ signal }` in `subscribe()` for lifecycle-safe subscriptions; use the returned `Unsubscribe` otherwise.
|
|
18923
19552
|
- Use `onMissingKey` and `onMissingVar` in development to surface authoring errors early; omit them in production.
|
|
18924
|
-
-
|
|
19553
|
+
- `createI18n()` already validates plural forms automatically in dev builds — only import `validateCatalog` from `@vielzeug/lingua/validate` directly if you want CI to fail the build on a warning.
|
|
18925
19554
|
- Share one `i18n` instance per app entry point; avoid creating separate instances per component.
|
|
18926
19555
|
|
|
18927
19556
|
### Examples
|
|
@@ -18955,7 +19584,7 @@ router.subscribe(() => {
|
|
|
18955
19584
|
- Pluralization Rules (id: `pluralization`)
|
|
18956
19585
|
- Preload Pattern (id: `preload-pattern`)
|
|
18957
19586
|
- scope(), has(), isLoaded(), extend() (id: `scope-bind`)
|
|
18958
|
-
-
|
|
19587
|
+
- getState() / restoreState() — SSR hydration (id: `ssr-hydration`)
|
|
18959
19588
|
- createFormatter() — standalone (no createI18n) (id: `standalone-formatter`)
|
|
18960
19589
|
- Variable Interpolation (id: `variable-interpolation`)
|
|
18961
19590
|
|
|
@@ -20718,11 +21347,13 @@ function useFloat(referenceRef: { value: HTMLElement | null }, floatingRef: { va
|
|
|
20718
21347
|
Use Orbit inside a Ore component to position tooltips and popovers reactively.
|
|
20719
21348
|
|
|
20720
21349
|
```ts
|
|
20721
|
-
import { define, html } from '@vielzeug/ore';
|
|
21350
|
+
import { define, getHost, html, onMounted } from '@vielzeug/ore';
|
|
20722
21351
|
import { flip, float, offset, shift } from '@vielzeug/orbit';
|
|
20723
21352
|
|
|
20724
21353
|
define('x-tooltip', {
|
|
20725
|
-
setup(_props
|
|
21354
|
+
setup(_props) {
|
|
21355
|
+
const el = getHost();
|
|
21356
|
+
|
|
20726
21357
|
onMounted(() => {
|
|
20727
21358
|
const tooltipEl = el.querySelector('[role=tooltip]')!;
|
|
20728
21359
|
|
|
@@ -20783,7 +21414,7 @@ define('x-tooltip', {
|
|
|
20783
21414
|
|
|
20784
21415
|
**Category:** ui-primitives
|
|
20785
21416
|
**Keywords:** web-components, custom-elements, reactive, templates, signals, lifecycle
|
|
20786
|
-
**Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict,
|
|
21417
|
+
**Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, provide, onMounted, onCleanup, useEmit (+15 more)
|
|
20787
21418
|
**Related:** ripple, refine, orbit
|
|
20788
21419
|
|
|
20789
21420
|
### Overview
|
|
@@ -20854,7 +21485,7 @@ yarn add @vielzeug/ore
|
|
|
20854
21485
|
|
|
20855
21486
|
```ts
|
|
20856
21487
|
import { computed, signal } from '@vielzeug/ripple';
|
|
20857
|
-
import { css, define, html, prop } from '@vielzeug/ore';
|
|
21488
|
+
import { bind, css, define, html, onMounted, prop } from '@vielzeug/ore';
|
|
20858
21489
|
|
|
20859
21490
|
define('my-counter', {
|
|
20860
21491
|
props: {
|
|
@@ -20869,7 +21500,7 @@ define('my-counter', {
|
|
|
20869
21500
|
}
|
|
20870
21501
|
`,
|
|
20871
21502
|
],
|
|
20872
|
-
setup(props
|
|
21503
|
+
setup(props) {
|
|
20873
21504
|
const count = signal(0);
|
|
20874
21505
|
const doubled = computed(() => count.value * 2);
|
|
20875
21506
|
|
|
@@ -20890,12 +21521,13 @@ define('my-counter', {
|
|
|
20890
21521
|
- Signal-first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple` — import them directly
|
|
20891
21522
|
- Functional component authoring via `define(tag, { props, setup, styles, formAssociated })`
|
|
20892
21523
|
- Props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneOf`, `prop.json`, `prop.data`) or raw `PropDef` objects
|
|
20893
|
-
-
|
|
20894
|
-
- Lifecycle hooks — `onMounted`, `onCleanup`, `onEvent`, `onElement`, `
|
|
21524
|
+
- `setup(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\``
|
|
21525
|
+
- Lifecycle hooks — `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect` — plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls
|
|
20895
21526
|
- Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `model`, `raw`
|
|
20896
|
-
- Host bindings via `
|
|
20897
|
-
- Reactive ARIA sync via `
|
|
20898
|
-
-
|
|
21527
|
+
- Host bindings via `bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off-host element
|
|
21528
|
+
- Reactive ARIA sync via `aria(target, config)` — applies `aria-*` attributes reactively to any element, auto-cleanup on disconnect
|
|
21529
|
+
- Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()`
|
|
21530
|
+
- Form-associated helpers (`@vielzeug/ore/forms`): `useField()`, `createFormContext()`
|
|
20899
21531
|
- Observers (`@vielzeug/ore/observers`)
|
|
20900
21532
|
- Testing utilities (`@vielzeug/ore/testing`) — `mount`, `renderHook`, `fire`, `user`, `waitFor`, `cleanup`
|
|
20901
21533
|
- Debug utilities (`@vielzeug/ore/devtools`) — `debugFlush()` for diagnosing update timing
|
|
@@ -20904,9 +21536,10 @@ define('my-counter', {
|
|
|
20904
21536
|
|
|
20905
21537
|
| Import | Purpose |
|
|
20906
21538
|
| --------------------------- | ----------------------------------------------------------------------------- |
|
|
20907
|
-
| `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context,
|
|
21539
|
+
| `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context), plus the everyday template directives (`each`, `when`, `model`, `classMap`, `styleMap`) |
|
|
20908
21540
|
| `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics (dev only) |
|
|
20909
|
-
| `@vielzeug/ore/directives` |
|
|
21541
|
+
| `@vielzeug/ore/directives` | The advanced/niche directives (`live`, `raw`) and the custom-directive authoring API (`createDirectiveResult`, `createSpreadObject`) |
|
|
21542
|
+
| `@vielzeug/ore/forms` | `useField`, `createFormContext`, `FORM_CONTEXT_KEY` |
|
|
20910
21543
|
| `@vielzeug/ore/observers` | `resizeObserver`, `intersectionObserver`, `mediaObserver`, `mutationObserver` |
|
|
20911
21544
|
| `@vielzeug/ore/testing` | `mount`, `fire`, `user`, `waitFor`, `cleanup`, and helpers |
|
|
20912
21545
|
|
|
@@ -20926,31 +21559,40 @@ define('my-counter', {
|
|
|
20926
21559
|
|
|
20927
21560
|
## API Overview
|
|
20928
21561
|
|
|
20929
|
-
|
|
20930
|
-
|
|
20931
|
-
|
|
20932
|
-
|
|
20933
|
-
|
|
|
20934
|
-
|
|
|
20935
|
-
| `
|
|
20936
|
-
| `
|
|
20937
|
-
| `
|
|
20938
|
-
| `
|
|
20939
|
-
| `
|
|
20940
|
-
| `
|
|
20941
|
-
| `
|
|
20942
|
-
| `
|
|
20943
|
-
| `
|
|
20944
|
-
| `
|
|
20945
|
-
| `
|
|
21562
|
+
All symbols below (except `useField`/`createFormContext`, under `@vielzeug/ore/forms`) are plain functions imported from `@vielzeug/ore`. Lifecycle/context/binding functions (`onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect`, `bind`, `aria`, `provide`, `useEmit`, `useSlots`, `getHost`) resolve the active component through an implicit "current component" context — they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window.
|
|
21563
|
+
|
|
21564
|
+
> `watchEffect` is not named `watch` — `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file.
|
|
21565
|
+
|
|
21566
|
+
| Symbol | Purpose | Execution mode | Common gotcha |
|
|
21567
|
+
| ---------------------- | ----------------------------------------------------- | -------------- | -------------------------------------------------------------------------- |
|
|
21568
|
+
| `define()` | Register a custom element with reactive setup | Sync | Tag must contain a hyphen; call before first use |
|
|
21569
|
+
| `html` | Tagged template literal returning HTMLResult | Sync | Expressions must be signals, functions, or primitives |
|
|
21570
|
+
| `prop.*` | Typed prop helpers (string, bool, number, …) | Sync | Prop values are signals — read `.value` |
|
|
21571
|
+
| `provide()`/`inject()` | Context API for parent-to-descendant sharing | Setup only | Must be called synchronously during `setup()` |
|
|
21572
|
+
| `ref()` | Reactive reference to a DOM element | Sync | Value is null until after first mount |
|
|
21573
|
+
| `createContext()` | Create a typed injection key | Sync | Context is scoped to the component tree |
|
|
21574
|
+
| `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys warn in dev; plain `T[]` treated as one-time static render |
|
|
21575
|
+
| `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
|
|
21576
|
+
| `model(signal)` | Two-way binding for input/select/textarea | Sync | `` uses `Signal`; `select` uses `change` |
|
|
21577
|
+
| `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
|
|
21578
|
+
| `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
|
|
21579
|
+
| `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
|
|
21580
|
+
| `onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
|
|
21581
|
+
| `useField(options)` | Wire signal to form `ElementInternals` | Setup only | Requires `formAssociated: true` on the component definition; `@vielzeug/ore/forms` |
|
|
21582
|
+
| `onFormReset(fn)` | Run work when the ancestor `` resets | Setup only | Fires every reset (not one-shot); only for `formAssociated: true` components |
|
|
21583
|
+
| `aria(target, config)` | Reactively sync ARIA attributes to any element | Setup only | Static values applied once; getter functions tracked as effects; auto-cleanup on disconnect |
|
|
21584
|
+
| `useEmit()` | Typed `emit()` bound to the current host | Setup only | Call once per component; returns `dispatchEvent`'s boolean (`false` if a listener called `preventDefault()`) |
|
|
21585
|
+
| `useSlots()`| Reactive slot presence/element signals | Setup only | Safe to call more than once — the underlying registry is created once |
|
|
21586
|
+
| `getHost()` | The current component's host element | Setup only | Prefer a higher-level helper (`bind`, `aria`, …) when one exists |
|
|
20946
21587
|
|
|
20947
21588
|
## Package Entry Points
|
|
20948
21589
|
|
|
20949
21590
|
| Import | Purpose |
|
|
20950
21591
|
| ---------------------------- | ------------------------------------------------------------------ |
|
|
20951
|
-
| `@vielzeug/ore` | Core authoring/runtime API
|
|
21592
|
+
| `@vielzeug/ore` | Core authoring/runtime API, including the everyday template directives (`each`, `when`, `classMap`, `styleMap`, `model`) |
|
|
20952
21593
|
| `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics |
|
|
20953
|
-
| `@vielzeug/ore/directives` |
|
|
21594
|
+
| `@vielzeug/ore/directives` | The advanced/niche directives (`raw`, `live`) plus the custom-directive authoring API (`createDirectiveResult`, `createSpreadObject`) |
|
|
21595
|
+
| `@vielzeug/ore/forms` | Form-association helpers (`useField`, `createFormContext`) |
|
|
20954
21596
|
| `@vielzeug/ore/observers` | Resize, intersection, mutation, and media observers |
|
|
20955
21597
|
| `@vielzeug/ore/testing` | DOM-oriented test helpers |
|
|
20956
21598
|
|
|
@@ -20962,35 +21604,36 @@ define('my-counter', {
|
|
|
20962
21604
|
define(tag: string, definition: ComponentDefinition): void;
|
|
20963
21605
|
```
|
|
20964
21606
|
|
|
20965
|
-
The `setup()` function receives typed prop signals
|
|
21607
|
+
The `setup()` function receives only typed prop signals:
|
|
20966
21608
|
|
|
20967
21609
|
```ts
|
|
20968
|
-
|
|
20969
|
-
|
|
20970
|
-
|
|
20971
|
-
el: HTMLElement; // The host element
|
|
20972
|
-
emit: EmitFn; // Dispatch typed custom events
|
|
20973
|
-
inject: (key: InjectionKey, fallback?: T) => T | undefined; // Resolve context from nearest ancestor
|
|
20974
|
-
onCleanup: (fn: CleanupFn) => void; // Register teardown; called on disconnect
|
|
20975
|
-
onElement: (ref, cb) => void; // Run callback when a ref resolves to an element
|
|
20976
|
-
onEvent: (target, event, listener, options?) => void; // Scoped event listener; auto-removed on disconnect
|
|
20977
|
-
onMounted: (fn: OnMountedCallback) => void; // DOM-ready callback
|
|
20978
|
-
provide: (key: InjectionKey, value: T) => void; // Register context on the host element
|
|
20979
|
-
slots: ComponentSlots; // Reactive slot signals
|
|
20980
|
-
watch: (fn: EffectCallback) => () => void; // Scoped reactive effect; auto-cleaned on disconnect
|
|
20981
|
-
};
|
|
21610
|
+
setup(props) {
|
|
21611
|
+
return html`${props.label}`;
|
|
21612
|
+
}
|
|
20982
21613
|
```
|
|
20983
21614
|
|
|
20984
|
-
|
|
20985
|
-
|
|
20986
|
-
`setup()` returns an `HTMLResult` directly (not a function):
|
|
21615
|
+
Everything else — lifecycle hooks, host bindings, context, slots, emit — is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):
|
|
20987
21616
|
|
|
20988
21617
|
```ts
|
|
20989
|
-
|
|
20990
|
-
|
|
20991
|
-
|
|
21618
|
+
import { define, html, onMounted, useEmit, useSlots } from '@vielzeug/ore';
|
|
21619
|
+
|
|
21620
|
+
define('my-card', {
|
|
21621
|
+
setup(_props) {
|
|
21622
|
+
const emit = useEmit();
|
|
21623
|
+
const slots = useSlots();
|
|
21624
|
+
|
|
21625
|
+
onMounted(() => console.log('mounted'));
|
|
21626
|
+
|
|
21627
|
+
// emit() returns dispatchEvent's boolean — false if a listener called preventDefault()
|
|
21628
|
+
const notCancelled = emit('close');
|
|
21629
|
+
|
|
21630
|
+
return html`${when(slots.has('header'), () => html``)}`;
|
|
21631
|
+
},
|
|
21632
|
+
});
|
|
20992
21633
|
```
|
|
20993
21634
|
|
|
21635
|
+
`useEmit()` and `useSlots()` are factory hooks — call them once per component to get a typed `emit`/`slots` bound to the current host. `useSlots()` is safe to call more than once (the underlying slot registry is created once per instance and reused).
|
|
21636
|
+
|
|
20994
21637
|
### ComponentDefinition
|
|
20995
21638
|
|
|
20996
21639
|
```ts
|
|
@@ -20999,26 +21642,12 @@ type ComponentDefinition = {
|
|
|
20999
21642
|
loading?: () => HTMLResult; // Template shown while async setup is pending
|
|
21000
21643
|
onError?: (error: OreLifecycleError, element: HTMLElement) => HTMLResult | void;
|
|
21001
21644
|
props?: PropsDef;
|
|
21002
|
-
setup: (
|
|
21003
|
-
props: InferProps>,
|
|
21004
|
-
ctx: SetupContextBag,
|
|
21005
|
-
) => HTMLResult | Promise;
|
|
21645
|
+
setup: (props: InferProps>) => HTMLResult | Promise;
|
|
21006
21646
|
shadow?: Partial | false; // false = light DOM (no shadow root)
|
|
21007
21647
|
styles?: (string | CSSStyleSheet | CSSResult)[];
|
|
21008
21648
|
};
|
|
21009
21649
|
```
|
|
21010
21650
|
|
|
21011
|
-
Pass `SlotNames` as a type parameter to `define()` to get typed `ctx.slots` access:
|
|
21012
|
-
|
|
21013
|
-
```ts
|
|
21014
|
-
define, Record, 'header' | 'footer'>('my-card', {
|
|
21015
|
-
setup(_props, { slots }) {
|
|
21016
|
-
const hasHeader = slots.has('header'); // typed ✓
|
|
21017
|
-
return html`...`;
|
|
21018
|
-
},
|
|
21019
|
-
});
|
|
21020
|
-
```
|
|
21021
|
-
|
|
21022
21651
|
#### Async setup
|
|
21023
21652
|
|
|
21024
21653
|
When `setup()` returns a `Promise`, `loading()` is rendered immediately. The real template replaces it once the promise resolves.
|
|
@@ -21037,10 +21666,12 @@ define('user-profile', {
|
|
|
21037
21666
|
|
|
21038
21667
|
## Runtime Helpers
|
|
21039
21668
|
|
|
21040
|
-
`onMounted`, `onCleanup`, `onEvent`, `onElement`, and `
|
|
21669
|
+
`onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watchEffect` are plain functions imported from `@vielzeug/ore`. Call them directly during `setup()`.
|
|
21041
21670
|
|
|
21042
21671
|
```ts
|
|
21043
|
-
|
|
21672
|
+
import { html, onCleanup, onEvent, onMounted } from '@vielzeug/ore';
|
|
21673
|
+
|
|
21674
|
+
setup(props) {
|
|
21044
21675
|
onMounted(() => {
|
|
21045
21676
|
// DOM is ready; return a function for mount-scoped cleanup
|
|
21046
21677
|
return () => { /* cleanup on unmount */ };
|
|
@@ -21054,20 +21685,18 @@ setup(props, { onMounted, onCleanup, onEvent, onElement, watch }) {
|
|
|
21054
21685
|
}
|
|
21055
21686
|
```
|
|
21056
21687
|
|
|
21057
|
-
|
|
21688
|
+
Because these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too — no need to pass hooks in as options:
|
|
21058
21689
|
|
|
21059
21690
|
```ts
|
|
21060
|
-
|
|
21061
|
-
onCleanup: (fn: () => void) => void;
|
|
21062
|
-
};
|
|
21691
|
+
import { onCleanup } from '@vielzeug/ore';
|
|
21063
21692
|
|
|
21064
|
-
function useMyHelper(
|
|
21065
|
-
|
|
21693
|
+
function useMyHelper() {
|
|
21694
|
+
onCleanup(() => { /* teardown */ });
|
|
21066
21695
|
}
|
|
21067
21696
|
|
|
21068
21697
|
// In setup:
|
|
21069
|
-
setup(_props
|
|
21070
|
-
useMyHelper(
|
|
21698
|
+
setup(_props) {
|
|
21699
|
+
useMyHelper();
|
|
21071
21700
|
return html`...`;
|
|
21072
21701
|
}
|
|
21073
21702
|
```
|
|
@@ -21149,7 +21778,7 @@ Event bindings support dot-separated modifiers: `@click.prevent.stop=${handler}`
|
|
|
21149
21778
|
|
|
21150
21779
|
## Host Bindings
|
|
21151
21780
|
|
|
21152
|
-
|
|
21781
|
+
`bind(config, options?)` is a plain function imported from `@vielzeug/ore`:
|
|
21153
21782
|
|
|
21154
21783
|
```ts
|
|
21155
21784
|
bind({
|
|
@@ -21175,9 +21804,9 @@ bind(
|
|
|
21175
21804
|
|
|
21176
21805
|
Event listener options (`once`, `capture`, `passive`) are also accepted in the second argument. Cleanup is auto-registered with the component scope when called during setup.
|
|
21177
21806
|
|
|
21178
|
-
###
|
|
21807
|
+
### aria()
|
|
21179
21808
|
|
|
21180
|
-
For reactive ARIA attribute syncing, use `
|
|
21809
|
+
For reactive ARIA attribute syncing, use `aria(target, config)`. Shorthand keys are normalised to `aria-*` automatically (`expanded` → `aria-expanded`; `role` is passed verbatim):
|
|
21181
21810
|
|
|
21182
21811
|
```ts
|
|
21183
21812
|
// Inside setup — cleanup auto-registered
|
|
@@ -21204,22 +21833,24 @@ Slot signals update reactively when assigned content changes, including when slo
|
|
|
21204
21833
|
## Context API
|
|
21205
21834
|
|
|
21206
21835
|
- `createContext(description?)` — Create a typed injection key
|
|
21207
|
-
- `
|
|
21836
|
+
- `provide(key, value)` — Provide a value to descendants
|
|
21208
21837
|
- `inject(key)` — Resolve from nearest ancestor; returns `undefined` if not found
|
|
21209
21838
|
- `inject(key, fallback)` — Resolve with a fallback value
|
|
21210
21839
|
- `injectStrict(key)` — Resolve or throw if absent
|
|
21211
21840
|
|
|
21212
|
-
`
|
|
21841
|
+
`provide()` and `inject()` must be called synchronously during `setup()`. Calling them outside a setup context throws `'Lifecycle hooks must be called synchronously during component setup'`. Context resolution walks the ancestor chain including shadow DOM boundaries. `inject()` resolves and caches its result once per consumer — provide a `Readable` (signal/computed) rather than a raw value if descendants need to observe later changes; re-calling `provide()` with a new raw value afterward is not seen by consumers that already resolved it (a dev-mode warning fires when a key is provided twice on the same element).
|
|
21213
21842
|
|
|
21214
21843
|
## Utilities
|
|
21215
21844
|
|
|
21216
21845
|
- `ref()` — Create a `Signal` element reference. Set to the element via `ref=` in templates.
|
|
21217
21846
|
- `createId(prefix = 'id')` — Generate a unique incremental string ID (e.g. `'id-1'`, `'id-2'`). Each call returns a new ID — it does not deduplicate by prefix.
|
|
21218
21847
|
- `createStableId(prefix = 'id')` — Generate a unique ID that also embeds a short random tag shared across all IDs generated in the session (e.g. `'field-a3k21'`), reducing collision risk when multiple app instances run on the same page. Like `createId()`, every call returns a new ID.
|
|
21219
|
-
- `
|
|
21848
|
+
- `resetStableIdCounter()` — Reset the `createStableId()` counter to 0. Call in test `beforeEach` for deterministic IDs. Scoped to `createStableId()` only — `createId()` has no public reset (it's for uniqueness, not cross-test determinism).
|
|
21220
21849
|
|
|
21221
21850
|
## Form-Associated API
|
|
21222
21851
|
|
|
21852
|
+
Import from `@vielzeug/ore/forms`.
|
|
21853
|
+
|
|
21223
21854
|
### `useField(options)`
|
|
21224
21855
|
|
|
21225
21856
|
Wire a form-associated element to `ElementInternals`. Requires `formAssociated: true` on the component definition. The `disabled` state tracking via `internals.states` (CustomStateSet) is skipped with a dev warning if the API is unavailable in the current environment.
|
|
@@ -21234,7 +21865,12 @@ type FormFieldOptions = {
|
|
|
21234
21865
|
* @default false
|
|
21235
21866
|
*/
|
|
21236
21867
|
emptyStringForNull?: boolean;
|
|
21868
|
+
/** Called when the ancestor resets (see onFormReset) — restore local field state here. */
|
|
21869
|
+
onReset?: () => void;
|
|
21237
21870
|
toFormValue?: (value: T) => File | FormData | string | null;
|
|
21871
|
+
/** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */
|
|
21872
|
+
validationMessage?: Readable;
|
|
21873
|
+
validity?: Readable;
|
|
21238
21874
|
value: Signal | Readable;
|
|
21239
21875
|
};
|
|
21240
21876
|
|
|
@@ -21247,11 +21883,23 @@ type FormFieldHandle = {
|
|
|
21247
21883
|
};
|
|
21248
21884
|
```
|
|
21249
21885
|
|
|
21886
|
+
Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation (`checkValidity()`/`reportValidity()`, and ``'s submit blocking):
|
|
21887
|
+
|
|
21888
|
+
```ts
|
|
21889
|
+
const isBlank = (v: string) => v.trim() === '';
|
|
21890
|
+
|
|
21891
|
+
useField({
|
|
21892
|
+
validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')),
|
|
21893
|
+
validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)),
|
|
21894
|
+
value,
|
|
21895
|
+
});
|
|
21896
|
+
```
|
|
21897
|
+
|
|
21250
21898
|
### Form Context
|
|
21251
21899
|
|
|
21252
21900
|
Coordinate form state across child field components:
|
|
21253
21901
|
|
|
21254
|
-
- `createFormContext(options?)` — Create a `FormController`; call `
|
|
21902
|
+
- `createFormContext(options?)` — Create a `FormController`; call `provide(FORM_CONTEXT_KEY, ctrl)` to make it available to descendants
|
|
21255
21903
|
- `FORM_CONTEXT_KEY` — the `InjectionKey` used to provide/inject the form context
|
|
21256
21904
|
|
|
21257
21905
|
```ts
|
|
@@ -21284,7 +21932,9 @@ Import from `@vielzeug/ore/testing`.
|
|
|
21284
21932
|
| ------------------------ | ------------------------------------------------------------------------------------------ |
|
|
21285
21933
|
| `mount(setup, options?)` | Mount a component and return a test fixture |
|
|
21286
21934
|
| `cleanup()` | Remove all mounted elements and reset test state |
|
|
21287
|
-
| `install(afterEach)` | Register auto-cleanup; pass `afterEach` from your test framework
|
|
21935
|
+
| `install(afterEach)` | Register auto-cleanup and the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below); pass `afterEach` from your test framework |
|
|
21936
|
+
| `installFormInternalsPolyfill()` | Called automatically by `install()`. Call directly only if you need the polyfill without auto-cleanup |
|
|
21937
|
+
| `walkFlatTree(root, visit)` | Walks the flat tree (expanding `` via `assignedElements()`) — for finding slotted content across a shadow boundary that `querySelectorAll()` can't cross |
|
|
21288
21938
|
| `flush(options?)` | Drain reactive updates and animation frames |
|
|
21289
21939
|
| `FLUSH_DEEP` | Pre-built options for deep async chains (`maxTurns: 12`) |
|
|
21290
21940
|
| `mock(tag, template?)` | Register a no-op stub custom element |
|
|
@@ -21297,6 +21947,8 @@ Import from `@vielzeug/ore/testing`.
|
|
|
21297
21947
|
|
|
21298
21948
|
> **Test isolation:** `cleanup()` resets mounted elements, `live()` signal tracking, and the raw HTML sanitizer. Call it in `afterEach` to prevent state leaking between tests.
|
|
21299
21949
|
|
|
21950
|
+
> **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API — `install()` polyfills `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`states`, mixes `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already exists, so it's safe to call `install()` even in a suite with no form-associated components — and safe for a downstream package (e.g. a component library built on `ore`) to rely on instead of hand-rolling its own copy.
|
|
21951
|
+
|
|
21300
21952
|
#### `Fixture` interface
|
|
21301
21953
|
|
|
21302
21954
|
```ts
|
|
@@ -21321,11 +21973,11 @@ interface Fixture {
|
|
|
21321
21973
|
|
|
21322
21974
|
#### `renderHook`
|
|
21323
21975
|
|
|
21324
|
-
Useful for testing composable lifecycle hooks (`onMounted`, `
|
|
21976
|
+
Useful for testing composable lifecycle hooks (`onMounted`, `watchEffect`, `inject`, etc.) without a template. `onMounted`/`onCleanup`/`watchEffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current-component context:
|
|
21325
21977
|
|
|
21326
21978
|
```ts
|
|
21327
21979
|
// Without props
|
|
21328
|
-
const { result, flush, dispose } = await renderHook((
|
|
21980
|
+
const { result, flush, dispose } = await renderHook(() => {
|
|
21329
21981
|
const count = signal(0);
|
|
21330
21982
|
onMounted(() => {
|
|
21331
21983
|
count.value = 1;
|
|
@@ -21374,48 +22026,32 @@ type InferProps = {
|
|
|
21374
22026
|
readonly [K in keyof D]-?: Readable>;
|
|
21375
22027
|
};
|
|
21376
22028
|
|
|
21377
|
-
|
|
21378
|
-
|
|
21379
|
-
|
|
21380
|
-
|
|
21381
|
-
|
|
21382
|
-
|
|
21383
|
-
|
|
21384
|
-
|
|
21385
|
-
|
|
21386
|
-
|
|
21387
|
-
|
|
21388
|
-
|
|
21389
|
-
|
|
21390
|
-
|
|
21391
|
-
|
|
21392
|
-
|
|
21393
|
-
|
|
21394
|
-
|
|
21395
|
-
|
|
21396
|
-
): void;
|
|
21397
|
-
(
|
|
21398
|
-
target: EventTarget | null | undefined,
|
|
21399
|
-
event: string,
|
|
21400
|
-
listener: EventListener,
|
|
21401
|
-
options?: AddEventListenerOptions,
|
|
21402
|
-
): void;
|
|
21403
|
-
};
|
|
21404
|
-
onMounted: (fn: OnMountedCallback) => void; // DOM-ready callback; runs after first render
|
|
21405
|
-
provide: (key: InjectionKey, value: T) => void; // Register a context value on the host element
|
|
21406
|
-
slots: ComponentSlots; // Reactive slot signals
|
|
21407
|
-
watch: (fn: EffectCallback) => () => void; // Scoped reactive effect; auto-cleaned on disconnect
|
|
21408
|
-
};
|
|
22029
|
+
// Runtime hooks — all plain functions imported from '@vielzeug/ore', not fields on an object.
|
|
22030
|
+
declare function onMounted(fn: OnMountedCallback): void; // DOM-ready callback; runs after first render
|
|
22031
|
+
declare function onCleanup(fn: CleanupFn): void; // Register teardown; called on disconnect
|
|
22032
|
+
declare function onElement(ref: Readable, cb: (el: T) => CleanupFn | void): () => void;
|
|
22033
|
+
declare function onEvent(
|
|
22034
|
+
target: EventTarget | null | undefined,
|
|
22035
|
+
event: string,
|
|
22036
|
+
listener: EventListener,
|
|
22037
|
+
options?: AddEventListenerOptions,
|
|
22038
|
+
): void;
|
|
22039
|
+
declare function onFormReset(fn: () => void): void; // Runs on every ancestor reset; formAssociated only
|
|
22040
|
+
declare function watchEffect(fn: EffectCallback): () => void; // Scoped reactive effect; auto-cleaned on disconnect
|
|
22041
|
+
declare function bind(config: HostBindConfig, options?: BindOptions): () => void; // Bindings for host or any target element
|
|
22042
|
+
declare function aria(target: Element, config: AriaConfig): () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect
|
|
22043
|
+
declare function provide(key: InjectionKey, value: T): void; // Register a context value on the host element
|
|
22044
|
+
declare function inject(key: InjectionKey, fallback?: T): T | undefined;
|
|
22045
|
+
declare function getHost(): HTMLElement; // The current component's host element
|
|
22046
|
+
declare function useEmit = Record>(): EmitFn;
|
|
22047
|
+
declare function useSlots(): ComponentSlots;
|
|
21409
22048
|
|
|
21410
22049
|
type ComponentDefinition = {
|
|
21411
22050
|
formAssociated?: boolean;
|
|
21412
22051
|
loading?: () => HTMLResult; // Shown while async setup is pending
|
|
21413
22052
|
onError?: (error: OreLifecycleError, el: HTMLElement) => HTMLResult | void;
|
|
21414
22053
|
props?: PropsDef;
|
|
21415
|
-
setup: (
|
|
21416
|
-
props: InferProps>,
|
|
21417
|
-
ctx: SetupContextBag,
|
|
21418
|
-
) => HTMLResult | Promise;
|
|
22054
|
+
setup: (props: InferProps>) => HTMLResult | Promise;
|
|
21419
22055
|
shadow?: Partial | false; // false = light DOM
|
|
21420
22056
|
styles?: (string | CSSStyleSheet | CSSResult)[];
|
|
21421
22057
|
};
|
|
@@ -21478,15 +22114,19 @@ define('status-chip', {
|
|
|
21478
22114
|
});
|
|
21479
22115
|
```
|
|
21480
22116
|
|
|
21481
|
-
|
|
22117
|
+
Everything besides `props` — lifecycle hooks, host bindings, context, slots, emit — is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):
|
|
21482
22118
|
|
|
21483
22119
|
```ts
|
|
22120
|
+
import { define, getHost, html, bind, useEmit, useSlots } from '@vielzeug/ore';
|
|
22121
|
+
|
|
21484
22122
|
define('my-widget', {
|
|
21485
|
-
setup(_props
|
|
21486
|
-
|
|
21487
|
-
|
|
21488
|
-
|
|
21489
|
-
|
|
22123
|
+
setup(_props) {
|
|
22124
|
+
const el = getHost(); // the host HTMLElement
|
|
22125
|
+
const emit = useEmit(); // typed event emitter
|
|
22126
|
+
const slots = useSlots(); // reactive slot observation
|
|
22127
|
+
|
|
22128
|
+
bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)
|
|
22129
|
+
|
|
21490
22130
|
return html``;
|
|
21491
22131
|
},
|
|
21492
22132
|
});
|
|
@@ -21518,16 +22158,17 @@ batch(() => {
|
|
|
21518
22158
|
|
|
21519
22159
|
## onMounted and lifecycle
|
|
21520
22160
|
|
|
21521
|
-
Use `
|
|
22161
|
+
Use `onMounted()` for DOM-dependent initialization that must run after the template is mounted. Use `onElement(ref, cb)` for work tied to a specific DOM node. `onEvent()` attaches a listener that is automatically removed on disconnect.
|
|
21522
22162
|
|
|
21523
22163
|
```ts
|
|
21524
22164
|
import { signal } from '@vielzeug/ripple';
|
|
21525
|
-
import { define, html, ref } from '@vielzeug/ore';
|
|
22165
|
+
import { define, html, onElement, onEvent, onMounted, ref, useSlots } from '@vielzeug/ore';
|
|
21526
22166
|
|
|
21527
22167
|
define('deferred-init', {
|
|
21528
|
-
setup(_props
|
|
22168
|
+
setup(_props) {
|
|
21529
22169
|
const tabIndex = signal(0);
|
|
21530
22170
|
const inputRef = ref();
|
|
22171
|
+
const slots = useSlots();
|
|
21531
22172
|
|
|
21532
22173
|
onMounted(() => {
|
|
21533
22174
|
const items = slots.elements('items').value;
|
|
@@ -21596,12 +22237,11 @@ define('profile-name', {
|
|
|
21596
22237
|
|
|
21597
22238
|
## directives
|
|
21598
22239
|
|
|
21599
|
-
Ore includes `each`, `classMap`, `styleMap`, `when`, `live`,
|
|
22240
|
+
Ore includes `each`, `classMap`, `styleMap`, `when`, and `model` — the everyday template directives most components need, imported directly from `@vielzeug/ore` alongside `define`/`html`. The advanced/niche ones (`live`, `raw`) live behind a separate `@vielzeug/ore/directives` import — see below.
|
|
21600
22241
|
|
|
21601
22242
|
```ts
|
|
21602
22243
|
import { signal } from '@vielzeug/ripple';
|
|
21603
|
-
import { classMap, each, styleMap, when } from '@vielzeug/ore
|
|
21604
|
-
import { define, html } from '@vielzeug/ore';
|
|
22244
|
+
import { classMap, define, each, html, styleMap, when } from '@vielzeug/ore';
|
|
21605
22245
|
|
|
21606
22246
|
define('task-list', {
|
|
21607
22247
|
setup() {
|
|
@@ -21667,14 +22307,14 @@ define('live-search', {
|
|
|
21667
22307
|
|
|
21668
22308
|
## host bindings
|
|
21669
22309
|
|
|
21670
|
-
|
|
22310
|
+
`bind()` wires reactive attrs, classes, styles, and events to the host element.
|
|
21671
22311
|
|
|
21672
22312
|
```ts
|
|
21673
22313
|
import { signal } from '@vielzeug/ripple';
|
|
21674
|
-
import { define, html } from '@vielzeug/ore';
|
|
22314
|
+
import { bind, define, html } from '@vielzeug/ore';
|
|
21675
22315
|
|
|
21676
22316
|
define('x-toggle', {
|
|
21677
|
-
setup(_props
|
|
22317
|
+
setup(_props) {
|
|
21678
22318
|
const open = signal(false);
|
|
21679
22319
|
|
|
21680
22320
|
bind({
|
|
@@ -21692,14 +22332,14 @@ The `bind` config supports `attr`, `class`, `style`, and `on` sections.
|
|
|
21692
22332
|
|
|
21693
22333
|
## ARIA bindings
|
|
21694
22334
|
|
|
21695
|
-
Use `
|
|
22335
|
+
Use `aria(target, config)` to reactively sync ARIA attributes to any element. Shorthand keys are normalised to `aria-*` automatically — `expanded` becomes `aria-expanded`, `role` is set verbatim.
|
|
21696
22336
|
|
|
21697
22337
|
```ts
|
|
21698
22338
|
import { signal } from '@vielzeug/ripple';
|
|
21699
|
-
import { define, html } from '@vielzeug/ore';
|
|
22339
|
+
import { aria, bind, define, html, onMounted } from '@vielzeug/ore';
|
|
21700
22340
|
|
|
21701
22341
|
define('x-disclosure', {
|
|
21702
|
-
setup(_props
|
|
22342
|
+
setup(_props) {
|
|
21703
22343
|
const open = signal(false);
|
|
21704
22344
|
const panelId = 'disclosure-panel';
|
|
21705
22345
|
|
|
@@ -21745,10 +22385,10 @@ Pass `{ target: el }` as a second argument to bind attributes, classes, styles,
|
|
|
21745
22385
|
|
|
21746
22386
|
```ts
|
|
21747
22387
|
import { signal } from '@vielzeug/ripple';
|
|
21748
|
-
import { define, html, ref } from '@vielzeug/ore';
|
|
22388
|
+
import { bind, define, html, onMounted, ref } from '@vielzeug/ore';
|
|
21749
22389
|
|
|
21750
22390
|
define('button-wrapper', {
|
|
21751
|
-
setup(_props
|
|
22391
|
+
setup(_props) {
|
|
21752
22392
|
const visible = signal(false);
|
|
21753
22393
|
const btnRef = ref();
|
|
21754
22394
|
|
|
@@ -21773,11 +22413,13 @@ define('button-wrapper', {
|
|
|
21773
22413
|
## slots and emits
|
|
21774
22414
|
|
|
21775
22415
|
```ts
|
|
21776
|
-
import { when } from '@vielzeug/ore
|
|
21777
|
-
|
|
22416
|
+
import { define, html, useEmit, useSlots, when } from '@vielzeug/ore';
|
|
22417
|
+
|
|
22418
|
+
define('card-with-footer', {
|
|
22419
|
+
setup(_props) {
|
|
22420
|
+
const slots = useSlots();
|
|
22421
|
+
const emit = useEmit();
|
|
21778
22422
|
|
|
21779
|
-
define, Record, 'header' | 'footer'>('card-with-footer', {
|
|
21780
|
-
setup(_props, { slots, emit }) {
|
|
21781
22423
|
return html`
|
|
21782
22424
|
|
|
21783
22425
|
|
|
@@ -21790,18 +22432,18 @@ define, Record, 'header' | 'footer'>('card-with-footer', {
|
|
|
21790
22432
|
});
|
|
21791
22433
|
```
|
|
21792
22434
|
|
|
21793
|
-
Pass `SlotNames`
|
|
22435
|
+
Pass a `SlotNames` type parameter to `useSlots()` to get typed `slots.has()` and `slots.elements()` calls.
|
|
21794
22436
|
|
|
21795
22437
|
## context provide/inject
|
|
21796
22438
|
|
|
21797
22439
|
```ts
|
|
21798
22440
|
import { signal } from '@vielzeug/ripple';
|
|
21799
|
-
import { createContext, define, html, injectStrict } from '@vielzeug/ore';
|
|
22441
|
+
import { createContext, define, html, injectStrict, provide } from '@vielzeug/ore';
|
|
21800
22442
|
|
|
21801
22443
|
const COUNT_CTX = createContext>>('count');
|
|
21802
22444
|
|
|
21803
22445
|
define('count-provider', {
|
|
21804
|
-
setup(_props
|
|
22446
|
+
setup(_props) {
|
|
21805
22447
|
const count = signal(0);
|
|
21806
22448
|
provide(COUNT_CTX, count);
|
|
21807
22449
|
|
|
@@ -21822,7 +22464,8 @@ define('count-consumer', {
|
|
|
21822
22464
|
|
|
21823
22465
|
```ts
|
|
21824
22466
|
import { signal } from '@vielzeug/ripple';
|
|
21825
|
-
import { define, html, prop
|
|
22467
|
+
import { define, html, prop } from '@vielzeug/ore';
|
|
22468
|
+
import { useField } from '@vielzeug/ore/forms';
|
|
21826
22469
|
|
|
21827
22470
|
define('rating-input', {
|
|
21828
22471
|
formAssociated: true,
|
|
@@ -21862,15 +22505,15 @@ define('user-profile', {
|
|
|
21862
22505
|
|
|
21863
22506
|
## platform observers
|
|
21864
22507
|
|
|
21865
|
-
Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `
|
|
22508
|
+
Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `onMounted()`.
|
|
21866
22509
|
|
|
21867
22510
|
```ts
|
|
21868
22511
|
import { effect } from '@vielzeug/ripple';
|
|
21869
|
-
import { define, html, ref } from '@vielzeug/ore';
|
|
22512
|
+
import { define, html, onMounted, ref } from '@vielzeug/ore';
|
|
21870
22513
|
import { intersectionObserver, mediaObserver, resizeObserver } from '@vielzeug/ore/observers';
|
|
21871
22514
|
|
|
21872
22515
|
define('x-observed', {
|
|
21873
|
-
setup(_props
|
|
22516
|
+
setup(_props) {
|
|
21874
22517
|
const boxRef = ref();
|
|
21875
22518
|
|
|
21876
22519
|
onMounted(() => {
|
|
@@ -21988,10 +22631,11 @@ Use `@vielzeug/forge` for typed form state alongside Ore's `useField()` for form
|
|
|
21988
22631
|
```ts
|
|
21989
22632
|
import { createForm } from '@vielzeug/forge';
|
|
21990
22633
|
import { s } from '@vielzeug/spell';
|
|
21991
|
-
import {
|
|
22634
|
+
import { define, html, provide } from '@vielzeug/ore';
|
|
22635
|
+
import { createFormContext, FORM_CONTEXT_KEY } from '@vielzeug/ore/forms';
|
|
21992
22636
|
|
|
21993
22637
|
define('signup-form', {
|
|
21994
|
-
setup(_props
|
|
22638
|
+
setup(_props) {
|
|
21995
22639
|
const formCtx = createFormContext({
|
|
21996
22640
|
onSubmit: async (e) => {
|
|
21997
22641
|
e?.preventDefault();
|
|
@@ -22013,13 +22657,13 @@ define('signup-form', {
|
|
|
22013
22657
|
## Best Practices
|
|
22014
22658
|
|
|
22015
22659
|
- Setup returns `html\`...\`` directly — not a function wrapping the template.
|
|
22016
|
-
- Use `
|
|
22017
|
-
- Use `
|
|
22018
|
-
- Bind host attributes and classes via `
|
|
22660
|
+
- Use `watchEffect()` for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect.
|
|
22661
|
+
- Use `onElement(ref, cb)` instead of `onMounted` when the work is tied to a single DOM node.
|
|
22662
|
+
- Bind host attributes and classes via `bind()` rather than mutating the element directly.
|
|
22019
22663
|
- Provide context at the nearest ancestor — avoid global context singletons.
|
|
22020
|
-
- Call `
|
|
22664
|
+
- Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
|
|
22021
22665
|
- Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
|
|
22022
|
-
-
|
|
22666
|
+
- Extract composable helper functions freely — `onMounted`/`onCleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.
|
|
22023
22667
|
- Test with `@vielzeug/ore/testing` helpers (`mount`, `flush`, `waitFor`) rather than direct DOM manipulation.
|
|
22024
22668
|
|
|
22025
22669
|
### Examples
|
|
@@ -25927,8 +26571,8 @@ When the buffer is full, the **oldest** frame is evicted to make room for the ne
|
|
|
25927
26571
|
|
|
25928
26572
|
**Category:** ui-components
|
|
25929
26573
|
**Keywords:** web-components, accessible, themeable, ui, components, design-system
|
|
25930
|
-
**Key exports:** ore-accordion, ore-accordion-item, ore-alert, ore-async, ore-avatar, ore-avatar-group, ore-badge, ore-box, ore-breadcrumb, ore-breadcrumb-item, ore-button, ore-button-group (+
|
|
25931
|
-
**Related:** ore, orbit, forge
|
|
26574
|
+
**Key exports:** ore-accordion, ore-accordion-item, ore-alert, ore-async, ore-avatar, ore-avatar-group, ore-badge, ore-box, ore-breadcrumb, ore-breadcrumb-item, ore-button, ore-button-group (+55 more)
|
|
26575
|
+
**Related:** ore, orbit, forge, keymap
|
|
25932
26576
|
|
|
25933
26577
|
### Overview
|
|
25934
26578
|
|
|
@@ -26034,17 +26678,17 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
|
|
|
26034
26678
|
|
|
26035
26679
|
### Components
|
|
26036
26680
|
|
|
26037
|
-
**Content:** `ore-avatar`, `ore-avatar-group`, `ore-breadcrumb`, `ore-card`, `ore-carousel`, `ore-carousel-slide`, `ore-icon`, `ore-pagination`, `ore-separator`, `ore-table`, `ore-text`
|
|
26681
|
+
**Content:** `ore-avatar`, `ore-avatar-group`, `ore-breadcrumb`, `ore-card`, `ore-carousel`, `ore-carousel-slide`, `ore-chat-message`, `ore-icon`, `ore-list`, `ore-list-item`, `ore-pagination`, `ore-separator`, `ore-table`, `ore-text`
|
|
26038
26682
|
|
|
26039
26683
|
**Disclosure:** `ore-accordion`, `ore-accordion-item`, `ore-tabs`, `ore-tab-item`, `ore-tab-panel`
|
|
26040
26684
|
|
|
26041
|
-
**Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`
|
|
26685
|
+
**Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`, `ore-typing-indicator`
|
|
26042
26686
|
|
|
26043
|
-
**Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-form`, `ore-input`, `ore-number-input`, `ore-otp-input`, `ore-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker`
|
|
26687
|
+
**Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-form`, `ore-input`, `ore-message-composer`, `ore-number-input`, `ore-otp-input`, `ore-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker`
|
|
26044
26688
|
|
|
26045
26689
|
**Layout:** `ore-box`, `ore-grid`, `ore-grid-item`, `ore-navbar`, `ore-sidebar`
|
|
26046
26690
|
|
|
26047
|
-
**Overlay:** `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
|
|
26691
|
+
**Overlay:** `ore-command-palette`, `ore-command-palette-item`, `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
|
|
26048
26692
|
|
|
26049
26693
|
## Features
|
|
26050
26694
|
|
|
@@ -26072,6 +26716,7 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
|
|
|
26072
26716
|
- [Ore](/ore/) — Web component runtime that powers Refine
|
|
26073
26717
|
- [Orbit](/orbit/) — Floating UI positioning used in Refine's overlays
|
|
26074
26718
|
- [Forge](/forge/) — Form state management for use with Refine inputs
|
|
26719
|
+
- [Keymap](/keymap/) — Keyboard shortcut manager that powers the command palette's global trigger
|
|
26075
26720
|
|
|
26076
26721
|
### API Reference
|
|
26077
26722
|
|
|
@@ -26131,11 +26776,13 @@ import '@vielzeug/refine/button-group';
|
|
|
26131
26776
|
import '@vielzeug/refine/calendar';
|
|
26132
26777
|
import '@vielzeug/refine/card';
|
|
26133
26778
|
import '@vielzeug/refine/carousel';
|
|
26779
|
+
import '@vielzeug/refine/chat-message';
|
|
26134
26780
|
import '@vielzeug/refine/checkbox';
|
|
26135
26781
|
import '@vielzeug/refine/checkbox-group';
|
|
26136
26782
|
import '@vielzeug/refine/chip';
|
|
26137
26783
|
import '@vielzeug/refine/copy-command';
|
|
26138
26784
|
import '@vielzeug/refine/combobox';
|
|
26785
|
+
import '@vielzeug/refine/command-palette';
|
|
26139
26786
|
import '@vielzeug/refine/datagrid';
|
|
26140
26787
|
import '@vielzeug/refine/date-picker';
|
|
26141
26788
|
import '@vielzeug/refine/dialog';
|
|
@@ -26146,7 +26793,10 @@ import '@vielzeug/refine/grid';
|
|
|
26146
26793
|
import '@vielzeug/refine/grid-item';
|
|
26147
26794
|
import '@vielzeug/refine/icon';
|
|
26148
26795
|
import '@vielzeug/refine/input';
|
|
26796
|
+
import '@vielzeug/refine/list';
|
|
26797
|
+
import '@vielzeug/refine/list-item';
|
|
26149
26798
|
import '@vielzeug/refine/menu';
|
|
26799
|
+
import '@vielzeug/refine/message-composer';
|
|
26150
26800
|
import '@vielzeug/refine/navbar';
|
|
26151
26801
|
import '@vielzeug/refine/number-input';
|
|
26152
26802
|
import '@vielzeug/refine/otp-input';
|
|
@@ -26172,6 +26822,7 @@ import '@vielzeug/refine/textarea';
|
|
|
26172
26822
|
import '@vielzeug/refine/time-picker';
|
|
26173
26823
|
import '@vielzeug/refine/toast';
|
|
26174
26824
|
import '@vielzeug/refine/tooltip';
|
|
26825
|
+
import '@vielzeug/refine/typing-indicator';
|
|
26175
26826
|
```
|
|
26176
26827
|
|
|
26177
26828
|
## Shared Exported Symbols
|
|
@@ -26231,20 +26882,24 @@ Per-component API — attributes, events, slots, CSS custom properties:
|
|
|
26231
26882
|
- [Progress](./components/progress.md)
|
|
26232
26883
|
- [Skeleton](./components/skeleton.md)
|
|
26233
26884
|
- [Toast](./components/toast.md)
|
|
26885
|
+
- [Typing Indicator](./components/typing-indicator.md)
|
|
26234
26886
|
|
|
26235
26887
|
### Content
|
|
26236
26888
|
- [Avatar](./components/avatar.md)
|
|
26237
26889
|
- [Breadcrumb](./components/breadcrumb.md)
|
|
26238
26890
|
- [Card](./components/card.md)
|
|
26239
26891
|
- [Carousel](./components/carousel.md)
|
|
26892
|
+
- [Chat Message](./components/chat-message.md)
|
|
26240
26893
|
- [Copy Command](./components/copy-command.md)
|
|
26241
26894
|
- [Icon](./components/icon.md)
|
|
26895
|
+
- [List (+ List Item)](./components/list.md)
|
|
26242
26896
|
- [Pagination](./components/pagination.md)
|
|
26243
26897
|
- [Separator](./components/separator.md)
|
|
26244
26898
|
- [Table](./components/table.md)
|
|
26245
26899
|
- [Text](./components/text.md)
|
|
26246
26900
|
|
|
26247
26901
|
### Overlay
|
|
26902
|
+
- [Command Palette](./components/command-palette.md)
|
|
26248
26903
|
- [Dialog](./components/dialog.md)
|
|
26249
26904
|
- [Drawer](./components/drawer.md)
|
|
26250
26905
|
- [Menu](./components/menu.md)
|
|
@@ -26261,6 +26916,7 @@ Per-component API — attributes, events, slots, CSS custom properties:
|
|
|
26261
26916
|
- [File Input](./components/file-input.md)
|
|
26262
26917
|
- [Form](./components/form.md)
|
|
26263
26918
|
- [Input](./components/input.md)
|
|
26919
|
+
- [Message Composer](./components/message-composer.md)
|
|
26264
26920
|
- [Number Input](./components/number-input.md)
|
|
26265
26921
|
- [OTP Input](./components/otp-input.md)
|
|
26266
26922
|
- [Radio (+ Radio Group)](./components/radio.md)
|
|
@@ -26313,7 +26969,7 @@ createTextField(options: TextFieldOptions): TextFieldHandle
|
|
|
26313
26969
|
|
|
26314
26970
|
Controller for `` and ``. Manages value sync, validation triggers, character counter, and event wiring.
|
|
26315
26971
|
|
|
26316
|
-
Key members: `value` (writable signal), `wire(el, signal?)`, `clear()`, `counter
|
|
26972
|
+
Key members: `value` (writable signal), `wire(el, signal?)`, `clear()`, `reset()`, `counter`, `validity`/`validationMessage` (feed straight into `useField({ validity, validationMessage })`), `attachFormField(formField)` (call once the `useField()` handle exists, to wire up validation triggers and form `reset()` restoration).
|
|
26317
26973
|
|
|
26318
26974
|
### `createChoiceField(options)`
|
|
26319
26975
|
|
|
@@ -26323,7 +26979,9 @@ createChoiceField(options: ChoiceFieldOptions): ChoiceFieldHandle
|
|
|
26323
26979
|
|
|
26324
26980
|
Controller for single and multi-select inputs. Normalises `string | string[]` values.
|
|
26325
26981
|
|
|
26326
|
-
Key members: `selectedValues`, `selectedValue`, `selectValue()`, `toggleValue()`, `removeValue()`, `clear()`, `setValues()`, `formValue
|
|
26982
|
+
Key members: `selectedValues`, `selectedValue`, `selectValue()`, `toggleValue()`, `removeValue()`, `clear()`, `setValues()`, `formValue`, `reset()` (see below), `validity`/`validationMessage` (feed straight into `useField({ validity, validationMessage })`, `{ valueMissing: true }` while `required` and nothing selected), `attachFormField(formField)` (call once the `useField()` handle exists).
|
|
26983
|
+
|
|
26984
|
+
`reset()` has two states, not one: before the user ever changes the selection, it re-syncs from whatever `value` currently holds (same as `createTextField`'s `reset()` — e.g. an async-loaded default arriving after mount is still a legitimate target). Once the user changes the selection for the first time, it freezes to the value captured at field creation and stops tracking `value` — this matters for `ore-radio-group`/`ore-checkbox-group` specifically, which reflect the current selection back onto the host's `value`/`values` attribute for `:host([value])` styling, so past that point `value` itself changes on every selection and can't double as "the default to revert to" the way an uncontrolled ``'s `value` attribute can. `ore-select`/`ore-combobox` don't reflect their selection back onto `value` at all, so for them this distinction is moot in practice — but the primitive can't know that in advance, so it applies the same safe two-state rule uniformly.
|
|
26327
26985
|
|
|
26328
26986
|
### `createCheckable(options)`
|
|
26329
26987
|
|
|
@@ -26333,7 +26991,7 @@ createCheckable(options: CheckableOptions): CheckableHandle
|
|
|
26333
26991
|
|
|
26334
26992
|
Controller for checkboxes and radios. Handles checked/indeterminate state, group delegation, and keyboard activation.
|
|
26335
26993
|
|
|
26336
|
-
Key members: `checked`, `indeterminate`, `toggle()`, `handleClick()`, `handleKeydown()`.
|
|
26994
|
+
Key members: `checked`, `indeterminate`, `toggle()`, `handleClick()`, `handleKeydown()`, `reset()` (same two-state rule as `createChoiceField`'s — tracks `checked`/`indeterminate` live until the first `toggle()`, then freezes, since `checked` is reflected back onto the host attribute too), `validity`/`validationMessage` (`{ valueMissing: true }` while `required` and unchecked, indeterminate counts as unchecked), `attachFormField(formField)`.
|
|
26337
26995
|
|
|
26338
26996
|
### `createOverlayControl(options)`
|
|
26339
26997
|
|
|
@@ -26367,6 +27025,8 @@ createListControl(options: ListNavigationOptions): ListControl
|
|
|
26367
27025
|
|
|
26368
27026
|
Keyboard-navigable list without open state. Supports vertical/horizontal/omni navigation, disabled-item skipping, looping, and typeahead. Navigation methods return the resolved index, or `-1` when no enabled item was found.
|
|
26369
27027
|
|
|
27028
|
+
Pass `direction` (`'ltr' | 'rtl'` or a getter) to mirror the default Left/Right arrow-key bindings for `'horizontal'`/`'both'` orientation, per WAI-ARIA APG (e.g. `direction: () => elementDirection(getHost())`). Has no effect when an explicit `keys` override is supplied.
|
|
27029
|
+
|
|
26370
27030
|
### Other Headless Exports
|
|
26371
27031
|
|
|
26372
27032
|
| Export | Description |
|
|
@@ -26379,7 +27039,6 @@ Keyboard-navigable list without open state. Supports vertical/horizontal/omni na
|
|
|
26379
27039
|
| `createDataGridControl()` | Data grid state (sorting, selection, column management, pagination)|
|
|
26380
27040
|
| `createTypeahead()` | Standalone typeahead buffer with debounced reset |
|
|
26381
27041
|
| `createDropdownPositioner()` | Floating dropdown positioner (wraps Orbit) |
|
|
26382
|
-
| `createDialogFocusControl()` | Dialog-specific focus entry and restoration |
|
|
26383
27042
|
| `createInteraction()` | Unified click/keyboard press handler for interactive elements |
|
|
26384
27043
|
| `dispatchKeyboardAction()` | Low-level keymap dispatcher |
|
|
26385
27044
|
| `createSelectionControl()` | Single/multi/none row-selection controller (used by the data grid) |
|
|
@@ -26681,7 +27340,7 @@ label.dispose();
|
|
|
26681
27340
|
- **`.replace(fn)`** — derive next state from current via a function; same-reference return is a no-op
|
|
26682
27341
|
- **`.reset()`** — restore the initial state baseline
|
|
26683
27342
|
- **`.lens(path)`** — cached writable `Signal` for a property or dot-path; writes produce an immutable copy
|
|
26684
|
-
- **`storeWithHistory(storeOrInit, options?)`** — store with explicit snapshot history; accepts an existing `Store` (not owned) or a plain object; call `.push()` / `.pushNamed(label)` to save checkpoints; `undo()`, `redo()`, `historyAt(i)` returns `HistoryEntry`; reactive `canUndo` / `canRedo`
|
|
27343
|
+
- **`storeWithHistory(storeOrInit, options?)`** — store with explicit snapshot history; accepts an existing `Store` (not owned) or a plain object; call `.push()` / `.pushNamed(label)` to save checkpoints; `undo()`, `redo()`, `historyAt(i)` returns `HistoryEntry`; reactive `canUndo` / `canRedo`; import via `@vielzeug/ripple/history`
|
|
26685
27344
|
- **`getDevToolsHook()`** — returns the currently installed DevTools hook, or `null`; install via `@vielzeug/ripple/devtools`
|
|
26686
27345
|
- **Glitch-free propagation** — computed signals propagate in dependency order; effects always observe a consistent snapshot
|
|
26687
27346
|
- **Infinite loop detection** — built-in guard against effect re-entry cycles (100 iterations default)
|
|
@@ -26725,7 +27384,7 @@ label.dispose();
|
|
|
26725
27384
|
| `scope()` | Isolated cleanup context | Sync | Must call `scope.run()` to activate; `dispose()` is LIFO |
|
|
26726
27385
|
| `debugEffect()` | Effect that logs changed sources before re-run | Sync | Sub-path only: `@vielzeug/ripple/devtools`; tree-shaken from production |
|
|
26727
27386
|
| `store()` | Create object-like state container | Sync | Store is a branded signal; use `.patch()`, `.replace()`, `.reset()` |
|
|
26728
|
-
| `storeWithHistory()` | Store with snapshot-based undo/redo history | Sync |
|
|
27387
|
+
| `storeWithHistory()` | Store with snapshot-based undo/redo history | Sync | Sub-path only: `@vielzeug/ripple/history`; call `.push()` / `.pushNamed()` explicitly to record a checkpoint; `maxHistory` caps the buffer |
|
|
26729
27388
|
| `installDevTools()` | Install DevTools observation hook | Sync | Sub-path only: `@vielzeug/ripple/devtools`; pass `null` to uninstall |
|
|
26730
27389
|
| `getDevToolsHook()` | Return current DevTools hook | Sync | Returns `null` if none installed |
|
|
26731
27390
|
| `isSignal()` | Type guard for any signal/computed/store | Sync | Uses an internal symbol marker, not duck-typing |
|
|
@@ -26738,6 +27397,7 @@ label.dispose();
|
|
|
26738
27397
|
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
26739
27398
|
| `@vielzeug/ripple` | All core exports and types |
|
|
26740
27399
|
| `@vielzeug/ripple/devtools` | `installDevTools`, `debugEffect`, and hook types (`RippleDevToolsHook`, `WriteEvent`, `NamedEvent`, `DisposeEvent`, `MutateEvent`) — dev-only, tree-shaken from prod |
|
|
27400
|
+
| `@vielzeug/ripple/history` | `storeWithHistory` and its types (`StoreWithHistory`, `HistoryEntry`) — tree-shaken unless imported |
|
|
26741
27401
|
| `@vielzeug/ripple/ssr` | SSR tracking isolation helpers (`setTrackingProvider`, `createAsyncProvider`, `withProvider`, `runWithProvider`). Node.js only — do not import in browser builds. |
|
|
26742
27402
|
|
|
26743
27403
|
## Signal Primitives
|
|
@@ -27249,6 +27909,8 @@ Creates a reactive store for the given object state. Stores accept `effect()`, `
|
|
|
27249
27909
|
|
|
27250
27910
|
### `storeWithHistory`
|
|
27251
27911
|
|
|
27912
|
+
`storeWithHistory` is exported from `@vielzeug/ripple/history`, not the main entry point. This keeps it tree-shaken from bundles that never use it — for async commands or history over anything other than a `Store`, use `@vielzeug/ledger` instead.
|
|
27913
|
+
|
|
27252
27914
|
```ts
|
|
27253
27915
|
function storeWithHistory(
|
|
27254
27916
|
storeOrInitial: Store | T,
|
|
@@ -27263,6 +27925,8 @@ The initial state is saved as the first snapshot automatically. Snapshots are de
|
|
|
27263
27925
|
**Ownership:** when called with an initial value (`T`), the adapter creates and owns the underlying store — `dispose()` also disposes it. When called with an existing `Store`, the adapter does **not** own it — `dispose()` leaves the store alive.
|
|
27264
27926
|
|
|
27265
27927
|
```ts
|
|
27928
|
+
import { storeWithHistory } from '@vielzeug/ripple/history';
|
|
27929
|
+
|
|
27266
27930
|
const editor = storeWithHistory({ text: '' }, { maxHistory: 100 });
|
|
27267
27931
|
|
|
27268
27932
|
editor.patch({ text: 'hello' }); // direct — StoreWithHistory extends Store
|
|
@@ -27891,6 +28555,20 @@ batch(() => {
|
|
|
27891
28555
|
|
|
27892
28556
|
Nested `batch()` calls merge into the outermost — only one flush occurs.
|
|
27893
28557
|
|
|
28558
|
+
## Error Handling
|
|
28559
|
+
|
|
28560
|
+
Ripple has no single error-handling mode — each primitive picks the surface that fits how it's normally consumed. This is one deliberate spectrum, not three unrelated designs:
|
|
28561
|
+
|
|
28562
|
+
| Primitive | Failure surface | Why |
|
|
28563
|
+
| ------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
|
|
28564
|
+
| `signal()` / `computed()` | Throws synchronously | Sync code — the caller is already in a position to catch |
|
|
28565
|
+
| `effect()` | Throws synchronously (rethrown from the run) | Same as above — no async gap between cause and observation |
|
|
28566
|
+
| `watch()` | Throws synchronously on an invalid callback return | Programmer-error guard, not a runtime failure mode |
|
|
28567
|
+
| `effectAsync()` | Routes to `onError` (default: rethrown via `queueMicrotask`) | There's no synchronous caller left to catch by the time the factory rejects — needs an explicit escape hatch |
|
|
28568
|
+
| `resource()` | Never throws — lands in `ResourceState.status === 'error'` | Failures are render state (show an error UI), not exceptions to unwind past |
|
|
28569
|
+
|
|
28570
|
+
Rule of thumb: if you're inside a synchronous callback, ripple throws (`RippleError` subtypes — see [Errors](#errors)). If the failure only exists after an `await`, ripple gives you a place to observe it instead of throwing into a call stack that's already gone — `onError` for `effectAsync()`, `ResourceState.error` for `resource()`.
|
|
28571
|
+
|
|
27894
28572
|
### Usage Guide
|
|
27895
28573
|
|
|
27896
28574
|
## Basic Usage
|
|
@@ -28309,7 +28987,7 @@ effect(() => {
|
|
|
28309
28987
|
`storeWithHistory(initial, options?)` wraps a store with snapshot-based undo/redo. Mutations do **not** automatically push snapshots — call `.push()` (or `.pushNamed(label)`) explicitly after each logical change. History navigation with `undo()` and `redo()` never re-runs logic — it replays snapshots directly.
|
|
28310
28988
|
|
|
28311
28989
|
```ts
|
|
28312
|
-
import { storeWithHistory } from '@vielzeug/ripple';
|
|
28990
|
+
import { storeWithHistory } from '@vielzeug/ripple/history';
|
|
28313
28991
|
|
|
28314
28992
|
const editor = storeWithHistory({ text: '', cursor: 0 }, { maxHistory: 100 });
|
|
28315
28993
|
|
|
@@ -28932,7 +29610,6 @@ effect(() => {
|
|
|
28932
29610
|
- Scope & onCleanup (id: `scope-cleanup`)
|
|
28933
29611
|
- Scope — setup shorthand (id: `scope-setup`)
|
|
28934
29612
|
- Store — patch, lens & computed (id: `store-basics`)
|
|
28935
|
-
- Store History — Undo/Redo (id: `store-history`)
|
|
28936
29613
|
- Store — fine-grained lens reactivity (id: `store-lenses`)
|
|
28937
29614
|
- Store - Todo List (id: `store-todo-list`)
|
|
28938
29615
|
- Watch, Lens & Map (id: `watch-and-subscribe`)
|
|
@@ -31276,7 +31953,7 @@ Scout builds a **trigram inverted index** at construction time. Query time is O(
|
|
|
31276
31953
|
| ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
|
31277
31954
|
| Bundle size | ~3 KB | | ~23 KB |
|
|
31278
31955
|
| Zero dependencies | | `@vielzeug/ripple` peer (reactive layer only) | |
|
|
31279
|
-
| Algorithm | Levenshtein | Trigram +
|
|
31956
|
+
| Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap |
|
|
31280
31957
|
| Query time | O(n·m) | O(candidates) | O(n·m) |
|
|
31281
31958
|
| Stateful index | | | |
|
|
31282
31959
|
| Match highlighting | | | |
|
|
@@ -31388,7 +32065,7 @@ function createIndex(items: T[], options: ScoutIndexOptions): ScoutIndex
|
|
|
31388
32065
|
| --- | --- | --- |
|
|
31389
32066
|
| `items` | `T[]` | Initial corpus to index. |
|
|
31390
32067
|
| `options.fields` | `ReadonlyArray>` | Fields to index. Required; at least one entry. |
|
|
31391
|
-
| `options.threshold` | `number` | Min
|
|
32068
|
+
| `options.threshold` | `number` | Min overlap score for a result (default `0.2`). |
|
|
31392
32069
|
| `options.limit` | `number` | Max results returned by `search()` (default `50`). |
|
|
31393
32070
|
| `options.minQueryLength` | `number` | Min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |
|
|
31394
32071
|
|
|
@@ -31533,7 +32210,7 @@ function createReactiveSearch(
|
|
|
31533
32210
|
| `items` | `T[]` | Initial corpus to index. |
|
|
31534
32211
|
| `options.fields` | `ReadonlyArray>` | Fields to index. Required. |
|
|
31535
32212
|
| `options.debounce` | `number` | Debounce ms (default `200`). |
|
|
31536
|
-
| `options.threshold` | `number` | Min
|
|
32213
|
+
| `options.threshold` | `number` | Min overlap score (default `0.2`). |
|
|
31537
32214
|
| `options.limit` | `number` | Max results (default `50`). |
|
|
31538
32215
|
| `options.minQueryLength` | `number` | Min chars before trigram scoring (default `3`). |
|
|
31539
32216
|
|
|
@@ -31903,12 +32580,18 @@ index.search('日本語'); // matches the first document
|
|
|
31903
32580
|
Pass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality.
|
|
31904
32581
|
|
|
31905
32582
|
```ts
|
|
31906
|
-
// At most 10 results, minimum
|
|
32583
|
+
// At most 10 results, minimum overlap score 0.3
|
|
31907
32584
|
const results = index.search('widget', { limit: 10, threshold: 0.3 });
|
|
31908
32585
|
```
|
|
31909
32586
|
|
|
31910
32587
|
Per-call options override the index-level defaults set in `createIndex`.
|
|
31911
32588
|
|
|
32589
|
+
Scores come from the overlap (Szymkiewicz–Simpson) coefficient — the fraction of the *shorter*
|
|
32590
|
+
trigram set (almost always the query) found in the longer one. This is deliberate for the
|
|
32591
|
+
autocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix
|
|
32592
|
+
of a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how
|
|
32593
|
+
much of the query matched, not diluted by how much longer the target field happens to be.
|
|
32594
|
+
|
|
31912
32595
|
### Controlling short-query behaviour
|
|
31913
32596
|
|
|
31914
32597
|
Queries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`.
|
|
@@ -32561,6 +33244,7 @@ interface VirtualizerState {
|
|
|
32561
33244
|
| `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset |
|
|
32562
33245
|
| `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` |
|
|
32563
33246
|
| `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list |
|
|
33247
|
+
| `isAtEnd` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto-follow (chat "stick to bottom") |
|
|
32564
33248
|
| `invalidate` | `() => void` | Clear all measurements and rebuild from estimates |
|
|
32565
33249
|
| `dispose` | `() => void` | Detach listeners; idempotent |
|
|
32566
33250
|
| `disposed` | `boolean` | `true` after `dispose()` is called |
|
|
@@ -32696,9 +33380,34 @@ ctrl.dispose();
|
|
|
32696
33380
|
| `overscan` | `number \| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |
|
|
32697
33381
|
| `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers |
|
|
32698
33382
|
| `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` |
|
|
33383
|
+
| `stickToBottom` | `boolean \| StickToBottomOptions` | — | Auto-scroll to the end after `setItems()` whenever the list was already at (or near) the end — the chat "stick to bottom on new message" pattern |
|
|
32699
33384
|
|
|
32700
33385
|
Without `getItemKey`, each `setItems()` call drops cached measurements.
|
|
32701
33386
|
|
|
33387
|
+
### `StickToBottomOptions`
|
|
33388
|
+
|
|
33389
|
+
| Option | Type | Default | Description |
|
|
33390
|
+
| ----------- | --------- | ------- | --------------------------------------------------------------------------- |
|
|
33391
|
+
| `enabled` | `boolean` | `true` | Enable/disable at runtime — pass the object form to toggle without removing it |
|
|
33392
|
+
| `threshold` | `number` | `48` | Distance in pixels from the end still considered "at the end" |
|
|
33393
|
+
|
|
33394
|
+
`stickToBottom` fires on **any** `setItems()` call made while the list is at the end — not just when the item count grows. This also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. It never fires while the user has scrolled away from the end, so reading older messages is never interrupted.
|
|
33395
|
+
|
|
33396
|
+
```ts
|
|
33397
|
+
const chat = createDomVirtualList({
|
|
33398
|
+
estimateSize: 48,
|
|
33399
|
+
getItemKey: (_, m) => m.id,
|
|
33400
|
+
listElement: listEl,
|
|
33401
|
+
render: renderMessages,
|
|
33402
|
+
scrollElement: scrollEl,
|
|
33403
|
+
stickToBottom: true, // or { threshold: 80 } for a larger "still at bottom" tolerance
|
|
33404
|
+
});
|
|
33405
|
+
|
|
33406
|
+
chat.setItems(messages); // scrolls to bottom on first load
|
|
33407
|
+
// … later, a new message arrives (or the last one grows while streaming) …
|
|
33408
|
+
chat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom
|
|
33409
|
+
```
|
|
33410
|
+
|
|
32702
33411
|
### `DomVirtualListRenderArgs`
|
|
32703
33412
|
|
|
32704
33413
|
```ts
|
|
@@ -32734,6 +33443,9 @@ Extends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virt
|
|
|
32734
33443
|
| `invalidate` | Clear measurements and rebuild from estimates |
|
|
32735
33444
|
| `scrollToIndex` | Scroll to an item |
|
|
32736
33445
|
| `scrollToOffset` | Scroll to a pixel offset |
|
|
33446
|
+
| `scrollToTop` | Scroll to offset `0` |
|
|
33447
|
+
| `scrollToBottom` | Scroll to the end of the list |
|
|
33448
|
+
| `isAtEnd` | `true` when within `threshold` px of the end |
|
|
32737
33449
|
| `dispose` | Teardown; idempotent |
|
|
32738
33450
|
| `disposed` | `true` after `dispose()` is called (live getter) |
|
|
32739
33451
|
| `[Symbol.dispose]` | Delegates to `dispose()` |
|
|
@@ -33613,6 +34325,76 @@ virt.scrollToTop();
|
|
|
33613
34325
|
virt.scrollToBottom({ behavior: 'smooth' });
|
|
33614
34326
|
```
|
|
33615
34327
|
|
|
34328
|
+
### Chat "stick to bottom on new message"
|
|
34329
|
+
|
|
34330
|
+
`createDomVirtualList`'s `stickToBottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read.
|
|
34331
|
+
|
|
34332
|
+
```ts
|
|
34333
|
+
import { createDomVirtualList } from '@vielzeug/scroll';
|
|
34334
|
+
|
|
34335
|
+
const chat = createDomVirtualList({
|
|
34336
|
+
estimateSize: 48,
|
|
34337
|
+
getItemKey: (_, m) => m.id,
|
|
34338
|
+
listElement: listEl,
|
|
34339
|
+
render: renderMessages,
|
|
34340
|
+
scrollElement: scrollEl,
|
|
34341
|
+
stickToBottom: true, // or { threshold: 80 } to widen the "still at bottom" tolerance
|
|
34342
|
+
});
|
|
34343
|
+
|
|
34344
|
+
chat.setItems(messages);
|
|
34345
|
+
|
|
34346
|
+
// New message arrives — follows only if the user hasn't scrolled up.
|
|
34347
|
+
socket.on('message', (msg) => {
|
|
34348
|
+
messages = [...messages, msg];
|
|
34349
|
+
chat.setItems(messages);
|
|
34350
|
+
});
|
|
34351
|
+
```
|
|
34352
|
+
|
|
34353
|
+
It also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged) — every `setItems()` call re-checks "was the list at the end before this update?", not just count changes. Build `isAtEnd()` from `createVirtualizer` directly for custom cases (e.g. showing a "jump to latest" button only while scrolled away):
|
|
34354
|
+
|
|
34355
|
+
```ts
|
|
34356
|
+
const showJumpButton = !virt.isAtEnd();
|
|
34357
|
+
```
|
|
34358
|
+
|
|
34359
|
+
## Infinite Scroll — Loading More at the End
|
|
34360
|
+
|
|
34361
|
+
Use `isAtEnd(threshold)` to fetch the next page as the user nears the bottom. `isAtEnd()` reports scroll position only — it keeps returning `true` while a fetch is in flight — so guard it with your own `loading` flag to avoid firing the same request twice.
|
|
34362
|
+
|
|
34363
|
+
```ts
|
|
34364
|
+
import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
|
|
34365
|
+
|
|
34366
|
+
let rows = await fetchPage(0);
|
|
34367
|
+
let loading = false;
|
|
34368
|
+
|
|
34369
|
+
let virt: Virtualizer;
|
|
34370
|
+
virt = createVirtualizer(scrollEl, {
|
|
34371
|
+
count: rows.length,
|
|
34372
|
+
estimateSize: 36,
|
|
34373
|
+
onChange: ({ items, totalSize }) => {
|
|
34374
|
+
listEl.style.height = `${totalSize}px`;
|
|
34375
|
+
listEl.innerHTML = '';
|
|
34376
|
+
|
|
34377
|
+
for (const item of items) {
|
|
34378
|
+
const el = document.createElement('div');
|
|
34379
|
+
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
|
|
34380
|
+
el.textContent = rows[item.index]?.label ?? '';
|
|
34381
|
+
listEl.appendChild(el);
|
|
34382
|
+
}
|
|
34383
|
+
|
|
34384
|
+
if (!loading && virt.isAtEnd(200)) {
|
|
34385
|
+
loading = true;
|
|
34386
|
+
fetchPage(rows.length).then((nextRows) => {
|
|
34387
|
+
rows = [...rows, ...nextRows];
|
|
34388
|
+
virt.update({ count: rows.length });
|
|
34389
|
+
loading = false;
|
|
34390
|
+
});
|
|
34391
|
+
}
|
|
34392
|
+
},
|
|
34393
|
+
});
|
|
34394
|
+
```
|
|
34395
|
+
|
|
34396
|
+
`isAtEnd(200)` fires once the viewport is within 200px of the bottom — tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re-triggers `onChange`, which re-checks `isAtEnd()` against the new total on the next scroll.
|
|
34397
|
+
|
|
33616
34398
|
## Shared Measurement Cache
|
|
33617
34399
|
|
|
33618
34400
|
When the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `MeasurementCache` created by `createMeasurementCache()`. Measurements recorded by one virtualizer are immediately available to all others using the same cache.
|
|
@@ -33699,7 +34481,7 @@ Scroll is rendering-layer agnostic. The pattern is always the same: create the v
|
|
|
33699
34481
|
|
|
33700
34482
|
```tsx [React]
|
|
33701
34483
|
import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
|
|
33702
|
-
import { useEffect, useRef } from 'react';
|
|
34484
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
33703
34485
|
|
|
33704
34486
|
interface Row {
|
|
33705
34487
|
id: number;
|
|
@@ -33734,7 +34516,10 @@ function VirtualList({ rows }: { rows: Row[] }) {
|
|
|
33734
34516
|
return () => virt.dispose();
|
|
33735
34517
|
}, []); // attach once
|
|
33736
34518
|
|
|
33737
|
-
useEffect
|
|
34519
|
+
// useLayoutEffect, not useEffect: syncs count before paint. With useEffect,
|
|
34520
|
+
// the DOM (and anything reading `rows`) paints once with the new length before
|
|
34521
|
+
// the virtualizer's internal count catches up, which can render stale/out-of-bounds indices.
|
|
34522
|
+
useLayoutEffect(() => {
|
|
33738
34523
|
virtRef.current?.update({ count: rows.length });
|
|
33739
34524
|
}, [rows.length]);
|
|
33740
34525
|
|
|
@@ -33877,6 +34662,7 @@ class VirtualList extends LitElement {
|
|
|
33877
34662
|
### Pitfalls
|
|
33878
34663
|
|
|
33879
34664
|
- **React:** Putting `rows` in the `useEffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. Only include the scroll element reference. Call `virt.update({ count })` from a separate `useEffect` for data changes.
|
|
34665
|
+
- **React:** Use `useLayoutEffect`, not `useEffect`, for the `count`-sync effect. `useEffect` fires after paint — a new `count` can reach the DOM (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out-of-bounds indices for one frame.
|
|
33880
34666
|
- **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`.
|
|
33881
34667
|
- **Svelte:** In Svelte 5, `$effect` with `bind:this` runs after the DOM is painted. The `bind:this` variable is available when the `$effect` runs — no extra tick needed.
|
|
33882
34668
|
- **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes.
|
|
@@ -33972,7 +34758,7 @@ define('virtual-list', {
|
|
|
33972
34758
|
|
|
33973
34759
|
**Category:** data
|
|
33974
34760
|
**Keywords:** pagination, filtering, sorting, search, data-source, query, remote, local, cursor, infinite-scroll
|
|
33975
|
-
**Key exports:** createLocalSource, createRemoteSource, createCursorSource, createInfiniteSource, deriveSource, mergeSource,
|
|
34761
|
+
**Key exports:** createLocalSource, createRemoteSource, createCursorSource, createInfiniteSource, deriveSource, mergeSource, SourcererDisposedError, SourcererError, SourcererTimeoutError, sourceState, itemRange, prefetchSource (+38 more)
|
|
33976
34762
|
**Related:** courier, ripple, wayfinder
|
|
33977
34763
|
|
|
33978
34764
|
### Overview
|
|
@@ -34072,6 +34858,8 @@ console.log(source.current, source.meta.totalItems);
|
|
|
34072
34858
|
| `createCursorSource()` | Server fetch | Cursor tokens | `patch()`, `ready()`, `queryKey` |
|
|
34073
34859
|
| `createInfiniteSource()` | Server fetch | Append (`loadMore`) | `patch()`, `loadedPages`, `ready()`, `queryKey` |
|
|
34074
34860
|
|
|
34861
|
+
`@vielzeug/sourcerer/devtools`: opt-in `debugSource()` for `console.debug` state-transition logging across any source type, tree-shaken from production.
|
|
34862
|
+
|
|
34075
34863
|
## Documentation
|
|
34076
34864
|
|
|
34077
34865
|
- [Usage Guide](./usage.md)
|
|
@@ -34090,16 +34878,15 @@ console.log(source.current, source.meta.totalItems);
|
|
|
34090
34878
|
|
|
34091
34879
|
| Symbol | Purpose | Execution mode | Common gotcha |
|
|
34092
34880
|
| -------------------------------------- | --------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------- |
|
|
34093
|
-
| `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn`
|
|
34881
|
+
| `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn` JSON-stringifies each item for substring matching |
|
|
34094
34882
|
| `createRemoteSource()` | Async server-backed collection with page navigation | Async | Fetches on creation; set `autoFetch: false` to delay |
|
|
34095
34883
|
| `createCursorSource()` | Async collection navigated by cursor tokens | Async | `next()`/`prev()` are no-ops when the cursor is absent |
|
|
34096
34884
|
| `createInfiniteSource()` | Async append-mode (infinite scroll) collection | Async | `loadMore()` is a no-op once `meta.hasMore` is `false` |
|
|
34097
34885
|
| `deriveSource()` | Create a reactive projection of another source | Sync | Derived source disposes automatically when parent disposes |
|
|
34098
34886
|
| `mergeSource()` | Combine multiple sources into one `MergedSource` | Sync | No `meta` field — returned type is `MergedSource`, not `ReactiveSource` |
|
|
34099
|
-
| `applyQuery()` | Apply a partial query patch to any source with `patch()` — fires one fetch | Async | Ignores `page` on Cursor/InfiniteSource — no page concept there |
|
|
34100
34887
|
| `SourcererError` | Base error class for all sourcerer errors; carries `message`, `cause`, `context`, `attempt` | Class | Extends `Error`; access context via getters, not object spread |
|
|
34101
|
-
| `
|
|
34102
|
-
| `
|
|
34888
|
+
| `SourcererTimeoutError` | Error thrown when `ready()` times out; has `timeoutMs` property | Class | Extends `SourcererError`; also caught by `instanceof SourcererError` |
|
|
34889
|
+
| `SourcererDisposedError` | Error thrown by `ready()` when the source is disposed | Class | Extends `SourcererError`; catch separately from `SourcererTimeoutError` if needed |
|
|
34103
34890
|
| `sourceState()` | Derive a discriminated union (`loading`/`error`/`success`) from any source | Sync | Returns `'loading'` when `isSearchPending` is true too |
|
|
34104
34891
|
| `itemRange()` | Compute 1-based display range from `SourceMeta` | Sync | Returns `{ start: 0, end: 0 }` when `totalItems === 0` |
|
|
34105
34892
|
| `prefetchSource()` | SSR: fetch first page, return serialisable snapshot; source is disposed immediately | Async | **Throws `SourcererError`** if fetch fails |
|
|
@@ -34107,6 +34894,7 @@ console.log(source.current, source.meta.totalItems);
|
|
|
34107
34894
|
| `filterContains()` | Preset predicate: case-insensitive substring match | Sync | Matches against a getter's string value |
|
|
34108
34895
|
| `filterEquals()` | Preset predicate: strict equality match | Sync | Uses `Object.is` semantics |
|
|
34109
34896
|
| `filterRange()` | Preset predicate: inclusive min/max range | Sync | Works with numbers and Dates |
|
|
34897
|
+
| `searchBy()` | Preset search builder: field-based matching for `LocalSourceConfig.searchFn` | Sync | Prefer this over default JSON-stringify search on large collections |
|
|
34110
34898
|
| `sortBy()` | Preset comparator: sort by a getter value | Sync | Supports `'asc'` / `'desc'`; handles strings, numbers, Dates |
|
|
34111
34899
|
| `encodeQuery()` | Serialize source query to URL params | Sync | Filter and sort are JSON-stringified |
|
|
34112
34900
|
| `decodeQuery()` | Deserialize URL params (or `URLSearchParams`) to a source query | Sync | Malformed JSON is silently dropped by default |
|
|
@@ -34114,11 +34902,12 @@ console.log(source.current, source.meta.totalItems);
|
|
|
34114
34902
|
| `SearchOptions` | Options bag for `search()` — only field is `immediate?: boolean` | Type | `search()` always returns `Promise`; debounced unless `{ immediate: true }` |
|
|
34115
34903
|
| `DecodeQueryOptions` | Options for `decodeQuery()` — `defaultLimit` and `strict` | Type | `strict: true` throws on malformed JSON; default silently drops it |
|
|
34116
34904
|
|
|
34117
|
-
## Package Entry
|
|
34905
|
+
## Package Entry Points
|
|
34118
34906
|
|
|
34119
|
-
| Import
|
|
34120
|
-
|
|
|
34121
|
-
| `@vielzeug/sourcerer`
|
|
34907
|
+
| Import | Purpose |
|
|
34908
|
+
| -------------------------------- | ------------------------------------------------------ |
|
|
34909
|
+
| `@vielzeug/sourcerer` | Main exports and types |
|
|
34910
|
+
| `@vielzeug/sourcerer/devtools` | Opt-in `debugSource()` — tree-shaken from production |
|
|
34122
34911
|
|
|
34123
34912
|
## Core Factories
|
|
34124
34913
|
|
|
@@ -34144,7 +34933,7 @@ type LocalSourceConfig = {
|
|
|
34144
34933
|
};
|
|
34145
34934
|
```
|
|
34146
34935
|
|
|
34147
|
-
The default `searchFn` performs a case-insensitive JSON substring match —
|
|
34936
|
+
The default `searchFn` performs a case-insensitive JSON substring match — it stringifies each item with `JSON.stringify` and checks if the query string appears anywhere in the result. For better performance and intent clarity, prefer `searchBy(...)` when searching known fields.
|
|
34148
34937
|
|
|
34149
34938
|
`filterAsync` and `sortAsync` run after their synchronous counterparts. They set `meta.isLoading = true` during computation and accept an `AbortSignal` — a new call aborts any running async computation.
|
|
34150
34939
|
|
|
@@ -34197,7 +34986,7 @@ type RemoteConfig = {
|
|
|
34197
34986
|
```
|
|
34198
34987
|
|
|
34199
34988
|
`queryKey` defaults to a stable JSON serialization with recursively sorted keys.
|
|
34200
|
-
`staleTime` compares the **query key** — navigating to a different page always fetches even within the stale window.
|
|
34989
|
+
`staleTime` compares the **query key** — navigating to a different page always fetches even within the stale window. If an `optimisticUpdate()` is active, `refresh()` bypasses `staleTime` to settle the optimistic state.
|
|
34201
34990
|
|
|
34202
34991
|
**Returns:** `RemoteSource` — async server-backed source with page navigation and optimistic update support.
|
|
34203
34992
|
|
|
@@ -34316,7 +35105,7 @@ All methods return `Promise` unless noted.
|
|
|
34316
35105
|
| `patch(changes)` | Apply one or more query changes atomically — a single recompute for any combination of `limit`, `page`, `search`, `filter`, `sort` |
|
|
34317
35106
|
| `prev()` | Navigate to the previous page (no-op at first page) |
|
|
34318
35107
|
| `query` | Current state as a `SourceQuery` (`limit`/`page`/`search` only — filter/sort aren't part of the query snapshot) — read-only snapshot; stable between changes |
|
|
34319
|
-
| `ready(timeout?)` | Resolve when no async computation is pending and no debounce is scheduled; rejects with `
|
|
35108
|
+
| `ready(timeout?)` | Resolve when no async computation is pending and no debounce is scheduled; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
|
|
34320
35109
|
| `reset()` | Restore initial config and return to page 1 |
|
|
34321
35110
|
| `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately |
|
|
34322
35111
|
| `setData(data)` | Replace the dataset and reset to page 1 |
|
|
@@ -34338,7 +35127,7 @@ All methods return `Promise` except `optimisticUpdate` and `subscribe`.
|
|
|
34338
35127
|
| `patch(changes)` | Apply one or more query changes atomically — a single fetch for any combination of `limit`, `page`, `search`, `filter`, `sort` |
|
|
34339
35128
|
| `prev()` | Previous page (no-op at first page) |
|
|
34340
35129
|
| `query` | Current state as a `RemoteSourceQuery` — read-only snapshot; stable between changes |
|
|
34341
|
-
| `ready(timeout?)` | Resolve when no requests are pending; rejects with `
|
|
35130
|
+
| `ready(timeout?)` | Resolve when no requests are pending; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
|
|
34342
35131
|
| `refresh()` | Re-fetch the current query |
|
|
34343
35132
|
| `reset()` | Restore initial config and refetch |
|
|
34344
35133
|
| `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately |
|
|
@@ -34370,7 +35159,7 @@ optimisticUpdate(
|
|
|
34370
35159
|
| `patch(changes)` | Apply `limit` and/or `search` atomically — a single fetch; resets cursor position |
|
|
34371
35160
|
| `prev()` | Go back using `prevCursor` (no-op if none) |
|
|
34372
35161
|
| `query` | Current state as a `CursorSourceQuery` — read-only snapshot; stable between changes |
|
|
34373
|
-
| `ready(timeout?)` | Resolve when idle; rejects with `
|
|
35162
|
+
| `ready(timeout?)` | Resolve when idle; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
|
|
34374
35163
|
| `refresh()` | Re-fetch current cursor position |
|
|
34375
35164
|
| `reset()` | Clear cursors and fetch from the start |
|
|
34376
35165
|
| `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await. Resets cursor position. |
|
|
@@ -34386,33 +35175,22 @@ optimisticUpdate(
|
|
|
34386
35175
|
| `loadMore()` | Fetch the next page and append to `current` (no-op when `meta.hasMore === false`) |
|
|
34387
35176
|
| `patch(changes)` | Apply `limit` and/or `search` atomically — **clears items immediately** and fetches from page 1 |
|
|
34388
35177
|
| `query` | Current state as an `InfiniteSourceQuery` — read-only snapshot; stable between changes |
|
|
34389
|
-
| `ready(timeout?)` | Resolve when idle; rejects with `
|
|
35178
|
+
| `ready(timeout?)` | Resolve when idle; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
|
|
34390
35179
|
| `reset()` | Clear accumulated items **immediately** and fetch from page 1 |
|
|
34391
35180
|
| `search(query, opts?)` | Always returns `Promise`. Debounced by default — **clears items immediately**; fetch fires after debounce. Pass `{ immediate: true }` to skip the window. |
|
|
34392
35181
|
| `subscribe(listener)` | Subscribe; returns unsubscribe |
|
|
34393
35182
|
|
|
34394
35183
|
## Query Utilities
|
|
34395
35184
|
|
|
34396
|
-
|
|
34397
|
-
|
|
34398
|
-
```ts
|
|
34399
|
-
applyQuery): Promise }>(
|
|
34400
|
-
source: T,
|
|
34401
|
-
changes: Partial,
|
|
34402
|
-
): Promise
|
|
34403
|
-
```
|
|
34404
|
-
|
|
34405
|
-
Applies a partial `SourceQuery` (`limit`/`page`/`search`) patch to any source that exposes a compatible `patch()` — delegates directly to `source.patch(changes)`. Fires a single fetch or recomputation for any combination of changed fields. No-op when `changes` is empty or all values are unchanged, per each source's own `patch()` implementation.
|
|
34406
|
-
|
|
34407
|
-
`CursorSource` and `InfiniteSource` have no page-number concept (keyset/append navigation) — their `patch()` only reads `limit`/`search`, so a `page` field from `decodeQuery()` output is silently ignored on those two source types.
|
|
35185
|
+
Restoring URL-decoded state onto a source is a direct `source.patch(decodeQuery(...))` call — no separate wrapper function. `CursorSource` and `InfiniteSource` have no page-number concept (keyset/append navigation), so their `patch()` type only accepts `limit`/`search`; passing a `page` field from `decodeQuery()`'s output to either is a compile-time error, not a silent no-op.
|
|
34408
35186
|
|
|
34409
35187
|
**Example:**
|
|
34410
35188
|
|
|
34411
35189
|
```ts
|
|
34412
|
-
import {
|
|
35190
|
+
import { decodeQuery } from '@vielzeug/sourcerer';
|
|
34413
35191
|
|
|
34414
35192
|
const q = decodeQuery(new URLSearchParams(location.search));
|
|
34415
|
-
await
|
|
35193
|
+
await source.patch(q);
|
|
34416
35194
|
```
|
|
34417
35195
|
|
|
34418
35196
|
---
|
|
@@ -34431,13 +35209,13 @@ class SourcererError extends Error {
|
|
|
34431
35209
|
}
|
|
34432
35210
|
```
|
|
34433
35211
|
|
|
34434
|
-
Base class for all sourcerer errors. Thrown (and stored as `meta.error`) when a fetch fails. `cause` is the original thrown value. `
|
|
35212
|
+
Base class for all sourcerer errors. Thrown (and stored as `meta.error`) when a fetch fails. `cause` is the original thrown value. `SourcererTimeoutError` and `SourcererDisposedError` both extend this class, so a single `instanceof SourcererError` check covers all sourcerer errors.
|
|
34435
35213
|
|
|
34436
|
-
### `
|
|
35214
|
+
### `SourcererTimeoutError`
|
|
34437
35215
|
|
|
34438
35216
|
```ts
|
|
34439
|
-
class
|
|
34440
|
-
readonly name = '
|
|
35217
|
+
class SourcererTimeoutError extends SourcererError {
|
|
35218
|
+
readonly name = 'SourcererTimeoutError';
|
|
34441
35219
|
readonly timeoutMs: number;
|
|
34442
35220
|
// message: 'Source.ready() timed out after Nms'
|
|
34443
35221
|
}
|
|
@@ -34445,11 +35223,11 @@ class SourceTimeoutError extends SourcererError {
|
|
|
34445
35223
|
|
|
34446
35224
|
Thrown by `ready(timeout)` when the timeout expires before the source becomes idle. Also caught by `instanceof SourcererError`.
|
|
34447
35225
|
|
|
34448
|
-
### `
|
|
35226
|
+
### `SourcererDisposedError`
|
|
34449
35227
|
|
|
34450
35228
|
```ts
|
|
34451
|
-
class
|
|
34452
|
-
readonly name = '
|
|
35229
|
+
class SourcererDisposedError extends SourcererError {
|
|
35230
|
+
readonly name = 'SourcererDisposedError';
|
|
34453
35231
|
// message: 'Source disposed while waiting for ready()'
|
|
34454
35232
|
}
|
|
34455
35233
|
```
|
|
@@ -34629,11 +35407,34 @@ Not generic — `filter`/`sort` on the result are always typed `unknown`. Narrow
|
|
|
34629
35407
|
**Example:**
|
|
34630
35408
|
|
|
34631
35409
|
```ts
|
|
34632
|
-
import {
|
|
35410
|
+
import { decodeQuery } from '@vielzeug/sourcerer';
|
|
34633
35411
|
|
|
34634
35412
|
// Pass URLSearchParams directly — filter/sort come back as `unknown`, narrow before use
|
|
34635
35413
|
const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 });
|
|
34636
|
-
await
|
|
35414
|
+
await source.patch(query);
|
|
35415
|
+
```
|
|
35416
|
+
|
|
35417
|
+
## Devtools (`@vielzeug/sourcerer/devtools`)
|
|
35418
|
+
|
|
35419
|
+
```ts
|
|
35420
|
+
debugSource>(
|
|
35421
|
+
source: { current: readonly unknown[]; meta: TMeta; subscribe(listener: () => void): () => void },
|
|
35422
|
+
options?: { label?: string },
|
|
35423
|
+
): () => void
|
|
35424
|
+
```
|
|
35425
|
+
|
|
35426
|
+
Attaches a `console.debug`-based observer to any source (or `deriveSource`/`mergeSource` result) — logs a line per changed `meta` field (works generically across `SourceMeta`/`CursorMeta`/`InfiniteMeta`) and `current`'s item count. Development-only by default; a no-op when `__SOURCERER_PROD__` is set. Import from the dedicated `/devtools` sub-path so it's tree-shaken from production bundles.
|
|
35427
|
+
|
|
35428
|
+
```ts
|
|
35429
|
+
import { createRemoteSource } from '@vielzeug/sourcerer';
|
|
35430
|
+
import { debugSource } from '@vielzeug/sourcerer/devtools';
|
|
35431
|
+
|
|
35432
|
+
const source = createRemoteSource({ fetch: fetchUsers, limit: 20 });
|
|
35433
|
+
const detach = debugSource(source, { label: 'users' });
|
|
35434
|
+
// [sourcerer:devtools:users] meta.isLoading: false → true
|
|
35435
|
+
// [sourcerer:devtools:users] current: 0 → 20 items
|
|
35436
|
+
|
|
35437
|
+
detach(); // stop logging
|
|
34637
35438
|
```
|
|
34638
35439
|
|
|
34639
35440
|
## Types
|
|
@@ -34737,50 +35538,50 @@ type FetchEvent = Readonly;
|
|
|
34737
35538
|
|
|
34738
35539
|
See [Error Utilities > `SourcererError`](#sourcererror) above.
|
|
34739
35540
|
|
|
34740
|
-
### `
|
|
35541
|
+
### `SourcererTimeoutError`
|
|
34741
35542
|
|
|
34742
35543
|
```ts
|
|
34743
|
-
class
|
|
34744
|
-
readonly name = '
|
|
35544
|
+
class SourcererTimeoutError extends SourcererError {
|
|
35545
|
+
readonly name = 'SourcererTimeoutError';
|
|
34745
35546
|
readonly timeoutMs: number;
|
|
34746
35547
|
// message: 'Source.ready() timed out after Nms'
|
|
34747
35548
|
}
|
|
34748
35549
|
```
|
|
34749
35550
|
|
|
34750
|
-
Thrown by `ready(timeout)` when the source has not become idle within the specified `timeout` milliseconds. Check with `instanceof
|
|
35551
|
+
Thrown by `ready(timeout)` when the source has not become idle within the specified `timeout` milliseconds. Check with `instanceof SourcererTimeoutError` for typed catch blocks:
|
|
34751
35552
|
|
|
34752
35553
|
```ts
|
|
34753
|
-
import {
|
|
35554
|
+
import { SourcererTimeoutError } from '@vielzeug/sourcerer';
|
|
34754
35555
|
|
|
34755
35556
|
try {
|
|
34756
35557
|
await source.ready(5000);
|
|
34757
35558
|
} catch (err) {
|
|
34758
|
-
if (err instanceof
|
|
35559
|
+
if (err instanceof SourcererTimeoutError) {
|
|
34759
35560
|
console.warn('Source did not load in time:', err.message);
|
|
34760
35561
|
}
|
|
34761
35562
|
}
|
|
34762
35563
|
```
|
|
34763
35564
|
|
|
34764
|
-
### `
|
|
35565
|
+
### `SourcererDisposedError`
|
|
34765
35566
|
|
|
34766
35567
|
```ts
|
|
34767
|
-
class
|
|
34768
|
-
readonly name = '
|
|
35568
|
+
class SourcererDisposedError extends SourcererError {
|
|
35569
|
+
readonly name = 'SourcererDisposedError';
|
|
34769
35570
|
// message: 'Source disposed while waiting for ready()'
|
|
34770
35571
|
}
|
|
34771
35572
|
```
|
|
34772
35573
|
|
|
34773
|
-
Thrown by `ready()` when `dispose()` is called on the source while a `ready()` call is still pending. Use `instanceof
|
|
35574
|
+
Thrown by `ready()` when `dispose()` is called on the source while a `ready()` call is still pending. Use `instanceof SourcererDisposedError` to distinguish it from `SourcererTimeoutError`:
|
|
34774
35575
|
|
|
34775
35576
|
```ts
|
|
34776
|
-
import {
|
|
35577
|
+
import { SourcererDisposedError, SourcererTimeoutError } from '@vielzeug/sourcerer';
|
|
34777
35578
|
|
|
34778
35579
|
try {
|
|
34779
35580
|
await source.ready(5000);
|
|
34780
35581
|
} catch (err) {
|
|
34781
|
-
if (err instanceof
|
|
35582
|
+
if (err instanceof SourcererDisposedError) {
|
|
34782
35583
|
// source was torn down — skip cleanup
|
|
34783
|
-
} else if (err instanceof
|
|
35584
|
+
} else if (err instanceof SourcererTimeoutError) {
|
|
34784
35585
|
console.warn('timed out');
|
|
34785
35586
|
}
|
|
34786
35587
|
}
|
|
@@ -34830,13 +35631,23 @@ createLocalSource(data, {
|
|
|
34830
35631
|
debounceMs: 300, // debounce delay for source.search() (default: 300)
|
|
34831
35632
|
filter: (u) => u.active, // initial synchronous filter predicate
|
|
34832
35633
|
sort: (a, b) => a.name.localeCompare(b.name), // initial sorter
|
|
34833
|
-
searchFn: (
|
|
35634
|
+
searchFn: searchBy([(u) => u.name, (u) => u.email]), // override default search
|
|
34834
35635
|
// Async variants — enable Web Worker offloading via @vielzeug/familiar:
|
|
34835
35636
|
filterAsync: async (items, signal) => items.filter(/* expensive filter */),
|
|
34836
35637
|
sortAsync: async (items, signal) => [...items].sort(/* expensive sort */),
|
|
34837
35638
|
});
|
|
34838
35639
|
```
|
|
34839
35640
|
|
|
35641
|
+
For large in-memory datasets, prefer the `searchBy(...)` preset over the default `JSON.stringify` search:
|
|
35642
|
+
|
|
35643
|
+
```ts
|
|
35644
|
+
import { createLocalSource, searchBy } from '@vielzeug/sourcerer';
|
|
35645
|
+
|
|
35646
|
+
const source = createLocalSource(users, {
|
|
35647
|
+
searchFn: searchBy([(u) => u.name, (u) => u.email]),
|
|
35648
|
+
});
|
|
35649
|
+
```
|
|
35650
|
+
|
|
34840
35651
|
`filterAsync` and `sortAsync` run after their synchronous counterparts. They set `meta.isLoading = true` during computation and accept an `AbortSignal` — a new call aborts any running async computation.
|
|
34841
35652
|
|
|
34842
35653
|
### Mutations
|
|
@@ -34853,13 +35664,13 @@ await source.reset(); // restore initial filter/sort, reset to page 1
|
|
|
34853
35664
|
|
|
34854
35665
|
### Restoring from URL state
|
|
34855
35666
|
|
|
34856
|
-
Use `
|
|
35667
|
+
Use `decodeQuery()` + `source.patch()` to restore URL-decoded state in a single atomic recompute.
|
|
34857
35668
|
|
|
34858
35669
|
```ts
|
|
34859
|
-
import {
|
|
35670
|
+
import { decodeQuery } from '@vielzeug/sourcerer';
|
|
34860
35671
|
|
|
34861
35672
|
const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 10 });
|
|
34862
|
-
await
|
|
35673
|
+
await source.patch(query);
|
|
34863
35674
|
```
|
|
34864
35675
|
|
|
34865
35676
|
## Remote Source
|
|
@@ -34907,6 +35718,7 @@ createRemoteSource({
|
|
|
34907
35718
|
```
|
|
34908
35719
|
|
|
34909
35720
|
`staleTime` compares the **query key** — navigating to a different page always fetches even when the previous result is still within the stale window.
|
|
35721
|
+
When an `optimisticUpdate()` is active, `refresh()` always fetches even within `staleTime` so the optimistic state can settle deterministically.
|
|
34910
35722
|
|
|
34911
35723
|
### The `fetch` callback
|
|
34912
35724
|
|
|
@@ -34941,13 +35753,13 @@ await source.refresh(); // re-fetch current query
|
|
|
34941
35753
|
|
|
34942
35754
|
### Restoring from URL state
|
|
34943
35755
|
|
|
34944
|
-
`
|
|
35756
|
+
`source.patch()` is a no-op when `changes` is empty — safe to call on every page load.
|
|
34945
35757
|
|
|
34946
35758
|
```ts
|
|
34947
|
-
import {
|
|
35759
|
+
import { decodeQuery } from '@vielzeug/sourcerer';
|
|
34948
35760
|
|
|
34949
35761
|
const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 });
|
|
34950
|
-
await
|
|
35762
|
+
await source.patch(query);
|
|
34951
35763
|
```
|
|
34952
35764
|
|
|
34953
35765
|
### Optimistic updates
|
|
@@ -35050,13 +35862,13 @@ await source.reset(); // clear all, restart from page 1
|
|
|
35050
35862
|
|
|
35051
35863
|
### Restoring from URL state
|
|
35052
35864
|
|
|
35053
|
-
Use `
|
|
35865
|
+
Use `decodeQuery()` + `source.patch()` to restore `limit` and `search`. `patch()` clears accumulated items and refetches from page 1 if any value changed.
|
|
35054
35866
|
|
|
35055
35867
|
```ts
|
|
35056
|
-
import {
|
|
35868
|
+
import { decodeQuery } from '@vielzeug/sourcerer';
|
|
35057
35869
|
|
|
35058
35870
|
const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 });
|
|
35059
|
-
await
|
|
35871
|
+
await source.patch({ limit: query.limit, search: query.search });
|
|
35060
35872
|
```
|
|
35061
35873
|
|
|
35062
35874
|
## Error Handling
|
|
@@ -35165,7 +35977,7 @@ const params = encodeQuery(source.query);
|
|
|
35165
35977
|
|
|
35166
35978
|
// Restore from URLSearchParams directly
|
|
35167
35979
|
const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 });
|
|
35168
|
-
await
|
|
35980
|
+
await source.patch(query);
|
|
35169
35981
|
```
|
|
35170
35982
|
|
|
35171
35983
|
`decodeQuery` is fault-tolerant by default — malformed `filter`/`sort` JSON is silently dropped. Pass `{ strict: true }` to throw instead.
|
|
@@ -35326,11 +36138,12 @@ effect(() => {
|
|
|
35326
36138
|
- Pass the `AbortSignal` from the `fetch` callback to your HTTP client so superseded requests are cancelled.
|
|
35327
36139
|
- Call `ready()` in server-side rendering or test setup — not in every render cycle.
|
|
35328
36140
|
- Always call the unsubscribe function returned by `subscribe()` when the component is torn down.
|
|
35329
|
-
- For URL sync, use `decodeQuery()` + `
|
|
36141
|
+
- For URL sync, use `decodeQuery()` + `source.patch()` rather than reconstructing source state from params manually.
|
|
35330
36142
|
- Use `staleTime` with `refreshInterval` for stale-while-revalidate patterns on dashboards.
|
|
36143
|
+
- If you use `optimisticUpdate()`, call `refresh()` after mutation confirmation; it bypasses `staleTime` while optimistic state is active.
|
|
35331
36144
|
- Only one `optimisticUpdate()` can be active at a time — always handle the thrown error or check before calling.
|
|
35332
36145
|
- When using `decodeQuery()`, validate the parsed `filter` and `sort` with a type guard before passing to the server — they are returned as-is without runtime validation.
|
|
35333
|
-
- For infinite sources, pass `{ limit: query.limit, search: query.search }` to `
|
|
36146
|
+
- For infinite sources, pass `{ limit: query.limit, search: query.search }` to `source.patch()` for URL state sync — `page` isn't part of `InfiniteSource`'s `patch()` type at all, since items accumulate across pages rather than jumping to one.
|
|
35334
36147
|
|
|
35335
36148
|
### Examples
|
|
35336
36149
|
|
|
@@ -35357,7 +36170,7 @@ effect(() => {
|
|
|
35357
36170
|
- LocalSource patch() with filter/sort (id: `local-source-patch`)
|
|
35358
36171
|
- Presets (filterContains, filterEquals, filterRange, sortBy) (id: `presets`)
|
|
35359
36172
|
- Remote Source (id: `remote-source`)
|
|
35360
|
-
- sourceState &
|
|
36173
|
+
- sourceState & SourcererTimeoutError (id: `source-state`)
|
|
35361
36174
|
|
|
35362
36175
|
|
|
35363
36176
|
---
|
|
@@ -35366,7 +36179,7 @@ effect(() => {
|
|
|
35366
36179
|
|
|
35367
36180
|
**Category:** validation
|
|
35368
36181
|
**Keywords:** schema, validation, parsing, json-schema, locale, typescript, descriptors
|
|
35369
|
-
**Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, setMessages, setLogger
|
|
36182
|
+
**Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, createParseContext, setMessages, setLogger (+4 more)
|
|
35370
36183
|
**Related:** forge, courier, vault
|
|
35371
36184
|
|
|
35372
36185
|
### Overview
|
|
@@ -35501,7 +36314,9 @@ const user = User.parse(payload);
|
|
|
35501
36314
|
| `Schema.parseAsync()` / `safeParseAsync()` | Validate including async `validate()` callbacks | Async | Required when any nested rule uses an async `validate()` callback. |
|
|
35502
36315
|
| `descriptorToJsonSchema()` | Convert a `SchemaDescriptor` to JSON Schema | Sync setup | Uses `toDescriptor()` output, not custom transforms. |
|
|
35503
36316
|
| `schemaToJsonSchema()` | Convert a `Schema` instance directly to JSON Schema | Sync setup | Calls `toDescriptor()` internally; same limitations apply. |
|
|
35504
|
-
| `setMessages()` / `setLogger()` / `resetMessages()` | Override validation messages and warning logger
|
|
36317
|
+
| `setMessages()` / `setLogger()` / `resetMessages()` | Override validation messages and warning logger globally | Sync setup | `setMessages()` replaces the active message set each call. |
|
|
36318
|
+
| `createParseContext()` | Create request-scoped message overrides for a parse call | Sync setup | Overrides apply only to calls that receive the returned context. |
|
|
36319
|
+
| `withMessages()` / `withLogger()` | Run a callback with temporary global message/logger overrides | Sync/async setup | Restores previous global state after the callback settles. |
|
|
35505
36320
|
| `SpellValidationError` | Inspect validation failures | Sync/async failures | `format()` returns nested objects, `flatten()` returns path arrays. |
|
|
35506
36321
|
| `prependIssuePath()` | Prefix a path segment to an array of issues | Sync | Use inside custom parsers that delegate to inner schemas. |
|
|
35507
36322
|
|
|
@@ -35518,7 +36333,7 @@ Use this table to scan every runtime export.
|
|
|
35518
36333
|
| Category | Exports |
|
|
35519
36334
|
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35520
36335
|
| Classes | `Schema`, `PipeSchema`, `SpellError`, `SpellValidationError` |
|
|
35521
|
-
| Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages` |
|
|
36336
|
+
| Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages`, `createParseContext`, `withMessages`, `withLogger` |
|
|
35522
36337
|
| Descriptor helpers | `descriptorToJsonSchema`, `schemaToJsonSchema` |
|
|
35523
36338
|
| Pure validators | `hasMaxLength`, `hasMinLength`, `isArray`, `isBoolean`, `isDate`, `isInteger`, `isMultipleOf`, `isNegative`, `isNonNegative`, `isNullOrUndefined`, `isNumber`, `isPositive`, `isString`, `isInRange` |
|
|
35524
36339
|
| String format validators | `isBase64`, `isBase64url`, `isCuid`, `isCuid2`, `isDuration`, `isEmail`, `isEmoji`, `isHex`, `isHexColor`, `isIp`, `isIsoDate`, `isIsoDateTime`, `isJwt`, `isNanoid`, `isNumeric`, `isSemver`, `isSlug`, `isTime`, `isUlid`, `isUrl`, `isUuid` |
|
|
@@ -35616,7 +36431,7 @@ Builder reference:
|
|
|
35616
36431
|
| `s.or(a, b)` | `UnionSchema` | Alias for `s.union()` with exactly two schemas. |
|
|
35617
36432
|
| `s.and(a, b)` | `IntersectSchema` | Alias for `s.intersect()` with two schemas. |
|
|
35618
36433
|
| `s.intersect(...items)` | `IntersectSchema` | Merges compatible outputs deeply and safely. |
|
|
35619
|
-
| `s.variant(key, map)` | `VariantSchema` | Discriminated object union
|
|
36434
|
+
| `s.variant(key, map)` | `VariantSchema` | Discriminated object union with async-aware branch parsing in `parseAsync()`. |
|
|
35620
36435
|
| `s.lazy(getter)` | `LazySchema` | Recursive schema definitions. |
|
|
35621
36436
|
| `s.instanceof(cls)` | `InstanceOfSchema` | Runtime class instance checks. |
|
|
35622
36437
|
|
|
@@ -36496,7 +37311,7 @@ const Signup = s.object({ confirm: s.string(), password: s.string() }).validate(
|
|
|
36496
37311
|
});
|
|
36497
37312
|
```
|
|
36498
37313
|
|
|
36499
|
-
Async rules work in the same method. Spell awaits them
|
|
37314
|
+
Async rules work in the same method. Spell awaits them in `parseAsync()`, including nested schemas (for example inside `s.variant(...)` branches). Async callbacks passed to `validate()` are still skipped in synchronous `parse()`.
|
|
36500
37315
|
|
|
36501
37316
|
```ts
|
|
36502
37317
|
import { s } from '@vielzeug/spell';
|
|
@@ -36599,7 +37414,7 @@ Descriptors are serializable snapshots of the schema structure. Use `toDescripto
|
|
|
36599
37414
|
|
|
36600
37415
|
## Messages
|
|
36601
37416
|
|
|
36602
|
-
Use `setMessages()` to replace the active validation message catalog. Each call replaces the current overrides — it does not accumulate.
|
|
37417
|
+
Use `setMessages()` to replace the active validation message catalog globally. Each call replaces the current overrides — it does not accumulate.
|
|
36603
37418
|
|
|
36604
37419
|
```ts
|
|
36605
37420
|
import { resetMessages, setMessages } from '@vielzeug/spell';
|
|
@@ -36627,6 +37442,35 @@ setLogger(null); // silence
|
|
|
36627
37442
|
setLogger((msg) => myLogger.warn(msg)); // redirect
|
|
36628
37443
|
```
|
|
36629
37444
|
|
|
37445
|
+
Use `createParseContext()` when you need request-scoped message overrides without mutating global state.
|
|
37446
|
+
|
|
37447
|
+
```ts
|
|
37448
|
+
import { createParseContext, s } from '@vielzeug/spell';
|
|
37449
|
+
|
|
37450
|
+
const User = s.object({ email: s.string().email() });
|
|
37451
|
+
|
|
37452
|
+
User.safeParse(
|
|
37453
|
+
{ email: 'ada@example.com', extra: true },
|
|
37454
|
+
createParseContext({ object: { invalidKeys: () => 'No unknown keys in this endpoint' } }),
|
|
37455
|
+
);
|
|
37456
|
+
```
|
|
37457
|
+
|
|
37458
|
+
Use `withMessages()` / `withLogger()` to apply temporary global overrides inside a bounded sync or async callback.
|
|
37459
|
+
|
|
37460
|
+
```ts
|
|
37461
|
+
import { s, withLogger, withMessages } from '@vielzeug/spell';
|
|
37462
|
+
|
|
37463
|
+
const Email = s.string().email();
|
|
37464
|
+
|
|
37465
|
+
await withMessages({ string: { email: () => 'Scoped email' } }, async () => {
|
|
37466
|
+
Email.safeParse('bad'); // issue message uses "Scoped email"
|
|
37467
|
+
});
|
|
37468
|
+
|
|
37469
|
+
withLogger((msg) => myLogger.warn(msg), () => {
|
|
37470
|
+
s.string().regex(/^a$/).regex(/^b$/);
|
|
37471
|
+
});
|
|
37472
|
+
```
|
|
37473
|
+
|
|
36630
37474
|
To integrate with `@vielzeug/lingua`, call `setMessages()` from your locale change callback:
|
|
36631
37475
|
|
|
36632
37476
|
```ts
|
|
@@ -40270,888 +41114,267 @@ const db = createIndexedDB({
|
|
|
40270
41114
|
## @vielzeug/ward
|
|
40271
41115
|
|
|
40272
41116
|
**Category:** auth
|
|
40273
|
-
**Keywords:** rbac, permissions, roles, access-control, authorization, wildcards, predicates
|
|
40274
|
-
**Key exports:** createWard, allow, deny, ruleFor, predicate, owns, matchesPattern, patternCovers, guardRequest, guardRequestWith, WardPredicateError, WILDCARD (+20 more)
|
|
40275
|
-
**Related:** rune, wayfinder, conduit
|
|
40276
41117
|
|
|
40277
41118
|
### Overview
|
|
40278
41119
|
|
|
40279
|
-
|
|
40280
|
-
|
|
40281
|
-
Spreading authorization checks across route handlers, service methods, and UI components leads to inconsistent enforcement, no central place to audit permissions, and rules that drift as the codebase grows.
|
|
40282
|
-
|
|
40283
|
-
```ts
|
|
40284
|
-
// Before — ad-hoc checks scattered across handlers
|
|
40285
|
-
function deletePost(user: User, post: Post) {
|
|
40286
|
-
if (user.role !== 'admin' && user.id !== post.authorId) {
|
|
40287
|
-
throw new Error('Forbidden');
|
|
40288
|
-
}
|
|
40289
|
-
// no logging, no explain, no wildcard, no composition
|
|
40290
|
-
}
|
|
40291
|
-
|
|
40292
|
-
// After — Ward declarative rules with typed enforcement
|
|
40293
|
-
import { allow, createWard, predicate } from '@vielzeug/ward';
|
|
40294
|
-
|
|
40295
|
-
const ward = createWard([
|
|
40296
|
-
...allow('admin', '*', ['*']),
|
|
40297
|
-
...allow('author', 'post', ['delete', 'edit'], { when: predicate.owns('authorId') }),
|
|
40298
|
-
]);
|
|
40299
|
-
|
|
40300
|
-
const guard = ward.forUser(currentUser);
|
|
40301
|
-
guard.explain('post', 'delete', post); // WardDecision — auditable
|
|
40302
|
-
guard.allowedActions('post', ['delete', 'edit'], post); // ['delete', 'edit'] or []
|
|
40303
|
-
```
|
|
40304
|
-
|
|
40305
|
-
| Feature | Ward | CASL | AccessControl |
|
|
40306
|
-
| --------------------------------- | ------------------------------------------------------ | ------------------------------------------ | --------------------------------------------------------------------- |
|
|
40307
|
-
| Bundle size | | ~11 kB | ~7 kB |
|
|
40308
|
-
| Typed rule contracts | | Partial | Partial |
|
|
40309
|
-
| Deterministic deny precedence | | | |
|
|
40310
|
-
| Rule predicates with request data | | | (manual patterns) |
|
|
40311
|
-
| Wildcard action support | | | |
|
|
40312
|
-
| Principal-bound API | (`forUser`) | Partial | |
|
|
40313
|
-
| Explainable decisions | | Partial | |
|
|
40314
|
-
| Zero dependencies | | | |
|
|
40315
|
-
|
|
40316
|
-
**Use Ward when** you want predictable authorization decisions with typed rules and explicit introspection APIs.
|
|
40317
|
-
|
|
40318
|
-
**Consider larger policy frameworks when** you need ecosystem-specific integrations or policy storage outside application code.
|
|
40319
|
-
|
|
40320
|
-
## Installation
|
|
40321
|
-
|
|
40322
|
-
```sh [pnpm]
|
|
40323
|
-
pnpm add @vielzeug/ward
|
|
40324
|
-
```
|
|
40325
|
-
|
|
40326
|
-
```sh [npm]
|
|
40327
|
-
npm install @vielzeug/ward
|
|
40328
|
-
```
|
|
40329
|
-
|
|
40330
|
-
```sh [yarn]
|
|
40331
|
-
yarn add @vielzeug/ward
|
|
40332
|
-
```
|
|
41120
|
+
`@vielzeug/ward` is a zero-dependency authorization engine for role/resource/action policies.
|
|
40333
41121
|
|
|
40334
41122
|
## Quick Start
|
|
40335
41123
|
|
|
40336
41124
|
```ts
|
|
40337
|
-
import { ANONYMOUS, WILDCARD, allow, createWard, deny,
|
|
41125
|
+
import { ANONYMOUS, WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
|
|
40338
41126
|
|
|
40339
41127
|
const ward = createWard([
|
|
40340
|
-
|
|
40341
|
-
...allow(
|
|
40342
|
-
// Editor can update their own posts
|
|
40343
|
-
...allow('editor', 'posts', ['update'], { when: predicate.owns('authorId') }),
|
|
40344
|
-
// High-priority deny overrides any allow rule for blocked principals
|
|
41128
|
+
...allow([ANONYMOUS, 'viewer'], 'posts', ['read']),
|
|
41129
|
+
...allow('editor', 'posts', ['update'], { when: owns('authorId') }),
|
|
40345
41130
|
...deny('blocked', WILDCARD, [WILDCARD], { priority: 100 }),
|
|
40346
|
-
// Anonymous visitors can read posts
|
|
40347
|
-
...allow(ANONYMOUS, 'posts', ['read']),
|
|
40348
|
-
]);
|
|
40349
|
-
|
|
40350
|
-
const editor = { id: 'u1', roles: ['editor'] };
|
|
40351
|
-
|
|
40352
|
-
// Full decision — narrow on .allowed for type-safe access to .reason / .rule
|
|
40353
|
-
const decision = ward.explain(editor, 'posts', 'update', { authorId: 'u2' });
|
|
40354
|
-
if (!decision.allowed) console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
|
|
40355
|
-
|
|
40356
|
-
// Decision trace — all candidates with index, score, priority, won (no logger fired)
|
|
40357
|
-
const trace = ward.trace(editor, 'posts', 'read');
|
|
40358
|
-
trace.candidates.forEach((c) => console.log(`Rule[${c.index}]`, c.rule.effect, c.score, c.won));
|
|
40359
|
-
|
|
40360
|
-
// Detect policy conflicts at startup
|
|
40361
|
-
const conflicts = ward.detectConflicts();
|
|
40362
|
-
if (conflicts.length > 0) console.warn('Policy conflicts:', conflicts);
|
|
40363
|
-
|
|
40364
|
-
const bound = ward.forUser(editor);
|
|
40365
|
-
|
|
40366
|
-
bound.allowedActions('posts', ['read', 'update', 'delete']);
|
|
40367
|
-
bound.explain('posts', 'update', { authorId: 'u2' });
|
|
40368
|
-
bound.checkAll([
|
|
40369
|
-
{ resource: 'posts', action: 'read' },
|
|
40370
|
-
{ resource: 'posts', action: 'update', data: { authorId: 'u1' } },
|
|
40371
|
-
]);
|
|
40372
|
-
bound.rulesInScope('posts');
|
|
40373
|
-
```
|
|
40374
|
-
|
|
40375
|
-
## Features
|
|
40376
|
-
|
|
40377
|
-
- One rule primitive: `WardRule` passed directly to `createWard(rules)`
|
|
40378
|
-
- **Rule factories**: `allow(role, resource, actions, opts?)` and `deny(...)` — readable, spreadable arrays
|
|
40379
|
-
- **Grouped predicate namespace**: `predicate.owns()`, `predicate.and()`, `predicate.or()`, `predicate.not()`
|
|
40380
|
-
- **Multi-role rules**: `role` accepts a string or an array of strings (OR semantics)
|
|
40381
|
-
- Decision methods: `ward.explain(principal, resource, action, data?)` — full `WardDecision` object
|
|
40382
|
-
- Batch decisions: `ward.checkAll(principal, checks)`
|
|
40383
|
-
- Full decision trace: `ward.trace(principal, resource, action, data?)` — all candidates with `index`, `score`, `priority`, `won`; **does not fire the logger**
|
|
40384
|
-
- Rule introspection: `ward.rulesInScope(principal, resource, data?)`
|
|
40385
|
-
- Action enumeration: `ward.allowedActions(principal, resource, knownActions, data?)`
|
|
40386
|
-
- Policy conflict detection: `ward.detectConflicts()` — lazy, cached, O(n²)
|
|
40387
|
-
- Explicit wildcard support with `WILDCARD`
|
|
40388
|
-
- Anonymous checks via `null` principal plus `ANONYMOUS` role rules
|
|
40389
|
-
- Ownership helper via `owns(attributeKey)` or `predicate.owns(attributeKey)`
|
|
40390
|
-
- Principal-bound API via `ward.forUser(principal)` — principal snapshotted at bind time
|
|
40391
|
-
- Framework-agnostic guards: `guardRequest`, `guardRequestWith`
|
|
40392
|
-
- **Debug logging** via `debugWard()` (`@vielzeug/ward/devtools`) — logs `explain` and `checkAll` decisions with `[ward:decision]` prefixes; tree-shaken from production bundles
|
|
40393
|
-
|
|
40394
|
-
## Documentation
|
|
40395
|
-
|
|
40396
|
-
- [Usage Guide](./usage.md)
|
|
40397
|
-
- [API Reference](./api.md)
|
|
40398
|
-
- [Examples](./examples.md)
|
|
40399
|
-
|
|
40400
|
-
## See Also
|
|
40401
|
-
|
|
40402
|
-
- [Wayfinder](../wayfinder/index.md) for route-level authorization middleware.
|
|
40403
|
-
- [Rune](../rune/index.md) for structured audit logs of permission checks.
|
|
40404
|
-
- [Herald](../herald/index.md) for event-driven permission workflows.
|
|
40405
|
-
|
|
40406
|
-
### API Reference
|
|
40407
|
-
|
|
40408
|
-
## API Overview
|
|
40409
|
-
|
|
40410
|
-
| Symbol | Purpose | Execution | Common gotcha |
|
|
40411
|
-
| ------------------------------------------------------------------------ | ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------ |
|
|
40412
|
-
| `createWard(rules, options?)` | Create an immutable ward instance | Sync | Rules cannot be mutated after creation |
|
|
40413
|
-
| `allow(role, resource, actions, options?)` | Create allow rules — returns `WardRule[]` | Sync | Spread into `createWard([ ...allow(...) ])` — returns an array |
|
|
40414
|
-
| `deny(role, resource, actions, options?)` | Create deny rules — returns `WardRule[]` | Sync | Same spreading pattern as `allow` |
|
|
40415
|
-
| `ruleFor(effect, role, resource, actions, options?)` | Low-level rule factory (effect as first arg) | Sync | Prefer `allow`/`deny` for readability |
|
|
40416
|
-
| `predicate.owns(attributeKey)` | Ownership predicate — `data[key] === principal.id` | Sync | Returns `false` when `data` is absent, not an object, or key not an own property |
|
|
40417
|
-
| `predicate.and(...preds)` | Combine predicates with AND | Sync | Zero arguments → always returns `true` (vacuously) |
|
|
40418
|
-
| `predicate.or(...preds)` | Combine predicates with OR | Sync | Zero arguments → always returns `false` |
|
|
40419
|
-
| `predicate.not(pred)` | Invert a predicate | Sync | — |
|
|
40420
|
-
| `owns(attributeKey)` | Top-level alias for `predicate.owns` | Sync | Prefer `predicate.owns` when using other `predicate.*` helpers |
|
|
40421
|
-
| `matchesPattern(pattern, value)` | Test a pattern against a concrete string | Sync | Works for both resources and actions (namespace wildcards) |
|
|
40422
|
-
| `patternCovers(broad, narrow)` | Test whether one pattern statically covers another | Sync | Used by `detectConflicts`; exported for custom tooling |
|
|
40423
|
-
| `ward.checkAll(principal, checks)` | Evaluate multiple decisions in one call | Sync | Returns `WardDecisionResult[]` — each entry includes originating `resource`+`action` |
|
|
40424
|
-
| `ward.explain(principal, resource, action, data?)` | Full decision object with deny reason | Sync | `rule` only present on `'allow'` and `'explicit-deny'` variants; fires logger |
|
|
40425
|
-
| `ward.trace(principal, resource, action, data?)` | Decision trace with all matching candidates | Sync | **Does not fire the logger** — use `explain` when logger output is needed |
|
|
40426
|
-
| `ward.allowedActions(principal, resource, knownActions, data?)` | List allowed actions; no logger | Sync | Wildcard-action rules require a non-empty `knownActions` |
|
|
40427
|
-
| `ward.rulesInScope(principal, resource, data?)` | Rules in scope for introspection; no logger | Sync | Without `data`, predicate rules appear unfiltered |
|
|
40428
|
-
| `ward.detectConflicts()` | Lazily detect and cache policy conflicts | Sync | O(n²); predicate-gated rules excluded from static analysis |
|
|
40429
|
-
| `ward.forUser(principal)` | Create a principal-bound ward view | Sync | Principal is deep-snapshotted at bind time |
|
|
40430
|
-
| `guardRequest(ward, principal, resource, action, data?)` | Framework-agnostic sync guard — direct principal | Sync | Use `guardRequestWith` when the principal must be extracted from a request object |
|
|
40431
|
-
| `guardRequestWith(ward, req, extractPrincipal, resource, action, data?)` | Framework-agnostic async guard — request + extractor | Async | Extractor may be async (e.g. JWT verification) |
|
|
40432
|
-
|
|
40433
|
-
## Package Entry Points
|
|
40434
|
-
|
|
40435
|
-
| Import | Purpose |
|
|
40436
|
-
| ------------------------- | ---------------------------------------- |
|
|
40437
|
-
| `@vielzeug/ward` | Main exports and types |
|
|
40438
|
-
| `@vielzeug/ward/devtools` | `debugWard` — decision logger (dev only) |
|
|
40439
|
-
|
|
40440
|
-
## Constants
|
|
40441
|
-
|
|
40442
|
-
- `WILDCARD = '*'`
|
|
40443
|
-
- `ANONYMOUS = 'anonymous'`
|
|
40444
|
-
|
|
40445
|
-
`WILDCARD` can be used as role, resource, or action.
|
|
40446
|
-
|
|
40447
|
-
## WardRule Fields
|
|
40448
|
-
|
|
40449
|
-
| Field | Type | Required | Description |
|
|
40450
|
-
| ---------- | ----------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
40451
|
-
| `role` | `string \| readonly string[]` | | One role or an array of roles. A rule matches if the principal holds **any** of the listed roles (OR semantics). Use `WILDCARD` for all authenticated principals, `ANONYMOUS` for unauthenticated requests. |
|
|
40452
|
-
| `resource` | `string` | | Resource identifier. Use `WILDCARD` to match any resource. |
|
|
40453
|
-
| `action` | `string` | | Action identifier. Use `WILDCARD` to match any action. |
|
|
40454
|
-
| `effect` | `'allow' \| 'deny'` | | Whether the rule grants or denies access. |
|
|
40455
|
-
| `priority` | `number` | — | Higher value wins. Optional when authoring a rule (defaults to `0`); always present on rules returned from decisions/trace/conflicts. Must be a finite number. |
|
|
40456
|
-
| `when` | `WardPredicate` | — | Runtime predicate evaluated only for authenticated principals. |
|
|
40457
|
-
|
|
40458
|
-
### Multi-Role Rules
|
|
40459
|
-
|
|
40460
|
-
When `role` is an array, the rule matches if the principal holds **any** of the listed roles. This lets you consolidate rules that share identical permissions across several roles:
|
|
40461
|
-
|
|
40462
|
-
```ts
|
|
40463
|
-
// Instead of three separate allow rules, write one:
|
|
40464
|
-
const ward = createWard([
|
|
40465
|
-
{ role: ['viewer', 'editor', 'admin'], resource: 'posts', action: 'read', effect: 'allow' },
|
|
40466
|
-
{ role: ['editor', 'admin'], resource: 'posts', action: 'update', effect: 'allow' },
|
|
40467
|
-
{ role: 'admin', resource: 'posts', action: 'delete', effect: 'allow' },
|
|
40468
41131
|
]);
|
|
40469
|
-
```
|
|
40470
|
-
|
|
40471
|
-
`ANONYMOUS` works inside multi-role arrays too:
|
|
40472
|
-
|
|
40473
|
-
```ts
|
|
40474
|
-
// Allows both unauthenticated visitors and authenticated viewers to read
|
|
40475
|
-
{ role: [ANONYMOUS, 'viewer'], resource: 'posts', action: 'read', effect: 'allow' }
|
|
40476
|
-
```
|
|
40477
|
-
|
|
40478
|
-
For specificity scoring, a multi-role rule is treated as specific (score 1) unless the array contains `WILDCARD`.
|
|
40479
|
-
|
|
40480
|
-
## Core Functions
|
|
40481
|
-
|
|
40482
|
-
### `createWard()`
|
|
40483
|
-
|
|
40484
|
-
```ts
|
|
40485
|
-
createWard(
|
|
40486
|
-
rules?: readonly WardRule[],
|
|
40487
|
-
options?: WardOptions,
|
|
40488
|
-
): Ward
|
|
40489
|
-
```
|
|
40490
41132
|
|
|
40491
|
-
|
|
40492
|
-
|
|
40493
|
-
**Parameters — `WardOptions`:**
|
|
40494
|
-
|
|
40495
|
-
| Option | Type | Default | Description |
|
|
40496
|
-
| -------------- | -------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
40497
|
-
| `logger` | `(context: WardLoggerContext) => void` | `undefined` | Called after every decision method (`explain`, `checkAll`, `trace`). Not called by `allowedActions` or `rulesInScope`. |
|
|
40498
|
-
| `onConflict` | `(conflict: WardConflict) => void` | `undefined` | Called synchronously for each conflict detected at creation time. |
|
|
40499
|
-
| `strict` | `boolean` | `false` | Throws immediately if any rule conflicts are detected. |
|
|
40500
|
-
| `maxConflicts` | `number` | `Infinity` | Caps the number of conflicts returned by `detectConflicts()`. Set to `0` to disable conflict detection entirely. |
|
|
40501
|
-
|
|
40502
|
-
**Winner selection** when multiple rules match:
|
|
40503
|
-
|
|
40504
|
-
1. Higher `priority` wins.
|
|
40505
|
-
2. On priority tie, higher specificity wins (`exact > ns:* > *`, applied independently to role, resource, and action).
|
|
40506
|
-
3. On specificity tie, `deny` beats `allow`.
|
|
40507
|
-
4. On absolute tie (identical priority, specificity, and effect), the rule declared **first in the array** wins.
|
|
40508
|
-
|
|
40509
|
-
**Returns:** `Ward`
|
|
40510
|
-
|
|
40511
|
-
**Example:**
|
|
40512
|
-
|
|
40513
|
-
```ts
|
|
40514
|
-
import { createWard, owns } from '@vielzeug/ward';
|
|
40515
|
-
|
|
40516
|
-
const ward = createWard([
|
|
40517
|
-
{ role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
|
|
40518
|
-
{ role: 'editor', resource: 'posts', action: 'update', effect: 'allow', when: owns('authorId') },
|
|
40519
|
-
]);
|
|
40520
|
-
```
|
|
40521
|
-
|
|
40522
|
-
## Ward Methods
|
|
40523
|
-
|
|
40524
|
-
### `checkAll()`
|
|
40525
|
-
|
|
40526
|
-
```ts
|
|
40527
|
-
ward.checkAll(
|
|
40528
|
-
principal: Principal,
|
|
40529
|
-
checks: readonly WardCheck[],
|
|
40530
|
-
): WardDecisionResult[]
|
|
40531
|
-
```
|
|
40532
|
-
|
|
40533
|
-
Evaluates each check independently and returns one `WardDecisionResult` per entry in the same order. Each result includes the originating `resource` and `action` fields, so callers do not need to zip the input array by index. Returns `[]` for an empty array without validating the principal.
|
|
40534
|
-
|
|
40535
|
-
**Returns:** `WardDecisionResult[]`
|
|
40536
|
-
|
|
40537
|
-
**Example:**
|
|
40538
|
-
|
|
40539
|
-
```ts
|
|
40540
|
-
const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
|
|
40541
|
-
{ resource: 'posts', action: 'read' },
|
|
40542
|
-
{ resource: 'posts', action: 'update', data: { authorId: 'u1' } },
|
|
40543
|
-
]);
|
|
40544
|
-
```
|
|
40545
|
-
|
|
40546
|
-
---
|
|
40547
|
-
|
|
40548
|
-
### `allowedActions()`
|
|
40549
|
-
|
|
40550
|
-
```ts
|
|
40551
|
-
ward.allowedActions(
|
|
40552
|
-
principal: Principal,
|
|
40553
|
-
resource: string,
|
|
40554
|
-
knownActions: readonly TAction[],
|
|
40555
|
-
data?: TData,
|
|
40556
|
-
): TAction[]
|
|
40557
|
-
```
|
|
40558
|
-
|
|
40559
|
-
Returns the subset of `knownActions` that the principal is currently allowed to perform on `resource`. Evaluates wildcard-action rules against each entry in `knownActions`. Deduplicates the input list.
|
|
40560
|
-
|
|
40561
|
-
`allowedActions` does **not** invoke the logger. Use `checkAll` if you need an auditable batch decision.
|
|
40562
|
-
|
|
40563
|
-
**Returns:** `TAction[]`
|
|
40564
|
-
|
|
40565
|
-
**Example:**
|
|
40566
|
-
|
|
40567
|
-
```ts
|
|
40568
|
-
// Resolves wildcard-action rules against the provided list
|
|
40569
|
-
const actions = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete']);
|
|
41133
|
+
const principal = { id: 'u1', roles: ['editor'] };
|
|
40570
41134
|
|
|
40571
|
-
|
|
40572
|
-
|
|
40573
|
-
|
|
41135
|
+
const decision = ward.explain({
|
|
41136
|
+
principal,
|
|
41137
|
+
resource: 'posts',
|
|
41138
|
+
action: 'update',
|
|
41139
|
+
data: { authorId: 'u2' },
|
|
40574
41140
|
});
|
|
40575
41141
|
```
|
|
40576
41142
|
|
|
40577
|
-
|
|
40578
|
-
|
|
40579
|
-
### `explain()`
|
|
40580
|
-
|
|
40581
|
-
```ts
|
|
40582
|
-
ward.explain(
|
|
40583
|
-
principal: Principal,
|
|
40584
|
-
resource: string,
|
|
40585
|
-
action: TAction,
|
|
40586
|
-
data?: TData,
|
|
40587
|
-
): WardDecision
|
|
40588
|
-
```
|
|
40589
|
-
|
|
40590
|
-
Returns a full decision object including the winning rule (for allow and explicit deny). The returned `rule` object is **frozen** — mutations throw `TypeError`. Uses `'rule' in decision` to safely narrow across all three variants.
|
|
41143
|
+
`explain()` returns a discriminated decision (`allowed: true | false`) and optional matching `rule`.
|
|
40591
41144
|
|
|
40592
|
-
|
|
41145
|
+
## Bound View
|
|
40593
41146
|
|
|
40594
|
-
|
|
41147
|
+
Use `forUser()` when checking many permissions for the same principal:
|
|
40595
41148
|
|
|
40596
41149
|
```ts
|
|
40597
|
-
const
|
|
41150
|
+
const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
|
|
40598
41151
|
|
|
40599
|
-
|
|
40600
|
-
|
|
40601
|
-
if (decision.reason === 'explicit-deny') {
|
|
40602
|
-
console.log(decision.rule.effect); // safe — rule is present
|
|
40603
|
-
}
|
|
40604
|
-
}
|
|
41152
|
+
bound.explain({ resource: 'posts', action: 'read' });
|
|
41153
|
+
bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
|
|
40605
41154
|
```
|
|
40606
41155
|
|
|
40607
|
-
|
|
40608
|
-
|
|
40609
|
-
### `trace()`
|
|
41156
|
+
## Middleware Guards
|
|
40610
41157
|
|
|
40611
41158
|
```ts
|
|
40612
|
-
ward
|
|
40613
|
-
principal: Principal,
|
|
40614
|
-
resource: string,
|
|
40615
|
-
action: TAction,
|
|
40616
|
-
data?: TData,
|
|
40617
|
-
): WardTrace
|
|
40618
|
-
```
|
|
40619
|
-
|
|
40620
|
-
Returns the complete decision trace: every rule that matched before the winner was selected, plus the final `WardDecision`. Each candidate exposes `priority`, `score`, `rule`, and a `won` flag.
|
|
40621
|
-
|
|
40622
|
-
`trace()` fires the logger with the same context as `explain()`. Switching from `explain` to `trace` for richer diagnostics does not silently drop audit records.
|
|
40623
|
-
|
|
40624
|
-
**Returns:** `WardTrace`
|
|
40625
|
-
|
|
40626
|
-
**Example:**
|
|
40627
|
-
|
|
40628
|
-
```ts
|
|
40629
|
-
const { decision, candidates } = ward.trace({ id: 'u1', roles: ['editor'] }, 'posts', 'read');
|
|
41159
|
+
import { guardRequest, guardRequestWith } from '@vielzeug/ward';
|
|
40630
41160
|
|
|
40631
|
-
|
|
40632
|
-
|
|
41161
|
+
const direct = guardRequest({
|
|
41162
|
+
ward,
|
|
41163
|
+
principal,
|
|
41164
|
+
resource: 'posts',
|
|
41165
|
+
action: 'read',
|
|
40633
41166
|
});
|
|
40634
|
-
```
|
|
40635
|
-
|
|
40636
|
-
---
|
|
40637
|
-
|
|
40638
|
-
### `rulesInScope()`
|
|
40639
41167
|
|
|
40640
|
-
|
|
40641
|
-
ward
|
|
40642
|
-
|
|
40643
|
-
|
|
40644
|
-
|
|
40645
|
-
|
|
40646
|
-
```
|
|
40647
|
-
|
|
40648
|
-
Returns all rules matching the principal/resource combination regardless of action. When `data` is provided, predicate rules are also evaluated and excluded if they do not match. Without `data`, predicate-gated rules appear unfiltered. Does not invoke the logger.
|
|
40649
|
-
|
|
40650
|
-
**Returns:** `ReadonlyArray>>`
|
|
40651
|
-
|
|
40652
|
-
**Example:**
|
|
40653
|
-
|
|
40654
|
-
```ts
|
|
40655
|
-
const rules = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts');
|
|
40656
|
-
const narrowed = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts', { authorId: 'u1' });
|
|
40657
|
-
```
|
|
40658
|
-
|
|
40659
|
-
---
|
|
40660
|
-
|
|
40661
|
-
### `detectConflicts()`
|
|
40662
|
-
|
|
40663
|
-
```ts
|
|
40664
|
-
ward.detectConflicts(): WardConflict[]
|
|
40665
|
-
```
|
|
40666
|
-
|
|
40667
|
-
Returns all rule conflicts in the policy. Lazily computed and cached — every call after the first returns the same array reference. O(n²) in the number of rules.
|
|
40668
|
-
|
|
40669
|
-
Two conflict kinds, narrowable by `kind`:
|
|
40670
|
-
|
|
40671
|
-
- **`'duplicate'`** — two predicate-free rules share the same (role set, resource, action). Fields: `ruleA`/`indexA` (first-declared, wins) and `ruleB`/`indexB` (unreachable).
|
|
40672
|
-
- **`'shadowed'`** — a higher-ranked predicate-free rule covers the narrower rule's patterns entirely. Fields: `shadowingRule`/`shadowingIndex` (always wins) and `shadowedRule`/`shadowedIndex` (can never win).
|
|
40673
|
-
|
|
40674
|
-
Rules with a `when` predicate are excluded from both checks — their applicability can only be determined at runtime.
|
|
40675
|
-
|
|
40676
|
-
**Returns:** `WardConflict[]`
|
|
40677
|
-
|
|
40678
|
-
**Example:**
|
|
40679
|
-
|
|
40680
|
-
```ts
|
|
40681
|
-
const conflicts = ward.detectConflicts();
|
|
40682
|
-
|
|
40683
|
-
conflicts.forEach((c) => {
|
|
40684
|
-
if (c.kind === 'duplicate') {
|
|
40685
|
-
console.warn(`Rule[${c.indexB}] is an unreachable duplicate of Rule[${c.indexA}]`);
|
|
40686
|
-
} else {
|
|
40687
|
-
console.warn(`Rule[${c.shadowedIndex}] is shadowed by Rule[${c.shadowingIndex}]`);
|
|
40688
|
-
}
|
|
41168
|
+
const extracted = await guardRequestWith({
|
|
41169
|
+
ward,
|
|
41170
|
+
req,
|
|
41171
|
+
extractPrincipal: async (request) => request.user ?? null,
|
|
41172
|
+
resource: 'posts',
|
|
41173
|
+
action: 'read',
|
|
40689
41174
|
});
|
|
40690
41175
|
```
|
|
40691
41176
|
|
|
40692
|
-
|
|
40693
|
-
|
|
40694
|
-
### `forUser()`
|
|
40695
|
-
|
|
40696
|
-
```ts
|
|
40697
|
-
ward.forUser(principal: UserPrincipal): BoundWard
|
|
40698
|
-
```
|
|
40699
|
-
|
|
40700
|
-
Creates a principal-bound view of the ward. The principal — including nested `attributes` — is deep-snapshotted at call time; subsequent mutations to the original object have no effect on the bound view.
|
|
40701
|
-
|
|
40702
|
-
**Returns:** `BoundWard`
|
|
40703
|
-
|
|
40704
|
-
**Methods on `BoundWard`:**
|
|
40705
|
-
|
|
40706
|
-
| Method | Signature | Description |
|
|
40707
|
-
| ---------------- | ---------------------------------------------- | ------------------------------ |
|
|
40708
|
-
| `checkAll` | `(checks) => WardDecisionResult[]` | Batch decisions |
|
|
40709
|
-
| `allowedActions` | `(resource, knownActions, data?) => TAction[]` | Action enumeration (no logger) |
|
|
40710
|
-
| `explain` | `(resource, action, data?) => WardDecision` | Full decision with reason |
|
|
40711
|
-
| `rulesInScope` | `(resource, data?) => ReadonlyArray>` | Rule introspection (no logger) |
|
|
40712
|
-
| `trace` | `(resource, action, data?) => WardTrace` | Decision trace (does not fire the logger) |
|
|
40713
|
-
|
|
40714
|
-
**Example:**
|
|
40715
|
-
|
|
40716
|
-
```ts
|
|
40717
|
-
const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
|
|
41177
|
+
See the [usage guide](./usage.md), [API reference](./api.md), and [examples](./examples/blog-roles.md).
|
|
40718
41178
|
|
|
40719
|
-
|
|
40720
|
-
bound.checkAll([
|
|
40721
|
-
{ resource: 'posts', action: 'read' },
|
|
40722
|
-
{ resource: 'posts', action: 'update', data: { authorId: 'u1' } },
|
|
40723
|
-
]);
|
|
40724
|
-
bound.allowedActions('posts', ['read', 'update', 'delete']);
|
|
40725
|
-
bound.allowedActions('posts', ['read', 'update', 'delete'], { authorId: 'u1' });
|
|
40726
|
-
bound.explain('posts', 'delete');
|
|
40727
|
-
bound.trace('posts', 'read');
|
|
40728
|
-
bound.rulesInScope('posts');
|
|
40729
|
-
```
|
|
41179
|
+
### API Reference
|
|
40730
41180
|
|
|
40731
|
-
##
|
|
41181
|
+
## Core Factory
|
|
40732
41182
|
|
|
40733
|
-
### `
|
|
41183
|
+
### `createWard(rules, options?)`
|
|
40734
41184
|
|
|
40735
41185
|
```ts
|
|
40736
|
-
|
|
40737
|
-
|
|
40738
|
-
|
|
40739
|
-
|
|
40740
|
-
options?: { priority?: number; when?: WardPredicate },
|
|
40741
|
-
): WardRule[]
|
|
40742
|
-
|
|
40743
|
-
deny(
|
|
40744
|
-
role: string | readonly string[],
|
|
40745
|
-
resource: string | typeof WILDCARD,
|
|
40746
|
-
actions: readonly (TAction | typeof WILDCARD)[],
|
|
40747
|
-
options?: { priority?: number; when?: WardPredicate },
|
|
40748
|
-
): WardRule[]
|
|
41186
|
+
createWard(
|
|
41187
|
+
rules: ReadonlyArray>>,
|
|
41188
|
+
options?: WardOptions,
|
|
41189
|
+
): Ward;
|
|
40749
41190
|
```
|
|
40750
41191
|
|
|
40751
|
-
|
|
41192
|
+
Creates an immutable ward instance.
|
|
40752
41193
|
|
|
40753
|
-
|
|
41194
|
+
## Rule Builders
|
|
40754
41195
|
|
|
40755
|
-
|
|
41196
|
+
### `allow(role, resource, actions, options?)`
|
|
41197
|
+
### `deny(role, resource, actions, options?)`
|
|
41198
|
+
### `ruleFor(effect, role, resource, actions, options?)`
|
|
40756
41199
|
|
|
40757
|
-
|
|
40758
|
-
import { WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
|
|
40759
|
-
|
|
40760
|
-
const ward = createWard([
|
|
40761
|
-
...allow(['viewer', 'editor'], 'posts', ['read']),
|
|
40762
|
-
...allow('editor', 'posts', ['update'], { when: owns('authorId'), priority: 5 }),
|
|
40763
|
-
...deny('blocked', WILDCARD, [WILDCARD], { priority: 100 }),
|
|
40764
|
-
]);
|
|
40765
|
-
```
|
|
41200
|
+
All three return `WardRule[]` (one rule per action).
|
|
40766
41201
|
|
|
40767
|
-
|
|
41202
|
+
## Ward Methods
|
|
40768
41203
|
|
|
40769
|
-
### `
|
|
41204
|
+
### `checkAll(principal, checks)`
|
|
40770
41205
|
|
|
40771
41206
|
```ts
|
|
40772
|
-
|
|
40773
|
-
|
|
40774
|
-
|
|
40775
|
-
|
|
40776
|
-
actions: readonly (TAction | typeof WILDCARD)[],
|
|
40777
|
-
options?: { priority?: number; when?: WardPredicate },
|
|
40778
|
-
): WardRule[]
|
|
41207
|
+
checkAll(
|
|
41208
|
+
principal: UserPrincipal,
|
|
41209
|
+
checks: ReadonlyArray>,
|
|
41210
|
+
): WardDecisionResult[];
|
|
40779
41211
|
```
|
|
40780
41212
|
|
|
40781
|
-
|
|
40782
|
-
|
|
40783
|
-
**Returns:** `WardRule[]`
|
|
40784
|
-
|
|
40785
|
-
**Example:**
|
|
41213
|
+
### `explain(input)`
|
|
40786
41214
|
|
|
40787
41215
|
```ts
|
|
40788
|
-
|
|
40789
|
-
|
|
40790
|
-
ruleFor('allow', 'viewer', 'posts', ['read', 'update']);
|
|
40791
|
-
ruleFor('deny', ['blocked', 'suspended'], WILDCARD, [WILDCARD], { priority: 100 });
|
|
41216
|
+
explain(input: WardExplainInput): WardDecision;
|
|
40792
41217
|
```
|
|
40793
41218
|
|
|
40794
|
-
|
|
40795
|
-
|
|
40796
|
-
### `owns()` / `predicate`
|
|
41219
|
+
`WardExplainInput`:
|
|
40797
41220
|
|
|
40798
41221
|
```ts
|
|
40799
|
-
|
|
40800
|
-
|
|
40801
|
-
|
|
40802
|
-
|
|
40803
|
-
|
|
40804
|
-
|
|
40805
|
-
owns(attributeKey: keyof TData & string): WardPredicate;
|
|
40806
|
-
};
|
|
41222
|
+
{
|
|
41223
|
+
principal: UserPrincipal;
|
|
41224
|
+
resource: string;
|
|
41225
|
+
action: TAction;
|
|
41226
|
+
data?: TData;
|
|
41227
|
+
}
|
|
40807
41228
|
```
|
|
40808
41229
|
|
|
40809
|
-
|
|
40810
|
-
|
|
40811
|
-
- `predicate.and(...preds)` — all predicates must return `true`. Zero arguments → `true` (vacuously).
|
|
40812
|
-
- `predicate.or(...preds)` — at least one predicate must return `true`. Zero arguments → `false`.
|
|
40813
|
-
- `predicate.not(pred)` — inverts a predicate.
|
|
40814
|
-
|
|
40815
|
-
`owns()` (and any `when` predicate) must only be used with rules that require authentication (non-`ANONYMOUS` role). Predicates are skipped for unauthenticated requests — pairing `owns` with `ANONYMOUS` produces a rule that can never match.
|
|
40816
|
-
|
|
40817
|
-
**Example:**
|
|
41230
|
+
### `trace(input)`
|
|
40818
41231
|
|
|
40819
41232
|
```ts
|
|
40820
|
-
|
|
40821
|
-
|
|
40822
|
-
allow('editor', 'posts:*', ['update'], { when: predicate.owns('authorId') });
|
|
40823
|
-
allow('user', 'posts:*', ['read'], { when: predicate.and(predicate.owns('authorId'), isBusinessHours) });
|
|
41233
|
+
trace(input: WardTraceInput): WardTrace;
|
|
40824
41234
|
```
|
|
40825
41235
|
|
|
40826
|
-
|
|
41236
|
+
Same request shape as `explain()`. Returns winner + candidate list. Does not fire logger.
|
|
40827
41237
|
|
|
40828
|
-
### `
|
|
41238
|
+
### `allowedActions(input)`
|
|
40829
41239
|
|
|
40830
41240
|
```ts
|
|
40831
|
-
|
|
41241
|
+
allowedActions(
|
|
41242
|
+
input: WardAllowedActionsInput,
|
|
41243
|
+
): TKnown[];
|
|
40832
41244
|
```
|
|
40833
41245
|
|
|
40834
|
-
|
|
40835
|
-
|
|
40836
|
-
**Pattern semantics:**
|
|
40837
|
-
|
|
40838
|
-
| Pattern | Matches |
|
|
40839
|
-
| ----------- | -------------------------------------------------------------------- |
|
|
40840
|
-
| `*` | Any value |
|
|
40841
|
-
| `posts` | Exactly `posts` |
|
|
40842
|
-
| `posts:*` | Any value starting with `posts:` (e.g. `posts:123`, `posts:draft:1`) |
|
|
40843
|
-
| `posts:123` | Exactly `posts:123` |
|
|
40844
|
-
| `read:*` | Any action starting with `read:` (e.g. `read:own`, `read:all`) |
|
|
40845
|
-
|
|
40846
|
-
**Example:**
|
|
41246
|
+
Input shape:
|
|
40847
41247
|
|
|
40848
41248
|
```ts
|
|
40849
|
-
|
|
40850
|
-
|
|
40851
|
-
|
|
40852
|
-
|
|
40853
|
-
|
|
41249
|
+
{
|
|
41250
|
+
principal: UserPrincipal;
|
|
41251
|
+
resource: string;
|
|
41252
|
+
knownActions: readonly TKnown[];
|
|
41253
|
+
data?: TData;
|
|
41254
|
+
}
|
|
40854
41255
|
```
|
|
40855
41256
|
|
|
40856
|
-
|
|
40857
|
-
|
|
40858
|
-
### `patternCovers()`
|
|
41257
|
+
### `rulesInScope(input)`
|
|
40859
41258
|
|
|
40860
41259
|
```ts
|
|
40861
|
-
|
|
41260
|
+
rulesInScope(input: WardRulesInScopeInput): ReadonlyArray>>;
|
|
40862
41261
|
```
|
|
40863
41262
|
|
|
40864
|
-
|
|
40865
|
-
|
|
40866
|
-
**Example:**
|
|
41263
|
+
Input shape:
|
|
40867
41264
|
|
|
40868
41265
|
```ts
|
|
40869
|
-
|
|
40870
|
-
|
|
40871
|
-
|
|
40872
|
-
|
|
40873
|
-
|
|
40874
|
-
patternCovers('posts', 'posts:*'); // false
|
|
41266
|
+
{
|
|
41267
|
+
principal: UserPrincipal;
|
|
41268
|
+
resource: string;
|
|
41269
|
+
data?: TData;
|
|
41270
|
+
}
|
|
40875
41271
|
```
|
|
40876
41272
|
|
|
40877
|
-
|
|
40878
|
-
|
|
40879
|
-
### Middleware Factories
|
|
40880
|
-
|
|
40881
|
-
#### `guardRequest()`
|
|
41273
|
+
### `detectConflicts()`
|
|
40882
41274
|
|
|
40883
41275
|
```ts
|
|
40884
|
-
|
|
40885
|
-
ward: Ward,
|
|
40886
|
-
principal: Principal,
|
|
40887
|
-
resource: string,
|
|
40888
|
-
action: TAction,
|
|
40889
|
-
data?: TData,
|
|
40890
|
-
): GuardResult
|
|
41276
|
+
detectConflicts(): WardConflict[];
|
|
40891
41277
|
```
|
|
40892
41278
|
|
|
40893
|
-
|
|
41279
|
+
### `forUser(principal)`
|
|
40894
41280
|
|
|
40895
41281
|
```ts
|
|
40896
|
-
|
|
40897
|
-
| { granted: true; principal: Principal }
|
|
40898
|
-
| { granted: false; decision: WardDecision; principal: Principal; reason: 'explicit-deny' | 'no-matching-rule' };
|
|
41282
|
+
forUser(principal: UserPrincipal): BoundWard;
|
|
40899
41283
|
```
|
|
40900
41284
|
|
|
40901
|
-
|
|
41285
|
+
Returns a principal-bound view.
|
|
40902
41286
|
|
|
40903
|
-
|
|
41287
|
+
## `BoundWard` Methods
|
|
40904
41288
|
|
|
40905
41289
|
```ts
|
|
40906
|
-
|
|
40907
|
-
|
|
40908
|
-
|
|
40909
|
-
|
|
40910
|
-
|
|
40911
|
-
|
|
41290
|
+
interface BoundWard {
|
|
41291
|
+
checkAll(checks: ReadonlyArray>): WardDecisionResult[];
|
|
41292
|
+
explain(input: BoundWardExplainInput): WardDecision;
|
|
41293
|
+
trace(input: BoundWardTraceInput): WardTrace;
|
|
41294
|
+
allowedActions(input: BoundWardAllowedActionsInput): TKnown[];
|
|
41295
|
+
rulesInScope(input: BoundWardRulesInScopeInput): ReadonlyArray>>;
|
|
40912
41296
|
}
|
|
40913
41297
|
```
|
|
40914
41298
|
|
|
40915
|
-
|
|
40916
|
-
|
|
40917
|
-
#### `guardRequestWith()`
|
|
41299
|
+
Bound input shapes remove `principal`:
|
|
40918
41300
|
|
|
40919
41301
|
```ts
|
|
40920
|
-
|
|
40921
|
-
|
|
40922
|
-
|
|
40923
|
-
extractPrincipal: (req: TReq) => Principal | Promise,
|
|
40924
|
-
resource: string,
|
|
40925
|
-
action: TAction,
|
|
40926
|
-
data?: TData,
|
|
40927
|
-
): Promise>
|
|
41302
|
+
{ resource: string; action: TAction; data?: TData } // explain/trace
|
|
41303
|
+
{ resource: string; knownActions: readonly TKnown[]; data?: TData } // allowedActions
|
|
41304
|
+
{ resource: string; data?: TData } // rulesInScope
|
|
40928
41305
|
```
|
|
40929
41306
|
|
|
40930
|
-
|
|
40931
|
-
|
|
40932
|
-
**Example:**
|
|
40933
|
-
|
|
40934
|
-
```ts
|
|
40935
|
-
import { guardRequestWith } from '@vielzeug/ward';
|
|
41307
|
+
## Predicate Helpers
|
|
40936
41308
|
|
|
40937
|
-
|
|
41309
|
+
### `predicate.owns(attributeKey)`
|
|
41310
|
+
### `predicate.and(...predicates)`
|
|
41311
|
+
### `predicate.or(...predicates)`
|
|
41312
|
+
### `predicate.not(predicate)`
|
|
41313
|
+
### `owns(attributeKey)` (alias)
|
|
40938
41314
|
|
|
40939
|
-
|
|
40940
|
-
return response.status(403).json({ reason: result.reason });
|
|
40941
|
-
}
|
|
40942
|
-
```
|
|
41315
|
+
Predicates run synchronously. Returning a Promise throws `WardPredicateError`.
|
|
40943
41316
|
|
|
40944
|
-
##
|
|
41317
|
+
## Pattern Helpers
|
|
40945
41318
|
|
|
40946
|
-
### `
|
|
41319
|
+
### `matchesPattern(pattern, value): boolean`
|
|
41320
|
+
### `patternCovers(broad, narrow): boolean`
|
|
40947
41321
|
|
|
40948
|
-
|
|
40949
|
-
type UserPrincipal = {
|
|
40950
|
-
id: string;
|
|
40951
|
-
roles: readonly string[];
|
|
40952
|
-
attributes?: Record;
|
|
40953
|
-
};
|
|
40954
|
-
```
|
|
41322
|
+
## Middleware Guards
|
|
40955
41323
|
|
|
40956
|
-
### `
|
|
41324
|
+
### `guardRequest(input)`
|
|
40957
41325
|
|
|
40958
41326
|
```ts
|
|
40959
|
-
|
|
41327
|
+
guardRequest(
|
|
41328
|
+
input: GuardRequestInput,
|
|
41329
|
+
): GuardResult;
|
|
40960
41330
|
```
|
|
40961
41331
|
|
|
40962
|
-
|
|
40963
|
-
|
|
40964
|
-
### `RuleContext`
|
|
41332
|
+
Input:
|
|
40965
41333
|
|
|
40966
41334
|
```ts
|
|
40967
|
-
|
|
41335
|
+
{
|
|
41336
|
+
ward: Ward;
|
|
40968
41337
|
principal: UserPrincipal;
|
|
41338
|
+
resource: string;
|
|
41339
|
+
action: TAction;
|
|
40969
41340
|
data?: TData;
|
|
40970
|
-
};
|
|
40971
|
-
```
|
|
40972
|
-
|
|
40973
|
-
### `WardPredicate`
|
|
40974
|
-
|
|
40975
|
-
```ts
|
|
40976
|
-
type WardPredicate = (ctx: RuleContext) => boolean;
|
|
40977
|
-
```
|
|
40978
|
-
|
|
40979
|
-
### `WardRule`
|
|
40980
|
-
|
|
40981
|
-
The single rule shape — used both when authoring rules passed to `createWard` and when reading rules back from decisions, `trace()`, `rulesInScope()`, and `detectConflicts()`.
|
|
40982
|
-
|
|
40983
|
-
```ts
|
|
40984
|
-
type WardRule = {
|
|
40985
|
-
action: TAction | typeof WILDCARD;
|
|
40986
|
-
effect: 'allow' | 'deny';
|
|
40987
|
-
priority?: number; // defaults to 0
|
|
40988
|
-
resource: string | typeof WILDCARD;
|
|
40989
|
-
role: string | readonly string[];
|
|
40990
|
-
when?: WardPredicate;
|
|
40991
|
-
};
|
|
40992
|
-
```
|
|
40993
|
-
|
|
40994
|
-
Internally, `createWard` normalizes each rule at compile time — `role` becomes a deduplicated `readonly string[]` and `priority` defaults to `0` — and freezes the result. Rules read back from `explain()`, `trace()`, `rulesInScope()`, or `detectConflicts()` are these normalized, frozen objects (`Readonly>`); mutating them throws `TypeError`.
|
|
40995
|
-
|
|
40996
|
-
### `WardDecision`
|
|
40997
|
-
|
|
40998
|
-
Three distinct variants — use discriminated narrowing:
|
|
40999
|
-
|
|
41000
|
-
```ts
|
|
41001
|
-
type WardDecision =
|
|
41002
|
-
| { allowed: true; rule: WardRule }
|
|
41003
|
-
| { allowed: false; reason: 'explicit-deny'; rule: WardRule }
|
|
41004
|
-
| { allowed: false; reason: 'no-matching-rule' }; // no rule field
|
|
41005
|
-
```
|
|
41006
|
-
|
|
41007
|
-
```ts
|
|
41008
|
-
const d = ward.explain(principal, 'posts', 'delete');
|
|
41009
|
-
|
|
41010
|
-
if (d.allowed) {
|
|
41011
|
-
console.log(d.rule.effect); // 'allow'
|
|
41012
|
-
} else if (d.reason === 'explicit-deny') {
|
|
41013
|
-
console.log(d.rule.effect); // 'deny'
|
|
41014
|
-
} else {
|
|
41015
|
-
// d.reason === 'no-matching-rule' — no rule field present
|
|
41016
41341
|
}
|
|
41017
|
-
|
|
41018
|
-
// Generic narrowing:
|
|
41019
|
-
if ('rule' in d) console.log(d.rule);
|
|
41020
41342
|
```
|
|
41021
41343
|
|
|
41022
|
-
### `
|
|
41344
|
+
### `guardRequestWith(input)`
|
|
41023
41345
|
|
|
41024
41346
|
```ts
|
|
41025
|
-
|
|
41026
|
-
|
|
41027
|
-
|
|
41028
|
-
data?: TData;
|
|
41029
|
-
};
|
|
41347
|
+
guardRequestWith(
|
|
41348
|
+
input: GuardRequestWithInput,
|
|
41349
|
+
): Promise>;
|
|
41030
41350
|
```
|
|
41031
41351
|
|
|
41032
|
-
|
|
41033
|
-
|
|
41034
|
-
Structurally identical to `WardDecision` plus the request fields — narrow `rule` with the same `if (ctx.allowed)` pattern used for decisions:
|
|
41352
|
+
Input:
|
|
41035
41353
|
|
|
41036
41354
|
```ts
|
|
41037
|
-
|
|
41355
|
+
{
|
|
41356
|
+
ward: Ward;
|
|
41357
|
+
req: TReq;
|
|
41358
|
+
extractPrincipal: PrincipalExtractor;
|
|
41359
|
+
resource: string;
|
|
41038
41360
|
action: TAction;
|
|
41039
41361
|
data?: TData;
|
|
41040
|
-
|
|
41041
|
-
resource: string;
|
|
41042
|
-
};
|
|
41043
|
-
```
|
|
41044
|
-
|
|
41045
|
-
```ts
|
|
41046
|
-
logger: (ctx) => {
|
|
41047
|
-
if (ctx.allowed) {
|
|
41048
|
-
console.log(ctx.rule.role); // no ?. needed — 'allowed: true' always carries a rule
|
|
41049
|
-
} else if (ctx.reason === 'explicit-deny') {
|
|
41050
|
-
console.log(ctx.rule.role); // 'explicit-deny' also carries a rule
|
|
41051
|
-
}
|
|
41052
|
-
},
|
|
41053
|
-
```
|
|
41054
|
-
|
|
41055
|
-
### `WardOptions`
|
|
41056
|
-
|
|
41057
|
-
```ts
|
|
41058
|
-
type WardOptions = {
|
|
41059
|
-
logger?: (context: WardLoggerContext) => void;
|
|
41060
|
-
onConflict?: (conflict: WardConflict) => void;
|
|
41061
|
-
strict?: boolean;
|
|
41062
|
-
maxConflicts?: number;
|
|
41063
|
-
};
|
|
41064
|
-
```
|
|
41065
|
-
|
|
41066
|
-
### `ConflictKind` / `WardConflict`
|
|
41067
|
-
|
|
41068
|
-
`WardConflict` is a discriminated union, narrowable by `kind`:
|
|
41069
|
-
|
|
41070
|
-
```ts
|
|
41071
|
-
type ConflictKind = 'duplicate' | 'shadowed';
|
|
41072
|
-
|
|
41073
|
-
type WardConflict =
|
|
41074
|
-
| {
|
|
41075
|
-
kind: 'duplicate';
|
|
41076
|
-
indexA: number; // first-declared rule (always wins)
|
|
41077
|
-
indexB: number; // second-declared rule (unreachable)
|
|
41078
|
-
ruleA: Readonly>;
|
|
41079
|
-
ruleB: Readonly>;
|
|
41080
|
-
}
|
|
41081
|
-
| {
|
|
41082
|
-
kind: 'shadowed';
|
|
41083
|
-
shadowedIndex: number; // the rule that can never win
|
|
41084
|
-
shadowedRule: Readonly>;
|
|
41085
|
-
shadowingIndex: number; // the rule that always wins instead
|
|
41086
|
-
shadowingRule: Readonly>;
|
|
41087
|
-
};
|
|
41362
|
+
}
|
|
41088
41363
|
```
|
|
41089
41364
|
|
|
41090
|
-
|
|
41091
|
-
|
|
41092
|
-
```ts
|
|
41093
|
-
type WardTraceCandidate = {
|
|
41094
|
-
index: number; // original index in the input array passed to createWard
|
|
41095
|
-
priority: number;
|
|
41096
|
-
rule: Readonly>;
|
|
41097
|
-
score: number;
|
|
41098
|
-
won: boolean;
|
|
41099
|
-
};
|
|
41365
|
+
## Devtools
|
|
41100
41366
|
|
|
41101
|
-
|
|
41102
|
-
candidates: WardTraceCandidate[];
|
|
41103
|
-
decision: WardDecision;
|
|
41104
|
-
};
|
|
41105
|
-
```
|
|
41367
|
+
### `debugWard(ward, logger?)`
|
|
41106
41368
|
|
|
41107
|
-
|
|
41369
|
+
Sub-path import: `@vielzeug/ward/devtools`.
|
|
41108
41370
|
|
|
41109
41371
|
```ts
|
|
41110
41372
|
import { debugWard } from '@vielzeug/ward/devtools';
|
|
41111
|
-
|
|
41112
|
-
const permit = debugWard(rules);
|
|
41113
|
-
|
|
41114
|
-
permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read');
|
|
41115
|
-
// [ward:decision] allow (allow) viewer posts read
|
|
41116
|
-
|
|
41117
|
-
permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'delete');
|
|
41118
|
-
// [ward:decision] no-matching-rule viewer posts delete
|
|
41119
|
-
```
|
|
41120
|
-
|
|
41121
|
-
Wraps `createWard()` with a `logger` pre-wired to `console.debug`. Returns the same `Ward` instance — all methods are identical to `createWard()`. Debug output fires on `explain()` and `checkAll()`; `trace()` never fires the logger (by design — see `trace()` above).
|
|
41122
|
-
|
|
41123
|
-
Import from the dedicated sub-path so the `console.debug` reference is tree-shaken from production bundles when not imported.
|
|
41124
|
-
|
|
41125
|
-
Accepts the same `options` as `createWard()` except `logger`, which is reserved for the debug output. All other options (`maxConflicts`, `onConflict`, `strict`) pass through unchanged.
|
|
41126
|
-
|
|
41127
|
-
### `WardDecisionResult`
|
|
41128
|
-
|
|
41129
|
-
```ts
|
|
41130
|
-
type WardDecisionResult = WardDecision & {
|
|
41131
|
-
action: TAction;
|
|
41132
|
-
resource: string;
|
|
41133
|
-
};
|
|
41134
|
-
```
|
|
41135
|
-
|
|
41136
|
-
The return type of `checkAll()` — a `WardDecision` with the originating `resource` and `action` attached, so callers do not need to zip the result by index.
|
|
41137
|
-
|
|
41138
|
-
### `WardRequest`
|
|
41139
|
-
|
|
41140
|
-
```ts
|
|
41141
|
-
type WardRequest = Record;
|
|
41142
41373
|
```
|
|
41143
41374
|
|
|
41144
|
-
Base constraint for the request object type used in `guardRequestWith`. Any object type satisfies this constraint.
|
|
41145
|
-
|
|
41146
|
-
### `Ward` / `BoundWard`
|
|
41147
|
-
|
|
41148
|
-
`Ward` is returned by `createWard()`. `BoundWard` is returned by `ward.forUser()` and omits `forUser` and `detectConflicts`. Full method signatures are documented in the sections above.
|
|
41149
|
-
|
|
41150
41375
|
### Usage Guide
|
|
41151
41376
|
|
|
41152
|
-
##
|
|
41153
|
-
|
|
41154
|
-
Create a ward instance with an array of rules. Rules are compiled once at creation time.
|
|
41377
|
+
## Create a Ward
|
|
41155
41378
|
|
|
41156
41379
|
```ts
|
|
41157
41380
|
import { WILDCARD, createWard } from '@vielzeug/ward';
|
|
@@ -41159,592 +41382,124 @@ import { WILDCARD, createWard } from '@vielzeug/ward';
|
|
|
41159
41382
|
const ward = createWard([
|
|
41160
41383
|
{ role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
|
|
41161
41384
|
{ role: 'editor', resource: 'posts', action: 'update', effect: 'allow' },
|
|
41162
|
-
// High-priority deny blocks the blocked role from every action on posts
|
|
41163
41385
|
{ role: 'blocked', resource: 'posts', action: WILDCARD, effect: 'deny', priority: 100 },
|
|
41164
41386
|
]);
|
|
41165
|
-
|
|
41166
|
-
ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true
|
|
41167
|
-
ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'update').allowed; // false
|
|
41168
|
-
ward.explain({ id: 'u2', roles: ['blocked'] }, 'posts', 'read').allowed; // false
|
|
41169
|
-
```
|
|
41170
|
-
|
|
41171
|
-
To update the policy, create a new instance — rules are immutable after creation.
|
|
41172
|
-
|
|
41173
|
-
## Rule Factories
|
|
41174
|
-
|
|
41175
|
-
Use `allow()` / `deny()` as an alternative to raw rule objects. Each produces one `WardRule` per action — spread the result into the array passed to `createWard`. They read naturally: "allow editor to read/update posts".
|
|
41176
|
-
|
|
41177
|
-
```ts
|
|
41178
|
-
import { allow, createWard, owns } from '@vielzeug/ward';
|
|
41179
|
-
|
|
41180
|
-
const ward = createWard([
|
|
41181
|
-
...allow(['viewer', 'editor'], 'posts', ['read']),
|
|
41182
|
-
...allow('editor', 'posts', ['update', 'delete'], { when: owns('authorId') }),
|
|
41183
|
-
]);
|
|
41184
|
-
```
|
|
41185
|
-
|
|
41186
|
-
Pass `{ priority: n }` and/or `{ when: predicate }` as the fourth argument. `deny()` is the same shape with `effect: 'deny'` fixed:
|
|
41187
|
-
|
|
41188
|
-
```ts
|
|
41189
|
-
import { WILDCARD, deny } from '@vielzeug/ward';
|
|
41190
|
-
|
|
41191
|
-
deny('blocked', 'posts', [WILDCARD], { priority: 100 });
|
|
41192
|
-
```
|
|
41193
|
-
|
|
41194
|
-
`ruleFor(effect, role, resource, actions, options?)` is the low-level factory that `allow()`/`deny()` wrap — use it when the effect is only known dynamically.
|
|
41195
|
-
|
|
41196
|
-
## Hierarchical Resources
|
|
41197
|
-
|
|
41198
|
-
Use colon-namespaced patterns to scope rules to resource instances:
|
|
41199
|
-
|
|
41200
|
-
```ts
|
|
41201
|
-
const ward = createWard([
|
|
41202
|
-
// Applies to any resource under 'posts:' namespace
|
|
41203
|
-
{ role: 'editor', resource: 'posts:*', action: 'update', effect: 'allow' },
|
|
41204
|
-
// Applies only to one specific post
|
|
41205
|
-
{ role: 'viewer', resource: 'posts:123', action: 'read', effect: 'allow' },
|
|
41206
|
-
]);
|
|
41207
|
-
|
|
41208
|
-
ward.explain(editor, 'posts:456', 'update').allowed; // true — matches posts:*
|
|
41209
|
-
ward.explain(viewer, 'posts:123', 'read').allowed; // true — exact match
|
|
41210
|
-
ward.explain(viewer, 'posts:456', 'read').allowed; // false — no matching rule
|
|
41211
|
-
```
|
|
41212
|
-
|
|
41213
|
-
The same namespace-wildcard syntax works for actions (action hierarchy):
|
|
41214
|
-
|
|
41215
|
-
```ts
|
|
41216
|
-
// 'read:*' matches 'read:own', 'read:all', 'read:draft:1', etc.
|
|
41217
|
-
const ward = createWard([{ role: 'viewer', resource: 'posts', action: 'read:*', effect: 'allow' }]);
|
|
41218
|
-
|
|
41219
|
-
ward.explain(viewer, 'posts', 'read:own').allowed; // true
|
|
41220
|
-
ward.explain(viewer, 'posts', 'read:all').allowed; // true
|
|
41221
|
-
ward.explain(viewer, 'posts', 'write').allowed; // false
|
|
41222
41387
|
```
|
|
41223
41388
|
|
|
41224
|
-
|
|
41389
|
+
Rules are immutable after creation. Create a new ward to update policy.
|
|
41225
41390
|
|
|
41226
|
-
##
|
|
41391
|
+
## Explain a Decision
|
|
41227
41392
|
|
|
41228
41393
|
```ts
|
|
41229
|
-
const
|
|
41230
|
-
|
|
41231
|
-
|
|
41232
|
-
|
|
41233
|
-
|
|
41234
|
-
|
|
41235
|
-
|
|
41236
|
-
`principal` must be either:
|
|
41237
|
-
|
|
41238
|
-
- `null` for anonymous users
|
|
41239
|
-
- `{ id: string, roles: readonly string[] }` for authenticated users
|
|
41240
|
-
|
|
41241
|
-
Malformed principal values throw errors.
|
|
41242
|
-
|
|
41243
|
-
## Bind a User with `forUser`
|
|
41244
|
-
|
|
41245
|
-
`BoundWard` does not expose `detectConflicts()`. Run `ward.detectConflicts()` on the parent ward before calling `forUser()` — typically at startup or during policy initialization.
|
|
41246
|
-
|
|
41247
|
-
```ts
|
|
41248
|
-
const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
|
|
41249
|
-
|
|
41250
|
-
bound.explain('posts', 'read').allowed;
|
|
41251
|
-
bound.explain('posts', 'update', { authorId: 'u1' }).allowed;
|
|
41252
|
-
```
|
|
41253
|
-
|
|
41254
|
-
`forUser()` returns a reusable bound ward object and snapshots roles/attributes at binding time.
|
|
41255
|
-
|
|
41256
|
-
## Check Multiple Actions
|
|
41257
|
-
|
|
41258
|
-
Ward has no dedicated "all"/"any" helper — use `checkAll()` and reduce with `Array.every` / `Array.some`:
|
|
41259
|
-
|
|
41260
|
-
```ts
|
|
41261
|
-
const checks = [
|
|
41262
|
-
{ action: 'read', resource: 'posts' },
|
|
41263
|
-
{ action: 'update', resource: 'posts', data: { authorId: 'u1' } },
|
|
41264
|
-
] as const;
|
|
41265
|
-
|
|
41266
|
-
const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, checks);
|
|
41394
|
+
const decision = ward.explain({
|
|
41395
|
+
principal: { id: 'u1', roles: ['editor'] },
|
|
41396
|
+
resource: 'posts',
|
|
41397
|
+
action: 'update',
|
|
41398
|
+
data: { authorId: 'u1' },
|
|
41399
|
+
});
|
|
41267
41400
|
|
|
41268
|
-
|
|
41269
|
-
|
|
41401
|
+
if (decision.allowed) {
|
|
41402
|
+
console.log(decision.rule);
|
|
41403
|
+
} else {
|
|
41404
|
+
console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
|
|
41405
|
+
}
|
|
41270
41406
|
```
|
|
41271
41407
|
|
|
41272
|
-
## Batch Decisions
|
|
41408
|
+
## Batch Decisions
|
|
41273
41409
|
|
|
41274
41410
|
```ts
|
|
41275
|
-
const
|
|
41411
|
+
const results = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
|
|
41276
41412
|
{ resource: 'posts', action: 'read' },
|
|
41277
41413
|
{ resource: 'posts', action: 'update', data: { authorId: 'u1' } },
|
|
41278
41414
|
]);
|
|
41279
|
-
|
|
41280
|
-
const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
|
|
41281
|
-
const boundDecisions = bound.checkAll([
|
|
41282
|
-
{ resource: 'posts', action: 'read' },
|
|
41283
|
-
{ resource: 'posts', action: 'delete' },
|
|
41284
|
-
]);
|
|
41285
|
-
```
|
|
41286
|
-
|
|
41287
|
-
`checkAll()` returns a `WardDecisionResult[]` — each entry is a `WardDecision` with the originating `resource` and `action` fields attached, so callers do not need to zip the result by index.
|
|
41288
|
-
|
|
41289
|
-
## List Allowed Actions
|
|
41290
|
-
|
|
41291
|
-
`allowedActions(principal, resource, knownActions, data?)` returns the subset of `knownActions` that the principal is allowed to perform on `resource`.
|
|
41292
|
-
|
|
41293
|
-
`knownActions` is required because Ward cannot enumerate actions on its own — an action defined with `WILDCARD` has no finite list of concrete values. Passing `knownActions` resolves wildcard-action rules against that set:
|
|
41294
|
-
|
|
41295
|
-
```ts
|
|
41296
|
-
// Returns the subset of the provided list that is allowed
|
|
41297
|
-
const actions = ward.allowedActions({ id: 'u1', roles: ['admin'] }, 'posts', ['read', 'update', 'delete']);
|
|
41298
|
-
|
|
41299
|
-
// With runtime data for predicate-gated rules
|
|
41300
|
-
const ownedActions = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete'], {
|
|
41301
|
-
authorId: 'u1',
|
|
41302
|
-
});
|
|
41303
41415
|
```
|
|
41304
41416
|
|
|
41305
|
-
|
|
41306
|
-
|
|
41307
|
-
## Inspect Rule Scope with `rulesInScope`
|
|
41417
|
+
## Bound Ward (`forUser`)
|
|
41308
41418
|
|
|
41309
41419
|
```ts
|
|
41310
|
-
const rules = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts');
|
|
41311
|
-
const narrowed = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts', { authorId: 'u1' });
|
|
41312
|
-
|
|
41313
41420
|
const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
|
|
41314
|
-
const boundRules = bound.rulesInScope('posts');
|
|
41315
|
-
```
|
|
41316
|
-
|
|
41317
|
-
`rulesInScope()` is introspection-only. It returns rules in scope for the principal/resource pair and never mutates the ward.
|
|
41318
|
-
If you pass `data`, Ward also filters predicate rules by whether they match that runtime payload.
|
|
41319
|
-
|
|
41320
|
-
## Explain Denials and Winners
|
|
41321
41421
|
|
|
41322
|
-
|
|
41323
|
-
|
|
41324
|
-
|
|
41325
|
-
|
|
41326
|
-
console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
|
|
41327
|
-
// decision.rule is only present for 'explicit-deny', not 'no-matching-rule'
|
|
41328
|
-
if (decision.reason === 'explicit-deny') {
|
|
41329
|
-
console.log(decision.rule.effect); // 'deny'
|
|
41330
|
-
}
|
|
41331
|
-
}
|
|
41422
|
+
bound.explain({ resource: 'posts', action: 'read' });
|
|
41423
|
+
bound.trace({ resource: 'posts', action: 'update', data: { authorId: 'u1' } });
|
|
41424
|
+
bound.rulesInScope({ resource: 'posts' });
|
|
41425
|
+
bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
|
|
41332
41426
|
```
|
|
41333
41427
|
|
|
41334
|
-
`
|
|
41428
|
+
`forUser()` snapshots the principal. Re-bind when roles/identity change.
|
|
41335
41429
|
|
|
41336
|
-
|
|
41337
|
-
| ------------- | --------- | -------------------- | --------------------- |
|
|
41338
|
-
| Allow | `true` | — | The winning rule |
|
|
41339
|
-
| Explicit deny | `false` | `'explicit-deny'` | The winning deny rule |
|
|
41340
|
-
| No match | `false` | `'no-matching-rule'` | Not present |
|
|
41430
|
+
## Allowed Actions
|
|
41341
41431
|
|
|
41342
|
-
|
|
41343
|
-
|
|
41344
|
-
## Trace Decisions
|
|
41345
|
-
|
|
41346
|
-
`trace()` returns the complete decision trace: every rule that matched the request before the winner was selected, with per-candidate scoring details.
|
|
41432
|
+
`allowedActions()` evaluates a provided action set:
|
|
41347
41433
|
|
|
41348
41434
|
```ts
|
|
41349
|
-
const
|
|
41350
|
-
|
|
41351
|
-
|
|
41352
|
-
|
|
41435
|
+
const actions = ward.allowedActions({
|
|
41436
|
+
principal: { id: 'u1', roles: ['admin'] },
|
|
41437
|
+
resource: 'posts',
|
|
41438
|
+
knownActions: ['read', 'update', 'delete'] as const,
|
|
41353
41439
|
});
|
|
41354
41440
|
```
|
|
41355
41441
|
|
|
41356
|
-
|
|
41357
|
-
|
|
41358
|
-
`trace()` is also available on `BoundWard`: `bound.trace(resource, action, data?)`.
|
|
41359
|
-
|
|
41360
|
-
## Detect Policy Conflicts
|
|
41442
|
+
It does not fire the logger.
|
|
41361
41443
|
|
|
41362
|
-
|
|
41444
|
+
## Rule Introspection
|
|
41363
41445
|
|
|
41364
41446
|
```ts
|
|
41365
|
-
const
|
|
41366
|
-
|
|
41367
|
-
|
|
41368
|
-
if (c.kind === 'duplicate') {
|
|
41369
|
-
console.warn(`Rule[${c.indexB}] is an unreachable duplicate of Rule[${c.indexA}]`);
|
|
41370
|
-
} else {
|
|
41371
|
-
console.warn(`Rule[${c.shadowedIndex}] is shadowed by Rule[${c.shadowingIndex}]`);
|
|
41372
|
-
}
|
|
41447
|
+
const scoped = ward.rulesInScope({
|
|
41448
|
+
principal: { id: 'u1', roles: ['editor'] },
|
|
41449
|
+
resource: 'posts',
|
|
41373
41450
|
});
|
|
41374
41451
|
```
|
|
41375
41452
|
|
|
41376
|
-
|
|
41377
|
-
|
|
41378
|
-
- **`'duplicate'`** — two predicate-free rules have the same (role set, resource, action). The second (`ruleB`/`indexB`) can never fire because the first (`ruleA`/`indexA`) always wins.
|
|
41379
|
-
- **`'shadowed'`** — a higher-ranked predicate-free rule (`shadowingRule`/`shadowingIndex`) covers the other's (`shadowedRule`/`shadowedIndex`) patterns entirely. The shadowed rule can never win.
|
|
41380
|
-
|
|
41381
|
-
Rules with a `when` predicate are excluded from both checks because their applicability is determined at runtime, not statically.
|
|
41453
|
+
Use optional `data` to filter predicate-gated matches.
|
|
41382
41454
|
|
|
41383
|
-
|
|
41455
|
+
## Trace Candidates
|
|
41384
41456
|
|
|
41385
41457
|
```ts
|
|
41386
|
-
|
|
41387
|
-
|
|
41388
|
-
|
|
41389
|
-
|
|
41458
|
+
const trace = ward.trace({
|
|
41459
|
+
principal: { id: 'u1', roles: ['editor', 'blocked'] },
|
|
41460
|
+
resource: 'posts',
|
|
41461
|
+
action: 'read',
|
|
41390
41462
|
});
|
|
41391
41463
|
|
|
41392
|
-
|
|
41393
|
-
|
|
41394
|
-
|
|
41395
|
-
// Cap O(n²) cost for large auto-generated policies
|
|
41396
|
-
const ward = createWard(rules, { maxConflicts: 20 });
|
|
41397
|
-
```
|
|
41398
|
-
|
|
41399
|
-
## Use Dynamic Conditions with `when`
|
|
41400
|
-
|
|
41401
|
-
```ts
|
|
41402
|
-
const ward = createWard([
|
|
41403
|
-
{
|
|
41404
|
-
role: 'editor',
|
|
41405
|
-
resource: 'posts',
|
|
41406
|
-
action: 'update',
|
|
41407
|
-
effect: 'allow',
|
|
41408
|
-
when: ({ principal, data }) => principal.id === data?.authorId,
|
|
41409
|
-
},
|
|
41410
|
-
]);
|
|
41411
|
-
```
|
|
41412
|
-
|
|
41413
|
-
`when` only runs for authenticated principals. For anonymous (`null`) checks, predicates are skipped and the rule does not match. Do not pair `owns()` or any `when` predicate with an `ANONYMOUS`-role rule — it can never match.
|
|
41414
|
-
|
|
41415
|
-
### Ownership Checks with `owns`
|
|
41416
|
-
|
|
41417
|
-
```ts
|
|
41418
|
-
import { createWard, owns } from '@vielzeug/ward';
|
|
41419
|
-
|
|
41420
|
-
const ward = createWard([
|
|
41421
|
-
{
|
|
41422
|
-
role: 'editor',
|
|
41423
|
-
resource: 'posts',
|
|
41424
|
-
action: 'update',
|
|
41425
|
-
effect: 'allow',
|
|
41426
|
-
when: owns('authorId'),
|
|
41427
|
-
},
|
|
41428
|
-
]);
|
|
41429
|
-
```
|
|
41430
|
-
|
|
41431
|
-
`owns()` is a convenience helper for the common `principal.id === data[attributeKey]` pattern.
|
|
41432
|
-
|
|
41433
|
-
### Attribute-Based Conditions (ABAC)
|
|
41434
|
-
|
|
41435
|
-
```ts
|
|
41436
|
-
const ward = createWard([
|
|
41437
|
-
{
|
|
41438
|
-
role: 'editor',
|
|
41439
|
-
resource: 'posts',
|
|
41440
|
-
action: 'publish',
|
|
41441
|
-
effect: 'allow',
|
|
41442
|
-
when: ({ principal }) => principal.attributes?.tier === 'pro',
|
|
41443
|
-
},
|
|
41444
|
-
]);
|
|
41445
|
-
```
|
|
41446
|
-
|
|
41447
|
-
`principal.attributes` can store arbitrary user metadata for runtime policy checks.
|
|
41448
|
-
|
|
41449
|
-
## Multi-Role Rules
|
|
41450
|
-
|
|
41451
|
-
The `role` field accepts either a single string or an array of strings. A rule matches if the principal holds **any** of the listed roles (OR semantics).
|
|
41452
|
-
|
|
41453
|
-
Multi-role rules reduce repetition when several roles share identical permissions:
|
|
41454
|
-
|
|
41455
|
-
```ts
|
|
41456
|
-
import { createWard } from '@vielzeug/ward';
|
|
41457
|
-
|
|
41458
|
-
const ward = createWard([
|
|
41459
|
-
// One rule instead of three separate allow rules
|
|
41460
|
-
{ role: ['viewer', 'editor', 'admin'], resource: 'posts', action: 'read', effect: 'allow' },
|
|
41461
|
-
{ role: ['editor', 'admin'], resource: 'posts', action: 'update', effect: 'allow' },
|
|
41462
|
-
{ role: 'admin', resource: 'posts', action: 'delete', effect: 'allow' },
|
|
41463
|
-
]);
|
|
41464
|
-
|
|
41465
|
-
ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true
|
|
41466
|
-
ward.explain({ id: 'u2', roles: ['editor'] }, 'posts', 'update').allowed; // true
|
|
41467
|
-
ward.explain({ id: 'u2', roles: ['editor'] }, 'posts', 'delete').allowed; // false
|
|
41468
|
-
```
|
|
41469
|
-
|
|
41470
|
-
`ANONYMOUS` works inside multi-role arrays. The rule matches both unauthenticated visitors and any authenticated role listed alongside it:
|
|
41471
|
-
|
|
41472
|
-
```ts
|
|
41473
|
-
import { ANONYMOUS, createWard } from '@vielzeug/ward';
|
|
41474
|
-
|
|
41475
|
-
const ward = createWard([{ role: [ANONYMOUS, 'viewer'], resource: 'posts', action: 'read', effect: 'allow' }]);
|
|
41476
|
-
|
|
41477
|
-
ward.explain(null, 'posts', 'read').allowed; // true (anonymous)
|
|
41478
|
-
ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true (viewer)
|
|
41479
|
-
ward.explain({ id: 'u2', roles: ['admin'] }, 'posts', 'read').allowed; // false (not in list)
|
|
41480
|
-
```
|
|
41481
|
-
|
|
41482
|
-
## Anonymous and Wildcards
|
|
41483
|
-
|
|
41484
|
-
```ts
|
|
41485
|
-
import { ANONYMOUS, WILDCARD } from '@vielzeug/ward';
|
|
41486
|
-
|
|
41487
|
-
const ward = createWard([
|
|
41488
|
-
{ role: ANONYMOUS, resource: 'posts', action: 'read', effect: 'allow' },
|
|
41489
|
-
{ role: WILDCARD, resource: 'status', action: 'read', effect: 'allow' },
|
|
41490
|
-
]);
|
|
41491
|
-
```
|
|
41492
|
-
|
|
41493
|
-
Use `ANONYMOUS` for anonymous-only rules and `WILDCARD` for any role/resource/action.
|
|
41494
|
-
|
|
41495
|
-
## Logger and Auditing
|
|
41496
|
-
|
|
41497
|
-
```ts
|
|
41498
|
-
const ward = createWard([{ role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' }], {
|
|
41499
|
-
logger: (ctx) => {
|
|
41500
|
-
const subject = ctx.principal === null ? 'anonymous' : ctx.principal.id;
|
|
41501
|
-
const outcome = ctx.allowed ? 'allow' : ctx.reason;
|
|
41502
|
-
console.log(subject, ctx.resource, ctx.action, outcome);
|
|
41503
|
-
},
|
|
41464
|
+
trace.candidates.forEach((c) => {
|
|
41465
|
+
console.log(c.index, c.priority, c.score, c.won);
|
|
41504
41466
|
});
|
|
41505
41467
|
```
|
|
41506
41468
|
|
|
41507
|
-
|
|
41508
|
-
Enumeration and introspection helpers (`allowedActions()`, `rulesInScope()`, `detectConflicts()`) stay side-effect free.
|
|
41469
|
+
`trace()` does not fire the logger.
|
|
41509
41470
|
|
|
41510
|
-
|
|
41471
|
+
## Predicate Helpers
|
|
41511
41472
|
|
|
41512
41473
|
```ts
|
|
41513
|
-
|
|
41514
|
-
if (ctx.allowed || ctx.reason === 'explicit-deny') {
|
|
41515
|
-
console.log(ctx.rule.role); // no ?. needed — rule is present
|
|
41516
|
-
}
|
|
41517
|
-
},
|
|
41518
|
-
```
|
|
41519
|
-
|
|
41520
|
-
- `allowed: true` — a matching allow rule won
|
|
41521
|
-
- `allowed: false, reason: 'explicit-deny'` — a matching deny rule won
|
|
41522
|
-
- `allowed: false, reason: 'no-matching-rule'` — no rule matched at all (default deny)
|
|
41523
|
-
|
|
41524
|
-
This lets you distinguish explicit blocks from gaps in your policy in audit logs and metrics.
|
|
41525
|
-
|
|
41526
|
-
## Decision Precedence
|
|
41527
|
-
|
|
41528
|
-
Ward uses one deterministic model:
|
|
41529
|
-
|
|
41530
|
-
1. If no rule matches, decision is deny.
|
|
41531
|
-
2. Higher `priority` wins.
|
|
41532
|
-
3. For equal `priority`, higher specificity wins — `exact > namespace-wildcard (ns:*) > global-wildcard (*)`, applied independently to role, resource, and action.
|
|
41533
|
-
4. For equal `priority` and specificity, deny overrides allow.
|
|
41534
|
-
5. On absolute tie (identical priority, specificity, and effect), the rule declared **first in the array** wins.
|
|
41535
|
-
|
|
41536
|
-
## Exact Matching
|
|
41537
|
-
|
|
41538
|
-
Ward uses exact string matching for role/resource/action.
|
|
41539
|
-
|
|
41540
|
-
```ts
|
|
41541
|
-
const ward = createWard([{ role: 'admin', resource: 'posts', action: 'read', effect: 'allow' }]);
|
|
41542
|
-
|
|
41543
|
-
ward.explain({ id: 'u1', roles: ['admin'] }, 'posts', 'read').allowed; // true
|
|
41544
|
-
ward.explain({ id: 'u1', roles: ['ADMIN'] }, 'posts', 'read').allowed; // false
|
|
41545
|
-
```
|
|
41546
|
-
|
|
41547
|
-
Adopt one identifier convention (for example all lowercase) at your app boundary.
|
|
41548
|
-
|
|
41549
|
-
## Framework Integration
|
|
41550
|
-
|
|
41551
|
-
```tsx [React]
|
|
41552
|
-
import { createContext, useContext, type ReactNode } from 'react';
|
|
41553
|
-
import { createWard } from '@vielzeug/ward';
|
|
41554
|
-
|
|
41555
|
-
type User = { id: string; roles: string[] };
|
|
41556
|
-
|
|
41557
|
-
const ward = createWard([
|
|
41558
|
-
{ role: 'admin', resource: '*', action: '*', effect: 'allow' },
|
|
41559
|
-
{ role: 'editor', resource: 'posts', action: 'write', effect: 'allow' },
|
|
41560
|
-
]);
|
|
41561
|
-
|
|
41562
|
-
const UserContext = createContext(null);
|
|
41563
|
-
|
|
41564
|
-
function useWard(resource: string, action: string) {
|
|
41565
|
-
const user = useContext(UserContext);
|
|
41566
|
-
if (!user) return false;
|
|
41567
|
-
return ward.explain(user, resource, action).allowed;
|
|
41568
|
-
}
|
|
41569
|
-
|
|
41570
|
-
function EditButton({ postId }: { postId: string }) {
|
|
41571
|
-
const canEdit = useWard('posts', 'write');
|
|
41572
|
-
if (!canEdit) return null;
|
|
41573
|
-
return Edit {postId};
|
|
41574
|
-
}
|
|
41575
|
-
```
|
|
41576
|
-
|
|
41577
|
-
```ts [Vue 3]
|
|
41578
|
-
import { computed } from 'vue';
|
|
41579
|
-
import { createWard } from '@vielzeug/ward';
|
|
41580
|
-
|
|
41581
|
-
type User = { id: string; roles: string[] };
|
|
41582
|
-
|
|
41583
|
-
const ward = createWard([
|
|
41584
|
-
{ role: 'admin', resource: '*', action: '*', effect: 'allow' },
|
|
41585
|
-
{ role: 'editor', resource: 'posts', action: 'write', effect: 'allow' },
|
|
41586
|
-
]);
|
|
41587
|
-
|
|
41588
|
-
function useWard(user: { value: User | null }, resource: string, action: string) {
|
|
41589
|
-
return computed(() => (user.value ? ward.explain(user.value, resource, action).allowed : false));
|
|
41590
|
-
}
|
|
41591
|
-
```
|
|
41592
|
-
|
|
41593
|
-
```svelte [Svelte]
|
|
41594
|
-
|
|
41595
|
-
import { createWard } from '@vielzeug/ward';
|
|
41596
|
-
|
|
41597
|
-
type User = { id: string; roles: string[] };
|
|
41598
|
-
|
|
41599
|
-
export let user: User;
|
|
41600
|
-
|
|
41601
|
-
const ward = createWard([
|
|
41602
|
-
{ role: 'admin', resource: '*', action: '*', effect: 'allow' },
|
|
41603
|
-
{ role: 'editor', resource: 'posts', action: 'write', effect: 'allow' },
|
|
41604
|
-
]);
|
|
41605
|
-
|
|
41606
|
-
$: canEdit = ward.explain(user, 'posts', 'write').allowed;
|
|
41474
|
+
import { owns, predicate } from '@vielzeug/ward';
|
|
41607
41475
|
|
|
41608
|
-
|
|
41476
|
+
const isOwner = owns('authorId');
|
|
41477
|
+
const canEdit = predicate.and(isOwner, ({ principal }) => principal !== null);
|
|
41609
41478
|
```
|
|
41610
41479
|
|
|
41611
|
-
|
|
41612
|
-
|
|
41613
|
-
- **React:** If the ward is created inside a component that re-renders often, `createWard()` runs on every render. Memoize with `useMemo(() => createWard(...), [role])`, or define it once at module scope as in the example above.
|
|
41614
|
-
- **Vue 3:** Injecting `ward` as a plain value (not a `ComputedRef`) means role changes don't propagate to child components. Always inject as a reactive ref.
|
|
41615
|
-
- **Svelte:** `setContext` must be called synchronously during component initialization. Calling it inside a reactive statement (`$:`) works only for setting the initial value — child components reading the context must use `getContext` in their own `` block.
|
|
41616
|
-
|
|
41617
|
-
## Middleware Integration
|
|
41480
|
+
Async predicates are rejected at runtime with `WardPredicateError`.
|
|
41618
41481
|
|
|
41619
|
-
|
|
41482
|
+
## Framework Guards
|
|
41620
41483
|
|
|
41621
41484
|
```ts
|
|
41622
41485
|
import { guardRequest, guardRequestWith } from '@vielzeug/ward';
|
|
41623
41486
|
|
|
41624
|
-
|
|
41625
|
-
|
|
41626
|
-
|
|
41627
|
-
|
|
41628
|
-
|
|
41629
|
-
|
|
41630
|
-
if (!result.granted) {
|
|
41631
|
-
return new Response(JSON.stringify({ reason: result.reason }), { status: 403 });
|
|
41632
|
-
}
|
|
41633
|
-
```
|
|
41634
|
-
|
|
41635
|
-
### Express / Connect
|
|
41636
|
-
|
|
41637
|
-
```ts
|
|
41638
|
-
app.use('/posts', async (req, res, next) => {
|
|
41639
|
-
const result = await guardRequestWith(ward, req, (r) => r.user ?? null, 'posts:*', 'update');
|
|
41640
|
-
result.granted ? next() : res.status(403).json({ reason: result.reason });
|
|
41641
|
-
});
|
|
41642
|
-
```
|
|
41643
|
-
|
|
41644
|
-
### Hono
|
|
41645
|
-
|
|
41646
|
-
```ts
|
|
41647
|
-
app.put('/posts/:id', async (c, next) => {
|
|
41648
|
-
const result = guardRequest(ward, c.get('user') ?? null, `posts:${c.req.param('id')}`, 'update');
|
|
41649
|
-
return result.granted ? next() : c.json({ reason: result.reason }, 403);
|
|
41487
|
+
const direct = guardRequest({
|
|
41488
|
+
ward,
|
|
41489
|
+
principal: { id: 'u1', roles: ['viewer'] },
|
|
41490
|
+
resource: 'posts',
|
|
41491
|
+
action: 'read',
|
|
41650
41492
|
});
|
|
41651
|
-
```
|
|
41652
|
-
|
|
41653
|
-
## Debug Mode
|
|
41654
41493
|
|
|
41655
|
-
|
|
41656
|
-
|
|
41657
|
-
|
|
41658
|
-
|
|
41659
|
-
|
|
41660
|
-
|
|
41661
|
-
{ role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
|
|
41662
|
-
{ role: 'editor', resource: 'posts', action: 'update', effect: 'allow' },
|
|
41663
|
-
]);
|
|
41664
|
-
|
|
41665
|
-
permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read');
|
|
41666
|
-
// [ward:decision] allow (allow) viewer posts read
|
|
41667
|
-
|
|
41668
|
-
permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'update');
|
|
41669
|
-
// [ward:decision] no-matching-rule viewer posts update
|
|
41670
|
-
|
|
41671
|
-
permit.explain(null, 'posts', 'read');
|
|
41672
|
-
// [ward:decision] no-matching-rule anonymous posts read
|
|
41673
|
-
```
|
|
41674
|
-
|
|
41675
|
-
The ward returned is identical to `createWard()` — all methods (`explain`, `checkAll`, `forUser`, etc.) work the same way.
|
|
41676
|
-
|
|
41677
|
-
Alternatively, pass a custom `logger` directly to `createWard()` to route decisions to a structured logger:
|
|
41678
|
-
|
|
41679
|
-
```ts
|
|
41680
|
-
const permit = createWard(rules, {
|
|
41681
|
-
logger: (ctx) => myLogger.debug('access decision', ctx),
|
|
41494
|
+
const extracted = await guardRequestWith({
|
|
41495
|
+
ward,
|
|
41496
|
+
req,
|
|
41497
|
+
extractPrincipal: async (request) => request.user ?? null,
|
|
41498
|
+
resource: 'posts',
|
|
41499
|
+
action: 'read',
|
|
41682
41500
|
});
|
|
41683
41501
|
```
|
|
41684
41502
|
|
|
41685
|
-
Debug logging fires on `explain()` and `checkAll()` (including through a `BoundWard`). It does **not** fire on `trace()`, or on the side-effect-free helpers `allowedActions()`, `rulesInScope()`, and `detectConflicts()`.
|
|
41686
|
-
|
|
41687
|
-
## Working with Other Vielzeug Libraries
|
|
41688
|
-
|
|
41689
|
-
### With Wayfinder
|
|
41690
|
-
|
|
41691
|
-
Use ward guards inside Wayfinder middleware to protect routes.
|
|
41692
|
-
|
|
41693
|
-
```ts
|
|
41694
|
-
import { createWard } from '@vielzeug/ward';
|
|
41695
|
-
import { createRouter } from '@vielzeug/wayfinder';
|
|
41696
|
-
|
|
41697
|
-
type User = { id: string; roles: string[] };
|
|
41698
|
-
|
|
41699
|
-
const ward = createWard([{ role: 'admin', resource: 'settings', action: 'read', effect: 'allow' }]);
|
|
41700
|
-
|
|
41701
|
-
const router = createRouter({
|
|
41702
|
-
routes: {
|
|
41703
|
-
settings: {
|
|
41704
|
-
path: '/settings',
|
|
41705
|
-
handler: ({ data }) => renderSettings(data),
|
|
41706
|
-
},
|
|
41707
|
-
},
|
|
41708
|
-
middleware: [
|
|
41709
|
-
(ctx, next) => {
|
|
41710
|
-
const user: User = getSessionUser(); // your auth provider
|
|
41711
|
-
if (!ward.explain(user, 'settings', 'read').allowed) {
|
|
41712
|
-
return ctx.navigate({ path: '/login' });
|
|
41713
|
-
}
|
|
41714
|
-
return next();
|
|
41715
|
-
},
|
|
41716
|
-
],
|
|
41717
|
-
});
|
|
41718
|
-
```
|
|
41719
|
-
|
|
41720
|
-
### With Rune
|
|
41721
|
-
|
|
41722
|
-
Use ward's `logger` option to audit every access decision.
|
|
41723
|
-
|
|
41724
|
-
```ts
|
|
41725
|
-
import { createWard } from '@vielzeug/ward';
|
|
41726
|
-
import { createLogger } from '@vielzeug/rune';
|
|
41727
|
-
|
|
41728
|
-
const log = createLogger({ namespace: 'ward' });
|
|
41729
|
-
|
|
41730
|
-
const ward = createWard(
|
|
41731
|
-
[
|
|
41732
|
-
/* rules */
|
|
41733
|
-
],
|
|
41734
|
-
{
|
|
41735
|
-
logger: (decision) => log.info('access decision', decision),
|
|
41736
|
-
},
|
|
41737
|
-
);
|
|
41738
|
-
```
|
|
41739
|
-
|
|
41740
|
-
## Best Practices
|
|
41741
|
-
|
|
41742
|
-
- Keep roles and resources explicit and predictable.
|
|
41743
|
-
- Use `priority` sparingly for explicit overrides.
|
|
41744
|
-
- Keep `when` predicates pure and side-effect free.
|
|
41745
|
-
- Prefer one ward instance per app boundary and keep rules centralized.
|
|
41746
|
-
- Use `forUser({ ... })` for repeated checks in UI or request scopes.
|
|
41747
|
-
|
|
41748
41503
|
### Examples
|
|
41749
41504
|
|
|
41750
41505
|
## Examples
|