@vielzeug/codex 1.0.2 → 1.0.3
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 +31 -31
- package/data/llms-full.txt +709 -1485
- package/data/vielzeug-data.json +4597 -3152
- package/package.json +3 -3
package/data/llms-full.txt
CHANGED
|
@@ -1687,6 +1687,7 @@ type InterpretOptions = {
|
|
|
1687
1687
|
onDebug?: (event: DebugEvent) => void;
|
|
1688
1688
|
persistence?: PersistenceAdapter;
|
|
1689
1689
|
snapshot?: MachineSnapshot;
|
|
1690
|
+
validateHydratedContext?: boolean;
|
|
1690
1691
|
traceLimit?: number;
|
|
1691
1692
|
};
|
|
1692
1693
|
```
|
|
@@ -1699,6 +1700,7 @@ type InterpretOptions = {
|
|
|
1699
1700
|
| `onDebug` | `undefined` | Callback for all debug events (guards, transitions, invokes, skips). Auto-enables a 50-entry trace buffer unless `traceLimit` is set. |
|
|
1700
1701
|
| `persistence` | `undefined` | Save/load adapter for snapshot persistence |
|
|
1701
1702
|
| `snapshot` | `undefined` | Snapshot to hydrate from on startup (takes priority over persistence) |
|
|
1703
|
+
| `validateHydratedContext`| `false` | When `true`, runs `validateContext` against hydrated context during startup; when `false`, hydrated context is trusted and only validated on transitions. |
|
|
1702
1704
|
| `traceLimit` | auto (`50`/`0`) | Ring buffer capacity for `getTrace()`. Defaults to `50` when `onDebug` is set; `0` (disabled) otherwise. Set explicitly to override. |
|
|
1703
1705
|
|
|
1704
1706
|
---
|
|
@@ -1826,7 +1828,7 @@ interface MachineInstance {
|
|
|
1826
1828
|
| `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
1829
|
| `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
1830
|
| `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. |
|
|
1831
|
+
| `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
1832
|
| `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations. |
|
|
1831
1833
|
|
|
1832
1834
|
---
|
|
@@ -1878,7 +1880,7 @@ type PersistenceAdapter = {
|
|
|
1878
1880
|
};
|
|
1879
1881
|
```
|
|
1880
1882
|
|
|
1881
|
-
`save` is called after every committed transition. `load` is called once during startup if no `snapshot` option is provided.
|
|
1883
|
+
`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
1884
|
|
|
1883
1885
|
---
|
|
1884
1886
|
|
|
@@ -2472,7 +2474,9 @@ On startup, `createMachine().start()` checks `options.snapshot` first, then `per
|
|
|
2472
2474
|
|
|
2473
2475
|
`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
2476
|
|
|
2475
|
-
|
|
2477
|
+
Hydrated context is trusted by default for backward compatibility. To enforce `validateContext` during hydration, pass `validateHydratedContext: true` to `.start(...)`.
|
|
2478
|
+
|
|
2479
|
+
If context is loaded from untrusted sources (e.g. `localStorage`), enable `validateHydratedContext` or validate inside `persistence.load()` before returning the snapshot.
|
|
2476
2480
|
|
|
2477
2481
|
## Interceptors
|
|
2478
2482
|
|
|
@@ -2547,6 +2551,7 @@ unsub(); // stop listening
|
|
|
2547
2551
|
```
|
|
2548
2552
|
|
|
2549
2553
|
The callback fires only when `state` or `context` reference changes — not on every signal read.
|
|
2554
|
+
Each callback receives an isolated snapshot object; mutating the callback payload does not mutate machine state.
|
|
2550
2555
|
|
|
2551
2556
|
## Debugging and Tracing
|
|
2552
2557
|
|
|
@@ -7052,7 +7057,7 @@ Creates a query client with caching, deduplication, prefix invalidation, and rea
|
|
|
7052
7057
|
| `getState` | `(key) => QueryState \| null` | Full state snapshot |
|
|
7053
7058
|
| `observeMany` | `(keys: QueryKey[]) => SyncStore[]>` | Observe multiple keys as one combined store; updates on any key change |
|
|
7054
7059
|
| `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
|
|
7060
|
+
| `remove` | `(key: QueryKey) => void` | Evict a single entry; aborts any in-flight fetch; resets observers to `'loading'` if active |
|
|
7056
7061
|
| `cancel` | `(key) => void` | Cancel an in-flight fetch; entry returns to `'loading'` or retains prior success data |
|
|
7057
7062
|
| `clear` | `() => void` | Clear all entries; active subscribers see `'loading'` |
|
|
7058
7063
|
| `refetchStale` | `() => void` | Manually revalidate all stale observed entries |
|
|
@@ -7113,7 +7118,7 @@ Creates a standalone, observable mutation handle.
|
|
|
7113
7118
|
| `peek` | `() => MutationState` | Read current state snapshot |
|
|
7114
7119
|
| `subscribe` | `(cb: () => void) => () => void` | Subscribe to state changes; returns unsubscribe fn |
|
|
7115
7120
|
| `store` | `SyncStore>` (property) | Framework-friendly external store; stable reference |
|
|
7116
|
-
| `reset` | `() => void` | Reset back to the
|
|
7121
|
+
| `reset` | `() => void` | Reset back to the `'loading'` baseline state |
|
|
7117
7122
|
| `dispose` | `() => void` | Abort active run, clear observers, and mark as disposed |
|
|
7118
7123
|
| `disposed` | `boolean` (getter) | Whether `dispose()` has been called |
|
|
7119
7124
|
| `[Symbol.dispose]` | — | Delegates to `dispose()`; enables `using` declarations |
|
|
@@ -8285,7 +8290,7 @@ const state = qc.getState(['users', 1]);
|
|
|
8285
8290
|
```ts
|
|
8286
8291
|
const store = qc.watchKey(['users', 1]);
|
|
8287
8292
|
|
|
8288
|
-
const initial = store.peek(); //
|
|
8293
|
+
const initial = store.peek(); // loading baseline if not yet fetched
|
|
8289
8294
|
const stop = store.subscribe(() => {
|
|
8290
8295
|
console.log(store.peek());
|
|
8291
8296
|
});
|
|
@@ -8324,7 +8329,7 @@ const store = qc.observe({
|
|
|
8324
8329
|
});
|
|
8325
8330
|
|
|
8326
8331
|
// Synchronously read the current state
|
|
8327
|
-
console.log(store.peek().status); // '
|
|
8332
|
+
console.log(store.peek().status); // 'loading' (or 'success' / 'error' if already cached)
|
|
8328
8333
|
console.log(store.peek().data); // placeholderData while fetching
|
|
8329
8334
|
|
|
8330
8335
|
// Subscribe to future changes
|
|
@@ -8368,7 +8373,7 @@ function useUser(id: number) {
|
|
|
8368
8373
|
For entries **without active subscribers**, invalidation evicts the cache entry immediately. For entries **with active subscribers**:
|
|
8369
8374
|
|
|
8370
8375
|
- 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 `
|
|
8376
|
+
- If the entry was only populated via `set()`, it resets to `'loading'`.
|
|
8372
8377
|
|
|
8373
8378
|
Supports **prefix matching**: invalidating `['users']` affects `['users', 1]`, `['users', 2]`, and so on.
|
|
8374
8379
|
|
|
@@ -8379,7 +8384,7 @@ qc.invalidate(['users']);
|
|
|
8379
8384
|
|
|
8380
8385
|
### `cancel(key)`
|
|
8381
8386
|
|
|
8382
|
-
Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'
|
|
8387
|
+
Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'loading'`.
|
|
8383
8388
|
|
|
8384
8389
|
```ts
|
|
8385
8390
|
qc.cancel(['users', 1]);
|
|
@@ -8387,7 +8392,7 @@ qc.cancel(['users', 1]);
|
|
|
8387
8392
|
|
|
8388
8393
|
### `clear()`
|
|
8389
8394
|
|
|
8390
|
-
Clears every cache entry. Active subscribers are notified with
|
|
8395
|
+
Clears every cache entry. Active subscribers are notified with a `'loading'` state.
|
|
8391
8396
|
|
|
8392
8397
|
```ts
|
|
8393
8398
|
qc.clear();
|
|
@@ -8968,7 +8973,7 @@ effect(() => console.log('user:', userStore.value.user?.name));
|
|
|
8968
8973
|
|
|
8969
8974
|
**Category:** ui-interaction
|
|
8970
8975
|
**Keywords:** drag-drop, sortable, file-upload, drop-zone, dnd, reorder
|
|
8971
|
-
**Key exports:** createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept
|
|
8976
|
+
**Key exports:** createDropZone, createSortable, createSortableScope, createTouchDragShim, applyReorder, matchesAccept
|
|
8972
8977
|
**Related:** ore, scroll, refine
|
|
8973
8978
|
|
|
8974
8979
|
### Overview
|
|
@@ -9016,6 +9021,7 @@ const zone = createDropZone({
|
|
|
9016
9021
|
| Sortable lists | | | |
|
|
9017
9022
|
| Drag handles | | | |
|
|
9018
9023
|
| `using` support | | | |
|
|
9024
|
+
| Touch support | Opt-in shim | | |
|
|
9019
9025
|
| Zero dependencies | | | |
|
|
9020
9026
|
|
|
9021
9027
|
**Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.
|
|
@@ -9088,6 +9094,7 @@ using sortable = createSortable({
|
|
|
9088
9094
|
- **`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
9095
|
- **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally
|
|
9090
9096
|
- **Explicit connected scopes** — lists only exchange items when they share a `createSortableScope()` instance
|
|
9097
|
+
- **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
9098
|
- **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers
|
|
9092
9099
|
- **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup
|
|
9093
9100
|
- **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 +9121,7 @@ using sortable = createSortable({
|
|
|
9114
9121
|
| `createDropZone()` | Create a typed drop-zone controller | Sync | Remember to destroy the controller during teardown |
|
|
9115
9122
|
| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |
|
|
9116
9123
|
| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |
|
|
9124
|
+
| `createTouchDragShim()` | Bridge touch gestures to synthetic DragEvents | Sync | Create once per app — it's a single `document`-level listener set |
|
|
9117
9125
|
| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |
|
|
9118
9126
|
| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |
|
|
9119
9127
|
| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |
|
|
@@ -9217,6 +9225,15 @@ declare function createSortableScope(): SortableScope;
|
|
|
9217
9225
|
|
|
9218
9226
|
Creates an explicit connection scope for multi-container sorting. Containers only exchange items when they share the same scope instance.
|
|
9219
9227
|
|
|
9228
|
+
### `TouchDragOptions`
|
|
9229
|
+
|
|
9230
|
+
```ts
|
|
9231
|
+
interface TouchDragOptions {
|
|
9232
|
+
disabled?: boolean;
|
|
9233
|
+
draggableSelector?: string;
|
|
9234
|
+
}
|
|
9235
|
+
```
|
|
9236
|
+
|
|
9220
9237
|
## `createDropZone()`
|
|
9221
9238
|
|
|
9222
9239
|
```ts
|
|
@@ -9323,7 +9340,7 @@ declare function createSortable(options: SortableOptions): Sortable;
|
|
|
9323
9340
|
|
|
9324
9341
|
Makes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.
|
|
9325
9342
|
|
|
9326
|
-
`createSortable` sets `draggable="true"
|
|
9343
|
+
`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
9344
|
|
|
9328
9345
|
- `element`: `HTMLElement`, required. The container whose children become sortable.
|
|
9329
9346
|
- `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 +9466,7 @@ Dnd reads and writes the following DOM attributes:
|
|
|
9449
9466
|
- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.
|
|
9450
9467
|
- `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
9468
|
- `aria-hidden="true"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.
|
|
9469
|
+
- `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
9470
|
|
|
9453
9471
|
## CSS Classes
|
|
9454
9472
|
|
|
@@ -9456,6 +9474,34 @@ Dnd reads and writes the following DOM attributes:
|
|
|
9456
9474
|
| ----------------- | ---------------------------- | ------------------------------------------------------------- |
|
|
9457
9475
|
| `dnd-placeholder` | `` inserted by sortable | While an item is being dragged, in the placeholder's position |
|
|
9458
9476
|
|
|
9477
|
+
## `createTouchDragShim()`
|
|
9478
|
+
|
|
9479
|
+
```ts
|
|
9480
|
+
declare function createTouchDragShim(options?: TouchDragOptions): Disposable;
|
|
9481
|
+
```
|
|
9482
|
+
|
|
9483
|
+
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.
|
|
9484
|
+
|
|
9485
|
+
| Option | Type | Default | Description |
|
|
9486
|
+
| -------------------- | --------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
|
9487
|
+
| `disabled` | `boolean` | — | When `true`, touch gestures are ignored. Read live off the same options object on each `touchstart`, like `SortableOptions.disabled`. |
|
|
9488
|
+
| `draggableSelector` | `string` | `'[draggable="true"]'` | CSS selector identifying draggable elements under the touch point. The default matches what `createSortable`/`createDropZone` already set. |
|
|
9489
|
+
| `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. |
|
|
9490
|
+
|
|
9491
|
+
**Returns:** `Disposable`
|
|
9492
|
+
|
|
9493
|
+
Notes:
|
|
9494
|
+
|
|
9495
|
+
- Listens at the `document` level — create one instance per app, not one per sortable/drop-zone.
|
|
9496
|
+
- 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.
|
|
9497
|
+
- 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.
|
|
9498
|
+
|
|
9499
|
+
```ts
|
|
9500
|
+
import { createTouchDragShim } from '@vielzeug/dnd';
|
|
9501
|
+
|
|
9502
|
+
using touchDrag = createTouchDragShim();
|
|
9503
|
+
```
|
|
9504
|
+
|
|
9459
9505
|
## `matchesAccept()`
|
|
9460
9506
|
|
|
9461
9507
|
```ts
|
|
@@ -9868,6 +9914,61 @@ try {
|
|
|
9868
9914
|
}
|
|
9869
9915
|
```
|
|
9870
9916
|
|
|
9917
|
+
## Touch Support
|
|
9918
|
+
|
|
9919
|
+
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.
|
|
9920
|
+
|
|
9921
|
+
```ts
|
|
9922
|
+
import { createTouchDragShim } from '@vielzeug/dnd';
|
|
9923
|
+
|
|
9924
|
+
// Call once at app startup — one instance covers the whole page.
|
|
9925
|
+
using touchDrag = createTouchDragShim();
|
|
9926
|
+
```
|
|
9927
|
+
|
|
9928
|
+
### Custom draggable selector
|
|
9929
|
+
|
|
9930
|
+
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.
|
|
9931
|
+
|
|
9932
|
+
```ts
|
|
9933
|
+
createTouchDragShim({ draggableSelector: '.my-drag-handle' });
|
|
9934
|
+
```
|
|
9935
|
+
|
|
9936
|
+
### Drag preview
|
|
9937
|
+
|
|
9938
|
+
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.
|
|
9939
|
+
|
|
9940
|
+
```ts
|
|
9941
|
+
// Opt out to render fully custom feedback instead (e.g. toggling a class from your own
|
|
9942
|
+
// dragstart/dragend listeners):
|
|
9943
|
+
createTouchDragShim({ showDragPreview: false });
|
|
9944
|
+
```
|
|
9945
|
+
|
|
9946
|
+
Note the preview only clones light-DOM content — an item whose visible content lives inside a shadow root will preview as an empty shell.
|
|
9947
|
+
|
|
9948
|
+
### Why draggable items get `touch-action: none`
|
|
9949
|
+
|
|
9950
|
+
`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.
|
|
9951
|
+
|
|
9952
|
+
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`.
|
|
9953
|
+
|
|
9954
|
+
### Disabled state
|
|
9955
|
+
|
|
9956
|
+
```ts
|
|
9957
|
+
const options = { disabled: false };
|
|
9958
|
+
const touchDrag = createTouchDragShim(options);
|
|
9959
|
+
|
|
9960
|
+
// options.disabled is read live on each touch event — mutate to toggle:
|
|
9961
|
+
options.disabled = true;
|
|
9962
|
+
```
|
|
9963
|
+
|
|
9964
|
+
### Cleanup
|
|
9965
|
+
|
|
9966
|
+
```ts
|
|
9967
|
+
touchDrag.dispose();
|
|
9968
|
+
// or:
|
|
9969
|
+
using touchDrag = createTouchDragShim();
|
|
9970
|
+
```
|
|
9971
|
+
|
|
9871
9972
|
## Framework Integration
|
|
9872
9973
|
|
|
9873
9974
|
```tsx [React]
|
|
@@ -9957,19 +10058,22 @@ Use Dnd in custom web components by attaching behavior in component lifecycle ho
|
|
|
9957
10058
|
|
|
9958
10059
|
```ts
|
|
9959
10060
|
import { createSortable } from '@vielzeug/dnd';
|
|
9960
|
-
import { define,
|
|
10061
|
+
import { define, getHost, html, onMounted } from '@vielzeug/ore';
|
|
9961
10062
|
|
|
9962
10063
|
define('task-list', {
|
|
9963
|
-
setup(_props
|
|
10064
|
+
setup(_props) {
|
|
10065
|
+
const el = getHost();
|
|
10066
|
+
|
|
9964
10067
|
onMounted(() => {
|
|
9965
10068
|
const sortable = createSortable({
|
|
9966
|
-
element:
|
|
10069
|
+
element: el,
|
|
9967
10070
|
getKey: (el) => el.dataset.sortId!,
|
|
9968
10071
|
onReorder: ({ ids }) => save(ids),
|
|
9969
10072
|
});
|
|
9970
10073
|
return () => sortable.dispose();
|
|
9971
10074
|
});
|
|
9972
|
-
|
|
10075
|
+
|
|
10076
|
+
return html``;
|
|
9973
10077
|
},
|
|
9974
10078
|
});
|
|
9975
10079
|
```
|
|
@@ -9983,12 +10087,14 @@ define('task-list', {
|
|
|
9983
10087
|
- Use `createSortableScope()` only when items should genuinely move between containers.
|
|
9984
10088
|
- Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.
|
|
9985
10089
|
- Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.
|
|
10090
|
+
- 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
10091
|
|
|
9987
10092
|
### Examples
|
|
9988
10093
|
|
|
9989
10094
|
## Examples
|
|
9990
10095
|
|
|
9991
10096
|
- [Sortable List](./examples/sortable-list.md)
|
|
10097
|
+
- [Touch-Enabled Sortable List](./examples/touch-enabled-sortable-list.md)
|
|
9992
10098
|
- [File Upload Drop Zone](./examples/file-upload-drop-zone.md)
|
|
9993
10099
|
- [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md)
|
|
9994
10100
|
- [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md)
|
|
@@ -12900,7 +13006,7 @@ if (!submission.ok && submission.type === 'validation') {
|
|
|
12900
13006
|
- Browser-first utility: `toFormData`
|
|
12901
13007
|
- Framework-agnostic core — wire into React, Vue, Svelte, or vanilla JS with `subscribe()`/`connect()`, no dedicated adapter package
|
|
12902
13008
|
- `@vielzeug/forge/validators` adapter: `fieldValidator` and `composeValidators`
|
|
12903
|
-
- `@vielzeug/forge/devtools`: opt-in `
|
|
13009
|
+
- `@vielzeug/forge/devtools`: opt-in `debugForm()` for `console.debug` state-transition logging, tree-shaken from production
|
|
12904
13010
|
|
|
12905
13011
|
## Documentation
|
|
12906
13012
|
|
|
@@ -12947,7 +13053,7 @@ if (!submission.ok && submission.type === 'validation') {
|
|
|
12947
13053
|
| ---------------------------- | ---------------------------------------------------------------------------------- |
|
|
12948
13054
|
| `@vielzeug/forge` | `createForm`, `toFormData`, `ValidationModes`, `FORM_ERROR`, and all types |
|
|
12949
13055
|
| `@vielzeug/forge/validators` | `fieldValidator`, `composeValidators` — schema and validator composition helpers |
|
|
12950
|
-
| `@vielzeug/forge/devtools` | `
|
|
13056
|
+
| `@vielzeug/forge/devtools` | `debugForm` — opt-in `console.debug` logging for form state transitions |
|
|
12951
13057
|
|
|
12952
13058
|
## createForm()
|
|
12953
13059
|
|
|
@@ -13471,7 +13577,7 @@ const form = createForm({
|
|
|
13471
13577
|
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
13578
|
|
|
13473
13579
|
```ts
|
|
13474
|
-
function
|
|
13580
|
+
function debugForm>(
|
|
13475
13581
|
form: Form,
|
|
13476
13582
|
options?: ForgeDevtoolsOptions,
|
|
13477
13583
|
): Unsubscribe;
|
|
@@ -13487,10 +13593,10 @@ Logs one line per observable state transition: per-field `value`/`error`/`touche
|
|
|
13487
13593
|
|
|
13488
13594
|
```ts
|
|
13489
13595
|
import { createForm } from '@vielzeug/forge';
|
|
13490
|
-
import {
|
|
13596
|
+
import { debugForm } from '@vielzeug/forge/devtools';
|
|
13491
13597
|
|
|
13492
13598
|
const form = createForm({ defaultValues: { email: '' } });
|
|
13493
|
-
const detach =
|
|
13599
|
+
const detach = debugForm(form, { label: 'signup' });
|
|
13494
13600
|
// [forge:devtools:signup] field "email" value: "" → "a@b.com"
|
|
13495
13601
|
|
|
13496
13602
|
detach(); // stop logging
|
|
@@ -13629,6 +13735,12 @@ console.log(result.valid); // true only if no errors exist after this run
|
|
|
13629
13735
|
console.log(result.errors); // full current error map after the run
|
|
13630
13736
|
```
|
|
13631
13737
|
|
|
13738
|
+
Validation race semantics:
|
|
13739
|
+
|
|
13740
|
+
- Forge aborts superseded runs instead of surfacing them as failures.
|
|
13741
|
+
- The newest validation run owns final field errors.
|
|
13742
|
+
- Aborted runs resolve without throwing in normal API usage.
|
|
13743
|
+
|
|
13632
13744
|
Schema integration — pass a `safeParse`-compatible schema directly to `validator`:
|
|
13633
13745
|
|
|
13634
13746
|
```ts
|
|
@@ -13778,11 +13890,27 @@ await address.validate(); // validates only address.* fields; returns scoped err
|
|
|
13778
13890
|
await address.submit((vals) => vals); // validates and submits only address.* fields
|
|
13779
13891
|
```
|
|
13780
13892
|
|
|
13893
|
+
### Root vs Scoped Path Semantics
|
|
13894
|
+
|
|
13895
|
+
| Surface | Root form (`form`) | Scoped form (`form.scope('address')`) |
|
|
13896
|
+
| -------------------------- | ------------------ | ------------------------------------- |
|
|
13897
|
+
| `get` / `set` / `field` | absolute keys | relative keys |
|
|
13898
|
+
| `errors` in `state` | absolute keys | relative keys |
|
|
13899
|
+
| `touchedFields` in `state` | absolute keys | relative keys |
|
|
13900
|
+
| `validatingFields` in `state` | absolute keys | relative keys |
|
|
13901
|
+
| `validate(name)` input | absolute key | relative key |
|
|
13902
|
+
| `validate()` result keys | absolute keys | relative keys |
|
|
13903
|
+
|
|
13904
|
+
### Recommended Scoped Patterns
|
|
13905
|
+
|
|
13906
|
+
1. Call `const address = form.scope('address')` once per UI/module boundary and pass that around.
|
|
13907
|
+
2. Use `address.validate()` / `address.submit()` instead of manually feeding `state.touchedFields` into `validate(fields[])`.
|
|
13908
|
+
3. Prefer `address.subscribeScoped(...)` for section UIs so sibling/root mutations do not trigger redraws.
|
|
13909
|
+
|
|
13781
13910
|
**Key characteristics:**
|
|
13782
13911
|
|
|
13783
13912
|
- `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.
|
|
13913
|
+
- `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
13914
|
|
|
13787
13915
|
### Scoped Subscriptions
|
|
13788
13916
|
|
|
@@ -13900,12 +14028,12 @@ After `dispose()`, all mutating APIs throw.
|
|
|
13900
14028
|
|
|
13901
14029
|
## Debugging
|
|
13902
14030
|
|
|
13903
|
-
Import `
|
|
14031
|
+
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
14032
|
|
|
13905
14033
|
```ts
|
|
13906
|
-
import {
|
|
14034
|
+
import { debugForm } from '@vielzeug/forge/devtools';
|
|
13907
14035
|
|
|
13908
|
-
const detach =
|
|
14036
|
+
const detach = debugForm(form, { label: 'signup' });
|
|
13909
14037
|
// later, e.g. on unmount:
|
|
13910
14038
|
detach();
|
|
13911
14039
|
```
|
|
@@ -17742,7 +17870,7 @@ i18n.getSupportedLocales();
|
|
|
17742
17870
|
| `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
17871
|
| `i18n.isNamespaceRegistered()` | Check if a namespace factory has been registered | Sync | `true` after `registerNamespace()` or `extend()`; `false` before |
|
|
17744
17872
|
| `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync | Equivalent to `serializeI18n(i18n)` — preferred for public API access |
|
|
17745
|
-
| `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Equivalent to `hydrateI18n(
|
|
17873
|
+
| `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Equivalent to `hydrateI18n(i18n, state)` — preferred for public API access; throws `LinguaRestoreError` if locale missing |
|
|
17746
17874
|
| `serializeI18n()` | Serialise loaded catalogs for SSR hydration | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
|
|
17747
17875
|
| `hydrateI18n()` | Hydrate a client instance from server-serialised state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
|
|
17748
17876
|
| Error classes | Named error subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, …) | — | All runtime errors are `instanceof LinguaError`; use `instanceof` for specific handling |
|
|
@@ -17960,7 +18088,7 @@ const state = i18n.getState();
|
|
|
17960
18088
|
restoreState(state: I18nState): void
|
|
17961
18089
|
```
|
|
17962
18090
|
|
|
17963
|
-
Hydrates this instance from an `I18nState` produced by `getState()` or `serializeI18n()`. Equivalent to `hydrateI18n(
|
|
18091
|
+
Hydrates this instance from an `I18nState` produced by `getState()` or `serializeI18n()`. Equivalent to `hydrateI18n(i18n, state)` but preferred because it is called directly on the instance.
|
|
17964
18092
|
|
|
17965
18093
|
- Replaces all catalogs with those from `state`.
|
|
17966
18094
|
- Sets the active locale to `state.locale`.
|
|
@@ -20718,11 +20846,13 @@ function useFloat(referenceRef: { value: HTMLElement | null }, floatingRef: { va
|
|
|
20718
20846
|
Use Orbit inside a Ore component to position tooltips and popovers reactively.
|
|
20719
20847
|
|
|
20720
20848
|
```ts
|
|
20721
|
-
import { define, html } from '@vielzeug/ore';
|
|
20849
|
+
import { define, getHost, html, onMounted } from '@vielzeug/ore';
|
|
20722
20850
|
import { flip, float, offset, shift } from '@vielzeug/orbit';
|
|
20723
20851
|
|
|
20724
20852
|
define('x-tooltip', {
|
|
20725
|
-
setup(_props
|
|
20853
|
+
setup(_props) {
|
|
20854
|
+
const el = getHost();
|
|
20855
|
+
|
|
20726
20856
|
onMounted(() => {
|
|
20727
20857
|
const tooltipEl = el.querySelector('[role=tooltip]')!;
|
|
20728
20858
|
|
|
@@ -20783,7 +20913,7 @@ define('x-tooltip', {
|
|
|
20783
20913
|
|
|
20784
20914
|
**Category:** ui-primitives
|
|
20785
20915
|
**Keywords:** web-components, custom-elements, reactive, templates, signals, lifecycle
|
|
20786
|
-
**Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict,
|
|
20916
|
+
**Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, provide, onMounted, onCleanup, useEmit (+10 more)
|
|
20787
20917
|
**Related:** ripple, refine, orbit
|
|
20788
20918
|
|
|
20789
20919
|
### Overview
|
|
@@ -20854,7 +20984,7 @@ yarn add @vielzeug/ore
|
|
|
20854
20984
|
|
|
20855
20985
|
```ts
|
|
20856
20986
|
import { computed, signal } from '@vielzeug/ripple';
|
|
20857
|
-
import { css, define, html, prop } from '@vielzeug/ore';
|
|
20987
|
+
import { bind, css, define, html, onMounted, prop } from '@vielzeug/ore';
|
|
20858
20988
|
|
|
20859
20989
|
define('my-counter', {
|
|
20860
20990
|
props: {
|
|
@@ -20869,7 +20999,7 @@ define('my-counter', {
|
|
|
20869
20999
|
}
|
|
20870
21000
|
`,
|
|
20871
21001
|
],
|
|
20872
|
-
setup(props
|
|
21002
|
+
setup(props) {
|
|
20873
21003
|
const count = signal(0);
|
|
20874
21004
|
const doubled = computed(() => count.value * 2);
|
|
20875
21005
|
|
|
@@ -20890,12 +21020,13 @@ define('my-counter', {
|
|
|
20890
21020
|
- Signal-first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple` — import them directly
|
|
20891
21021
|
- Functional component authoring via `define(tag, { props, setup, styles, formAssociated })`
|
|
20892
21022
|
- 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`, `
|
|
21023
|
+
- `setup(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\``
|
|
21024
|
+
- Lifecycle hooks — `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect` — plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls
|
|
20895
21025
|
- Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `model`, `raw`
|
|
20896
|
-
- Host bindings via `
|
|
20897
|
-
- Reactive ARIA sync via `
|
|
20898
|
-
-
|
|
21026
|
+
- Host bindings via `bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off-host element
|
|
21027
|
+
- Reactive ARIA sync via `aria(target, config)` — applies `aria-*` attributes reactively to any element, auto-cleanup on disconnect
|
|
21028
|
+
- Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()`
|
|
21029
|
+
- Form-associated helpers (`@vielzeug/ore/forms`): `useField()`, `createFormContext()`
|
|
20899
21030
|
- Observers (`@vielzeug/ore/observers`)
|
|
20900
21031
|
- Testing utilities (`@vielzeug/ore/testing`) — `mount`, `renderHook`, `fire`, `user`, `waitFor`, `cleanup`
|
|
20901
21032
|
- Debug utilities (`@vielzeug/ore/devtools`) — `debugFlush()` for diagnosing update timing
|
|
@@ -20904,9 +21035,10 @@ define('my-counter', {
|
|
|
20904
21035
|
|
|
20905
21036
|
| Import | Purpose |
|
|
20906
21037
|
| --------------------------- | ----------------------------------------------------------------------------- |
|
|
20907
|
-
| `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context
|
|
21038
|
+
| `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context) |
|
|
20908
21039
|
| `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics (dev only) |
|
|
20909
21040
|
| `@vielzeug/ore/directives` | `each`, `when`, `model`, `live`, `raw`, `classMap`, `styleMap` |
|
|
21041
|
+
| `@vielzeug/ore/forms` | `useField`, `createFormContext`, `FORM_CONTEXT_KEY` |
|
|
20910
21042
|
| `@vielzeug/ore/observers` | `resizeObserver`, `intersectionObserver`, `mediaObserver`, `mutationObserver` |
|
|
20911
21043
|
| `@vielzeug/ore/testing` | `mount`, `fire`, `user`, `waitFor`, `cleanup`, and helpers |
|
|
20912
21044
|
|
|
@@ -20926,23 +21058,31 @@ define('my-counter', {
|
|
|
20926
21058
|
|
|
20927
21059
|
## API Overview
|
|
20928
21060
|
|
|
20929
|
-
|
|
20930
|
-
|
|
20931
|
-
|
|
20932
|
-
|
|
20933
|
-
|
|
|
20934
|
-
|
|
|
20935
|
-
| `
|
|
20936
|
-
| `
|
|
20937
|
-
| `
|
|
20938
|
-
| `
|
|
20939
|
-
| `
|
|
20940
|
-
| `
|
|
20941
|
-
| `
|
|
20942
|
-
| `
|
|
20943
|
-
| `
|
|
20944
|
-
| `
|
|
20945
|
-
| `
|
|
21061
|
+
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.
|
|
21062
|
+
|
|
21063
|
+
> `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.
|
|
21064
|
+
|
|
21065
|
+
| Symbol | Purpose | Execution mode | Common gotcha |
|
|
21066
|
+
| ---------------------- | ----------------------------------------------------- | -------------- | -------------------------------------------------------------------------- |
|
|
21067
|
+
| `define()` | Register a custom element with reactive setup | Sync | Tag must contain a hyphen; call before first use |
|
|
21068
|
+
| `html` | Tagged template literal returning HTMLResult | Sync | Expressions must be signals, functions, or primitives |
|
|
21069
|
+
| `prop.*` | Typed prop helpers (string, bool, number, …) | Sync | Prop values are signals — read `.value` |
|
|
21070
|
+
| `provide()`/`inject()` | Context API for parent-to-descendant sharing | Setup only | Must be called synchronously during `setup()` |
|
|
21071
|
+
| `ref()` | Reactive reference to a DOM element | Sync | Value is null until after first mount |
|
|
21072
|
+
| `createContext()` | Create a typed injection key | Sync | Context is scoped to the component tree |
|
|
21073
|
+
| `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys warn in dev; plain `T[]` treated as one-time static render |
|
|
21074
|
+
| `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
|
|
21075
|
+
| `model(signal)` | Two-way binding for input/select/textarea | Sync | `` uses `Signal`; `select` uses `change` |
|
|
21076
|
+
| `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
|
|
21077
|
+
| `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
|
|
21078
|
+
| `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
|
|
21079
|
+
| `onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
|
|
21080
|
+
| `useField(options)` | Wire signal to form `ElementInternals` | Setup only | Requires `formAssociated: true` on the component definition; `@vielzeug/ore/forms` |
|
|
21081
|
+
| `onFormReset(fn)` | Run work when the ancestor `` resets | Setup only | Fires every reset (not one-shot); only for `formAssociated: true` components |
|
|
21082
|
+
| `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 |
|
|
21083
|
+
| `useEmit()` | Typed `emit()` bound to the current host | Setup only | Call once per component; returns `dispatchEvent`'s boolean (`false` if a listener called `preventDefault()`) |
|
|
21084
|
+
| `useSlots()`| Reactive slot presence/element signals | Setup only | Safe to call more than once — the underlying registry is created once |
|
|
21085
|
+
| `getHost()` | The current component's host element | Setup only | Prefer a higher-level helper (`bind`, `aria`, …) when one exists |
|
|
20946
21086
|
|
|
20947
21087
|
## Package Entry Points
|
|
20948
21088
|
|
|
@@ -20951,6 +21091,7 @@ define('my-counter', {
|
|
|
20951
21091
|
| `@vielzeug/ore` | Core authoring/runtime API |
|
|
20952
21092
|
| `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics |
|
|
20953
21093
|
| `@vielzeug/ore/directives` | Standalone directive imports (`each`, `when`, `classMap`, …) |
|
|
21094
|
+
| `@vielzeug/ore/forms` | Form-association helpers (`useField`, `createFormContext`) |
|
|
20954
21095
|
| `@vielzeug/ore/observers` | Resize, intersection, mutation, and media observers |
|
|
20955
21096
|
| `@vielzeug/ore/testing` | DOM-oriented test helpers |
|
|
20956
21097
|
|
|
@@ -20962,35 +21103,36 @@ define('my-counter', {
|
|
|
20962
21103
|
define(tag: string, definition: ComponentDefinition): void;
|
|
20963
21104
|
```
|
|
20964
21105
|
|
|
20965
|
-
The `setup()` function receives typed prop signals
|
|
21106
|
+
The `setup()` function receives only typed prop signals:
|
|
20966
21107
|
|
|
20967
21108
|
```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
|
-
};
|
|
21109
|
+
setup(props) {
|
|
21110
|
+
return html`${props.label}`;
|
|
21111
|
+
}
|
|
20982
21112
|
```
|
|
20983
21113
|
|
|
20984
|
-
|
|
20985
|
-
|
|
20986
|
-
`setup()` returns an `HTMLResult` directly (not a function):
|
|
21114
|
+
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
21115
|
|
|
20988
21116
|
```ts
|
|
20989
|
-
|
|
20990
|
-
|
|
20991
|
-
|
|
21117
|
+
import { define, html, onMounted, useEmit, useSlots } from '@vielzeug/ore';
|
|
21118
|
+
|
|
21119
|
+
define('my-card', {
|
|
21120
|
+
setup(_props) {
|
|
21121
|
+
const emit = useEmit();
|
|
21122
|
+
const slots = useSlots();
|
|
21123
|
+
|
|
21124
|
+
onMounted(() => console.log('mounted'));
|
|
21125
|
+
|
|
21126
|
+
// emit() returns dispatchEvent's boolean — false if a listener called preventDefault()
|
|
21127
|
+
const notCancelled = emit('close');
|
|
21128
|
+
|
|
21129
|
+
return html`${when(slots.has('header'), () => html``)}`;
|
|
21130
|
+
},
|
|
21131
|
+
});
|
|
20992
21132
|
```
|
|
20993
21133
|
|
|
21134
|
+
`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).
|
|
21135
|
+
|
|
20994
21136
|
### ComponentDefinition
|
|
20995
21137
|
|
|
20996
21138
|
```ts
|
|
@@ -20999,26 +21141,12 @@ type ComponentDefinition = {
|
|
|
20999
21141
|
loading?: () => HTMLResult; // Template shown while async setup is pending
|
|
21000
21142
|
onError?: (error: OreLifecycleError, element: HTMLElement) => HTMLResult | void;
|
|
21001
21143
|
props?: PropsDef;
|
|
21002
|
-
setup: (
|
|
21003
|
-
props: InferProps>,
|
|
21004
|
-
ctx: SetupContextBag,
|
|
21005
|
-
) => HTMLResult | Promise;
|
|
21144
|
+
setup: (props: InferProps>) => HTMLResult | Promise;
|
|
21006
21145
|
shadow?: Partial | false; // false = light DOM (no shadow root)
|
|
21007
21146
|
styles?: (string | CSSStyleSheet | CSSResult)[];
|
|
21008
21147
|
};
|
|
21009
21148
|
```
|
|
21010
21149
|
|
|
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
21150
|
#### Async setup
|
|
21023
21151
|
|
|
21024
21152
|
When `setup()` returns a `Promise`, `loading()` is rendered immediately. The real template replaces it once the promise resolves.
|
|
@@ -21037,10 +21165,12 @@ define('user-profile', {
|
|
|
21037
21165
|
|
|
21038
21166
|
## Runtime Helpers
|
|
21039
21167
|
|
|
21040
|
-
`onMounted`, `onCleanup`, `onEvent`, `onElement`, and `
|
|
21168
|
+
`onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watchEffect` are plain functions imported from `@vielzeug/ore`. Call them directly during `setup()`.
|
|
21041
21169
|
|
|
21042
21170
|
```ts
|
|
21043
|
-
|
|
21171
|
+
import { html, onCleanup, onEvent, onMounted } from '@vielzeug/ore';
|
|
21172
|
+
|
|
21173
|
+
setup(props) {
|
|
21044
21174
|
onMounted(() => {
|
|
21045
21175
|
// DOM is ready; return a function for mount-scoped cleanup
|
|
21046
21176
|
return () => { /* cleanup on unmount */ };
|
|
@@ -21054,20 +21184,18 @@ setup(props, { onMounted, onCleanup, onEvent, onElement, watch }) {
|
|
|
21054
21184
|
}
|
|
21055
21185
|
```
|
|
21056
21186
|
|
|
21057
|
-
|
|
21187
|
+
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
21188
|
|
|
21059
21189
|
```ts
|
|
21060
|
-
|
|
21061
|
-
onCleanup: (fn: () => void) => void;
|
|
21062
|
-
};
|
|
21190
|
+
import { onCleanup } from '@vielzeug/ore';
|
|
21063
21191
|
|
|
21064
|
-
function useMyHelper(
|
|
21065
|
-
|
|
21192
|
+
function useMyHelper() {
|
|
21193
|
+
onCleanup(() => { /* teardown */ });
|
|
21066
21194
|
}
|
|
21067
21195
|
|
|
21068
21196
|
// In setup:
|
|
21069
|
-
setup(_props
|
|
21070
|
-
useMyHelper(
|
|
21197
|
+
setup(_props) {
|
|
21198
|
+
useMyHelper();
|
|
21071
21199
|
return html`...`;
|
|
21072
21200
|
}
|
|
21073
21201
|
```
|
|
@@ -21149,7 +21277,7 @@ Event bindings support dot-separated modifiers: `@click.prevent.stop=${handler}`
|
|
|
21149
21277
|
|
|
21150
21278
|
## Host Bindings
|
|
21151
21279
|
|
|
21152
|
-
|
|
21280
|
+
`bind(config, options?)` is a plain function imported from `@vielzeug/ore`:
|
|
21153
21281
|
|
|
21154
21282
|
```ts
|
|
21155
21283
|
bind({
|
|
@@ -21175,9 +21303,9 @@ bind(
|
|
|
21175
21303
|
|
|
21176
21304
|
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
21305
|
|
|
21178
|
-
###
|
|
21306
|
+
### aria()
|
|
21179
21307
|
|
|
21180
|
-
For reactive ARIA attribute syncing, use `
|
|
21308
|
+
For reactive ARIA attribute syncing, use `aria(target, config)`. Shorthand keys are normalised to `aria-*` automatically (`expanded` → `aria-expanded`; `role` is passed verbatim):
|
|
21181
21309
|
|
|
21182
21310
|
```ts
|
|
21183
21311
|
// Inside setup — cleanup auto-registered
|
|
@@ -21204,12 +21332,12 @@ Slot signals update reactively when assigned content changes, including when slo
|
|
|
21204
21332
|
## Context API
|
|
21205
21333
|
|
|
21206
21334
|
- `createContext(description?)` — Create a typed injection key
|
|
21207
|
-
- `
|
|
21335
|
+
- `provide(key, value)` — Provide a value to descendants
|
|
21208
21336
|
- `inject(key)` — Resolve from nearest ancestor; returns `undefined` if not found
|
|
21209
21337
|
- `inject(key, fallback)` — Resolve with a fallback value
|
|
21210
21338
|
- `injectStrict(key)` — Resolve or throw if absent
|
|
21211
21339
|
|
|
21212
|
-
`
|
|
21340
|
+
`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
21341
|
|
|
21214
21342
|
## Utilities
|
|
21215
21343
|
|
|
@@ -21220,6 +21348,8 @@ Slot signals update reactively when assigned content changes, including when slo
|
|
|
21220
21348
|
|
|
21221
21349
|
## Form-Associated API
|
|
21222
21350
|
|
|
21351
|
+
Import from `@vielzeug/ore/forms`.
|
|
21352
|
+
|
|
21223
21353
|
### `useField(options)`
|
|
21224
21354
|
|
|
21225
21355
|
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 +21364,12 @@ type FormFieldOptions = {
|
|
|
21234
21364
|
* @default false
|
|
21235
21365
|
*/
|
|
21236
21366
|
emptyStringForNull?: boolean;
|
|
21367
|
+
/** Called when the ancestor resets (see onFormReset) — restore local field state here. */
|
|
21368
|
+
onReset?: () => void;
|
|
21237
21369
|
toFormValue?: (value: T) => File | FormData | string | null;
|
|
21370
|
+
/** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */
|
|
21371
|
+
validationMessage?: Readable;
|
|
21372
|
+
validity?: Readable;
|
|
21238
21373
|
value: Signal | Readable;
|
|
21239
21374
|
};
|
|
21240
21375
|
|
|
@@ -21247,11 +21382,23 @@ type FormFieldHandle = {
|
|
|
21247
21382
|
};
|
|
21248
21383
|
```
|
|
21249
21384
|
|
|
21385
|
+
Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation (`checkValidity()`/`reportValidity()`, and ``'s submit blocking):
|
|
21386
|
+
|
|
21387
|
+
```ts
|
|
21388
|
+
const isBlank = (v: string) => v.trim() === '';
|
|
21389
|
+
|
|
21390
|
+
useField({
|
|
21391
|
+
validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')),
|
|
21392
|
+
validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)),
|
|
21393
|
+
value,
|
|
21394
|
+
});
|
|
21395
|
+
```
|
|
21396
|
+
|
|
21250
21397
|
### Form Context
|
|
21251
21398
|
|
|
21252
21399
|
Coordinate form state across child field components:
|
|
21253
21400
|
|
|
21254
|
-
- `createFormContext(options?)` — Create a `FormController`; call `
|
|
21401
|
+
- `createFormContext(options?)` — Create a `FormController`; call `provide(FORM_CONTEXT_KEY, ctrl)` to make it available to descendants
|
|
21255
21402
|
- `FORM_CONTEXT_KEY` — the `InjectionKey` used to provide/inject the form context
|
|
21256
21403
|
|
|
21257
21404
|
```ts
|
|
@@ -21284,7 +21431,9 @@ Import from `@vielzeug/ore/testing`.
|
|
|
21284
21431
|
| ------------------------ | ------------------------------------------------------------------------------------------ |
|
|
21285
21432
|
| `mount(setup, options?)` | Mount a component and return a test fixture |
|
|
21286
21433
|
| `cleanup()` | Remove all mounted elements and reset test state |
|
|
21287
|
-
| `install(afterEach)` | Register auto-cleanup; pass `afterEach` from your test framework
|
|
21434
|
+
| `install(afterEach)` | Register auto-cleanup and the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below); pass `afterEach` from your test framework |
|
|
21435
|
+
| `installFormInternalsPolyfill()` | Called automatically by `install()`. Call directly only if you need the polyfill without auto-cleanup |
|
|
21436
|
+
| `walkFlatTree(root, visit)` | Walks the flat tree (expanding `` via `assignedElements()`) — for finding slotted content across a shadow boundary that `querySelectorAll()` can't cross |
|
|
21288
21437
|
| `flush(options?)` | Drain reactive updates and animation frames |
|
|
21289
21438
|
| `FLUSH_DEEP` | Pre-built options for deep async chains (`maxTurns: 12`) |
|
|
21290
21439
|
| `mock(tag, template?)` | Register a no-op stub custom element |
|
|
@@ -21297,6 +21446,8 @@ Import from `@vielzeug/ore/testing`.
|
|
|
21297
21446
|
|
|
21298
21447
|
> **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
21448
|
|
|
21449
|
+
> **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.
|
|
21450
|
+
|
|
21300
21451
|
#### `Fixture` interface
|
|
21301
21452
|
|
|
21302
21453
|
```ts
|
|
@@ -21321,11 +21472,11 @@ interface Fixture {
|
|
|
21321
21472
|
|
|
21322
21473
|
#### `renderHook`
|
|
21323
21474
|
|
|
21324
|
-
Useful for testing composable lifecycle hooks (`onMounted`, `
|
|
21475
|
+
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
21476
|
|
|
21326
21477
|
```ts
|
|
21327
21478
|
// Without props
|
|
21328
|
-
const { result, flush, dispose } = await renderHook((
|
|
21479
|
+
const { result, flush, dispose } = await renderHook(() => {
|
|
21329
21480
|
const count = signal(0);
|
|
21330
21481
|
onMounted(() => {
|
|
21331
21482
|
count.value = 1;
|
|
@@ -21374,48 +21525,32 @@ type InferProps = {
|
|
|
21374
21525
|
readonly [K in keyof D]-?: Readable>;
|
|
21375
21526
|
};
|
|
21376
21527
|
|
|
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
|
-
};
|
|
21528
|
+
// Runtime hooks — all plain functions imported from '@vielzeug/ore', not fields on an object.
|
|
21529
|
+
declare function onMounted(fn: OnMountedCallback): void; // DOM-ready callback; runs after first render
|
|
21530
|
+
declare function onCleanup(fn: CleanupFn): void; // Register teardown; called on disconnect
|
|
21531
|
+
declare function onElement(ref: Readable, cb: (el: T) => CleanupFn | void): () => void;
|
|
21532
|
+
declare function onEvent(
|
|
21533
|
+
target: EventTarget | null | undefined,
|
|
21534
|
+
event: string,
|
|
21535
|
+
listener: EventListener,
|
|
21536
|
+
options?: AddEventListenerOptions,
|
|
21537
|
+
): void;
|
|
21538
|
+
declare function onFormReset(fn: () => void): void; // Runs on every ancestor reset; formAssociated only
|
|
21539
|
+
declare function watchEffect(fn: EffectCallback): () => void; // Scoped reactive effect; auto-cleaned on disconnect
|
|
21540
|
+
declare function bind(config: HostBindConfig, options?: BindOptions): () => void; // Bindings for host or any target element
|
|
21541
|
+
declare function aria(target: Element, config: AriaConfig): () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect
|
|
21542
|
+
declare function provide(key: InjectionKey, value: T): void; // Register a context value on the host element
|
|
21543
|
+
declare function inject(key: InjectionKey, fallback?: T): T | undefined;
|
|
21544
|
+
declare function getHost(): HTMLElement; // The current component's host element
|
|
21545
|
+
declare function useEmit = Record>(): EmitFn;
|
|
21546
|
+
declare function useSlots(): ComponentSlots;
|
|
21409
21547
|
|
|
21410
21548
|
type ComponentDefinition = {
|
|
21411
21549
|
formAssociated?: boolean;
|
|
21412
21550
|
loading?: () => HTMLResult; // Shown while async setup is pending
|
|
21413
21551
|
onError?: (error: OreLifecycleError, el: HTMLElement) => HTMLResult | void;
|
|
21414
21552
|
props?: PropsDef;
|
|
21415
|
-
setup: (
|
|
21416
|
-
props: InferProps>,
|
|
21417
|
-
ctx: SetupContextBag,
|
|
21418
|
-
) => HTMLResult | Promise;
|
|
21553
|
+
setup: (props: InferProps>) => HTMLResult | Promise;
|
|
21419
21554
|
shadow?: Partial | false; // false = light DOM
|
|
21420
21555
|
styles?: (string | CSSStyleSheet | CSSResult)[];
|
|
21421
21556
|
};
|
|
@@ -21478,15 +21613,19 @@ define('status-chip', {
|
|
|
21478
21613
|
});
|
|
21479
21614
|
```
|
|
21480
21615
|
|
|
21481
|
-
|
|
21616
|
+
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
21617
|
|
|
21483
21618
|
```ts
|
|
21619
|
+
import { define, getHost, html, bind, useEmit, useSlots } from '@vielzeug/ore';
|
|
21620
|
+
|
|
21484
21621
|
define('my-widget', {
|
|
21485
|
-
setup(_props
|
|
21486
|
-
|
|
21487
|
-
|
|
21488
|
-
|
|
21489
|
-
|
|
21622
|
+
setup(_props) {
|
|
21623
|
+
const el = getHost(); // the host HTMLElement
|
|
21624
|
+
const emit = useEmit(); // typed event emitter
|
|
21625
|
+
const slots = useSlots(); // reactive slot observation
|
|
21626
|
+
|
|
21627
|
+
bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)
|
|
21628
|
+
|
|
21490
21629
|
return html``;
|
|
21491
21630
|
},
|
|
21492
21631
|
});
|
|
@@ -21518,16 +21657,17 @@ batch(() => {
|
|
|
21518
21657
|
|
|
21519
21658
|
## onMounted and lifecycle
|
|
21520
21659
|
|
|
21521
|
-
Use `
|
|
21660
|
+
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
21661
|
|
|
21523
21662
|
```ts
|
|
21524
21663
|
import { signal } from '@vielzeug/ripple';
|
|
21525
|
-
import { define, html, ref } from '@vielzeug/ore';
|
|
21664
|
+
import { define, html, onElement, onEvent, onMounted, ref, useSlots } from '@vielzeug/ore';
|
|
21526
21665
|
|
|
21527
21666
|
define('deferred-init', {
|
|
21528
|
-
setup(_props
|
|
21667
|
+
setup(_props) {
|
|
21529
21668
|
const tabIndex = signal(0);
|
|
21530
21669
|
const inputRef = ref();
|
|
21670
|
+
const slots = useSlots();
|
|
21531
21671
|
|
|
21532
21672
|
onMounted(() => {
|
|
21533
21673
|
const items = slots.elements('items').value;
|
|
@@ -21667,14 +21807,14 @@ define('live-search', {
|
|
|
21667
21807
|
|
|
21668
21808
|
## host bindings
|
|
21669
21809
|
|
|
21670
|
-
|
|
21810
|
+
`bind()` wires reactive attrs, classes, styles, and events to the host element.
|
|
21671
21811
|
|
|
21672
21812
|
```ts
|
|
21673
21813
|
import { signal } from '@vielzeug/ripple';
|
|
21674
|
-
import { define, html } from '@vielzeug/ore';
|
|
21814
|
+
import { bind, define, html } from '@vielzeug/ore';
|
|
21675
21815
|
|
|
21676
21816
|
define('x-toggle', {
|
|
21677
|
-
setup(_props
|
|
21817
|
+
setup(_props) {
|
|
21678
21818
|
const open = signal(false);
|
|
21679
21819
|
|
|
21680
21820
|
bind({
|
|
@@ -21692,14 +21832,14 @@ The `bind` config supports `attr`, `class`, `style`, and `on` sections.
|
|
|
21692
21832
|
|
|
21693
21833
|
## ARIA bindings
|
|
21694
21834
|
|
|
21695
|
-
Use `
|
|
21835
|
+
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
21836
|
|
|
21697
21837
|
```ts
|
|
21698
21838
|
import { signal } from '@vielzeug/ripple';
|
|
21699
|
-
import { define, html } from '@vielzeug/ore';
|
|
21839
|
+
import { aria, bind, define, html, onMounted } from '@vielzeug/ore';
|
|
21700
21840
|
|
|
21701
21841
|
define('x-disclosure', {
|
|
21702
|
-
setup(_props
|
|
21842
|
+
setup(_props) {
|
|
21703
21843
|
const open = signal(false);
|
|
21704
21844
|
const panelId = 'disclosure-panel';
|
|
21705
21845
|
|
|
@@ -21745,10 +21885,10 @@ Pass `{ target: el }` as a second argument to bind attributes, classes, styles,
|
|
|
21745
21885
|
|
|
21746
21886
|
```ts
|
|
21747
21887
|
import { signal } from '@vielzeug/ripple';
|
|
21748
|
-
import { define, html, ref } from '@vielzeug/ore';
|
|
21888
|
+
import { bind, define, html, onMounted, ref } from '@vielzeug/ore';
|
|
21749
21889
|
|
|
21750
21890
|
define('button-wrapper', {
|
|
21751
|
-
setup(_props
|
|
21891
|
+
setup(_props) {
|
|
21752
21892
|
const visible = signal(false);
|
|
21753
21893
|
const btnRef = ref();
|
|
21754
21894
|
|
|
@@ -21774,10 +21914,13 @@ define('button-wrapper', {
|
|
|
21774
21914
|
|
|
21775
21915
|
```ts
|
|
21776
21916
|
import { when } from '@vielzeug/ore/directives';
|
|
21777
|
-
import { define, html } from '@vielzeug/ore';
|
|
21917
|
+
import { define, html, useEmit, useSlots } from '@vielzeug/ore';
|
|
21918
|
+
|
|
21919
|
+
define('card-with-footer', {
|
|
21920
|
+
setup(_props) {
|
|
21921
|
+
const slots = useSlots();
|
|
21922
|
+
const emit = useEmit();
|
|
21778
21923
|
|
|
21779
|
-
define, Record, 'header' | 'footer'>('card-with-footer', {
|
|
21780
|
-
setup(_props, { slots, emit }) {
|
|
21781
21924
|
return html`
|
|
21782
21925
|
|
|
21783
21926
|
|
|
@@ -21790,18 +21933,18 @@ define, Record, 'header' | 'footer'>('card-with-footer', {
|
|
|
21790
21933
|
});
|
|
21791
21934
|
```
|
|
21792
21935
|
|
|
21793
|
-
Pass `SlotNames`
|
|
21936
|
+
Pass a `SlotNames` type parameter to `useSlots()` to get typed `slots.has()` and `slots.elements()` calls.
|
|
21794
21937
|
|
|
21795
21938
|
## context provide/inject
|
|
21796
21939
|
|
|
21797
21940
|
```ts
|
|
21798
21941
|
import { signal } from '@vielzeug/ripple';
|
|
21799
|
-
import { createContext, define, html, injectStrict } from '@vielzeug/ore';
|
|
21942
|
+
import { createContext, define, html, injectStrict, provide } from '@vielzeug/ore';
|
|
21800
21943
|
|
|
21801
21944
|
const COUNT_CTX = createContext>>('count');
|
|
21802
21945
|
|
|
21803
21946
|
define('count-provider', {
|
|
21804
|
-
setup(_props
|
|
21947
|
+
setup(_props) {
|
|
21805
21948
|
const count = signal(0);
|
|
21806
21949
|
provide(COUNT_CTX, count);
|
|
21807
21950
|
|
|
@@ -21822,7 +21965,8 @@ define('count-consumer', {
|
|
|
21822
21965
|
|
|
21823
21966
|
```ts
|
|
21824
21967
|
import { signal } from '@vielzeug/ripple';
|
|
21825
|
-
import { define, html, prop
|
|
21968
|
+
import { define, html, prop } from '@vielzeug/ore';
|
|
21969
|
+
import { useField } from '@vielzeug/ore/forms';
|
|
21826
21970
|
|
|
21827
21971
|
define('rating-input', {
|
|
21828
21972
|
formAssociated: true,
|
|
@@ -21862,15 +22006,15 @@ define('user-profile', {
|
|
|
21862
22006
|
|
|
21863
22007
|
## platform observers
|
|
21864
22008
|
|
|
21865
|
-
Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `
|
|
22009
|
+
Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `onMounted()`.
|
|
21866
22010
|
|
|
21867
22011
|
```ts
|
|
21868
22012
|
import { effect } from '@vielzeug/ripple';
|
|
21869
|
-
import { define, html, ref } from '@vielzeug/ore';
|
|
22013
|
+
import { define, html, onMounted, ref } from '@vielzeug/ore';
|
|
21870
22014
|
import { intersectionObserver, mediaObserver, resizeObserver } from '@vielzeug/ore/observers';
|
|
21871
22015
|
|
|
21872
22016
|
define('x-observed', {
|
|
21873
|
-
setup(_props
|
|
22017
|
+
setup(_props) {
|
|
21874
22018
|
const boxRef = ref();
|
|
21875
22019
|
|
|
21876
22020
|
onMounted(() => {
|
|
@@ -21988,10 +22132,11 @@ Use `@vielzeug/forge` for typed form state alongside Ore's `useField()` for form
|
|
|
21988
22132
|
```ts
|
|
21989
22133
|
import { createForm } from '@vielzeug/forge';
|
|
21990
22134
|
import { s } from '@vielzeug/spell';
|
|
21991
|
-
import {
|
|
22135
|
+
import { define, html, provide } from '@vielzeug/ore';
|
|
22136
|
+
import { createFormContext, FORM_CONTEXT_KEY } from '@vielzeug/ore/forms';
|
|
21992
22137
|
|
|
21993
22138
|
define('signup-form', {
|
|
21994
|
-
setup(_props
|
|
22139
|
+
setup(_props) {
|
|
21995
22140
|
const formCtx = createFormContext({
|
|
21996
22141
|
onSubmit: async (e) => {
|
|
21997
22142
|
e?.preventDefault();
|
|
@@ -22013,13 +22158,13 @@ define('signup-form', {
|
|
|
22013
22158
|
## Best Practices
|
|
22014
22159
|
|
|
22015
22160
|
- Setup returns `html\`...\`` directly — not a function wrapping the template.
|
|
22016
|
-
- Use `
|
|
22017
|
-
- Use `
|
|
22018
|
-
- Bind host attributes and classes via `
|
|
22161
|
+
- Use `watchEffect()` for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect.
|
|
22162
|
+
- Use `onElement(ref, cb)` instead of `onMounted` when the work is tied to a single DOM node.
|
|
22163
|
+
- Bind host attributes and classes via `bind()` rather than mutating the element directly.
|
|
22019
22164
|
- Provide context at the nearest ancestor — avoid global context singletons.
|
|
22020
|
-
- Call `
|
|
22165
|
+
- Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
|
|
22021
22166
|
- Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
|
|
22022
|
-
-
|
|
22167
|
+
- 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
22168
|
- Test with `@vielzeug/ore/testing` helpers (`mount`, `flush`, `waitFor`) rather than direct DOM manipulation.
|
|
22024
22169
|
|
|
22025
22170
|
### Examples
|
|
@@ -25927,8 +26072,8 @@ When the buffer is full, the **oldest** frame is evicted to make room for the ne
|
|
|
25927
26072
|
|
|
25928
26073
|
**Category:** ui-components
|
|
25929
26074
|
**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
|
|
26075
|
+
**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)
|
|
26076
|
+
**Related:** ore, orbit, forge, keymap
|
|
25932
26077
|
|
|
25933
26078
|
### Overview
|
|
25934
26079
|
|
|
@@ -26034,17 +26179,17 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
|
|
|
26034
26179
|
|
|
26035
26180
|
### Components
|
|
26036
26181
|
|
|
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`
|
|
26182
|
+
**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
26183
|
|
|
26039
26184
|
**Disclosure:** `ore-accordion`, `ore-accordion-item`, `ore-tabs`, `ore-tab-item`, `ore-tab-panel`
|
|
26040
26185
|
|
|
26041
|
-
**Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`
|
|
26186
|
+
**Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`, `ore-typing-indicator`
|
|
26042
26187
|
|
|
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`
|
|
26188
|
+
**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
26189
|
|
|
26045
26190
|
**Layout:** `ore-box`, `ore-grid`, `ore-grid-item`, `ore-navbar`, `ore-sidebar`
|
|
26046
26191
|
|
|
26047
|
-
**Overlay:** `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
|
|
26192
|
+
**Overlay:** `ore-command-palette`, `ore-command-palette-item`, `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
|
|
26048
26193
|
|
|
26049
26194
|
## Features
|
|
26050
26195
|
|
|
@@ -26072,6 +26217,7 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
|
|
|
26072
26217
|
- [Ore](/ore/) — Web component runtime that powers Refine
|
|
26073
26218
|
- [Orbit](/orbit/) — Floating UI positioning used in Refine's overlays
|
|
26074
26219
|
- [Forge](/forge/) — Form state management for use with Refine inputs
|
|
26220
|
+
- [Keymap](/keymap/) — Keyboard shortcut manager that powers the command palette's global trigger
|
|
26075
26221
|
|
|
26076
26222
|
### API Reference
|
|
26077
26223
|
|
|
@@ -26131,11 +26277,13 @@ import '@vielzeug/refine/button-group';
|
|
|
26131
26277
|
import '@vielzeug/refine/calendar';
|
|
26132
26278
|
import '@vielzeug/refine/card';
|
|
26133
26279
|
import '@vielzeug/refine/carousel';
|
|
26280
|
+
import '@vielzeug/refine/chat-message';
|
|
26134
26281
|
import '@vielzeug/refine/checkbox';
|
|
26135
26282
|
import '@vielzeug/refine/checkbox-group';
|
|
26136
26283
|
import '@vielzeug/refine/chip';
|
|
26137
26284
|
import '@vielzeug/refine/copy-command';
|
|
26138
26285
|
import '@vielzeug/refine/combobox';
|
|
26286
|
+
import '@vielzeug/refine/command-palette';
|
|
26139
26287
|
import '@vielzeug/refine/datagrid';
|
|
26140
26288
|
import '@vielzeug/refine/date-picker';
|
|
26141
26289
|
import '@vielzeug/refine/dialog';
|
|
@@ -26146,7 +26294,10 @@ import '@vielzeug/refine/grid';
|
|
|
26146
26294
|
import '@vielzeug/refine/grid-item';
|
|
26147
26295
|
import '@vielzeug/refine/icon';
|
|
26148
26296
|
import '@vielzeug/refine/input';
|
|
26297
|
+
import '@vielzeug/refine/list';
|
|
26298
|
+
import '@vielzeug/refine/list-item';
|
|
26149
26299
|
import '@vielzeug/refine/menu';
|
|
26300
|
+
import '@vielzeug/refine/message-composer';
|
|
26150
26301
|
import '@vielzeug/refine/navbar';
|
|
26151
26302
|
import '@vielzeug/refine/number-input';
|
|
26152
26303
|
import '@vielzeug/refine/otp-input';
|
|
@@ -26172,6 +26323,7 @@ import '@vielzeug/refine/textarea';
|
|
|
26172
26323
|
import '@vielzeug/refine/time-picker';
|
|
26173
26324
|
import '@vielzeug/refine/toast';
|
|
26174
26325
|
import '@vielzeug/refine/tooltip';
|
|
26326
|
+
import '@vielzeug/refine/typing-indicator';
|
|
26175
26327
|
```
|
|
26176
26328
|
|
|
26177
26329
|
## Shared Exported Symbols
|
|
@@ -26231,20 +26383,24 @@ Per-component API — attributes, events, slots, CSS custom properties:
|
|
|
26231
26383
|
- [Progress](./components/progress.md)
|
|
26232
26384
|
- [Skeleton](./components/skeleton.md)
|
|
26233
26385
|
- [Toast](./components/toast.md)
|
|
26386
|
+
- [Typing Indicator](./components/typing-indicator.md)
|
|
26234
26387
|
|
|
26235
26388
|
### Content
|
|
26236
26389
|
- [Avatar](./components/avatar.md)
|
|
26237
26390
|
- [Breadcrumb](./components/breadcrumb.md)
|
|
26238
26391
|
- [Card](./components/card.md)
|
|
26239
26392
|
- [Carousel](./components/carousel.md)
|
|
26393
|
+
- [Chat Message](./components/chat-message.md)
|
|
26240
26394
|
- [Copy Command](./components/copy-command.md)
|
|
26241
26395
|
- [Icon](./components/icon.md)
|
|
26396
|
+
- [List (+ List Item)](./components/list.md)
|
|
26242
26397
|
- [Pagination](./components/pagination.md)
|
|
26243
26398
|
- [Separator](./components/separator.md)
|
|
26244
26399
|
- [Table](./components/table.md)
|
|
26245
26400
|
- [Text](./components/text.md)
|
|
26246
26401
|
|
|
26247
26402
|
### Overlay
|
|
26403
|
+
- [Command Palette](./components/command-palette.md)
|
|
26248
26404
|
- [Dialog](./components/dialog.md)
|
|
26249
26405
|
- [Drawer](./components/drawer.md)
|
|
26250
26406
|
- [Menu](./components/menu.md)
|
|
@@ -26261,6 +26417,7 @@ Per-component API — attributes, events, slots, CSS custom properties:
|
|
|
26261
26417
|
- [File Input](./components/file-input.md)
|
|
26262
26418
|
- [Form](./components/form.md)
|
|
26263
26419
|
- [Input](./components/input.md)
|
|
26420
|
+
- [Message Composer](./components/message-composer.md)
|
|
26264
26421
|
- [Number Input](./components/number-input.md)
|
|
26265
26422
|
- [OTP Input](./components/otp-input.md)
|
|
26266
26423
|
- [Radio (+ Radio Group)](./components/radio.md)
|
|
@@ -26313,7 +26470,7 @@ createTextField(options: TextFieldOptions): TextFieldHandle
|
|
|
26313
26470
|
|
|
26314
26471
|
Controller for `` and ``. Manages value sync, validation triggers, character counter, and event wiring.
|
|
26315
26472
|
|
|
26316
|
-
Key members: `value` (writable signal), `wire(el, signal?)`, `clear()`, `counter
|
|
26473
|
+
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
26474
|
|
|
26318
26475
|
### `createChoiceField(options)`
|
|
26319
26476
|
|
|
@@ -26323,7 +26480,9 @@ createChoiceField(options: ChoiceFieldOptions): ChoiceFieldHandle
|
|
|
26323
26480
|
|
|
26324
26481
|
Controller for single and multi-select inputs. Normalises `string | string[]` values.
|
|
26325
26482
|
|
|
26326
|
-
Key members: `selectedValues`, `selectedValue`, `selectValue()`, `toggleValue()`, `removeValue()`, `clear()`, `setValues()`, `formValue
|
|
26483
|
+
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).
|
|
26484
|
+
|
|
26485
|
+
`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
26486
|
|
|
26328
26487
|
### `createCheckable(options)`
|
|
26329
26488
|
|
|
@@ -26333,7 +26492,7 @@ createCheckable(options: CheckableOptions): CheckableHandle
|
|
|
26333
26492
|
|
|
26334
26493
|
Controller for checkboxes and radios. Handles checked/indeterminate state, group delegation, and keyboard activation.
|
|
26335
26494
|
|
|
26336
|
-
Key members: `checked`, `indeterminate`, `toggle()`, `handleClick()`, `handleKeydown()`.
|
|
26495
|
+
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
26496
|
|
|
26338
26497
|
### `createOverlayControl(options)`
|
|
26339
26498
|
|
|
@@ -26367,6 +26526,8 @@ createListControl(options: ListNavigationOptions): ListControl
|
|
|
26367
26526
|
|
|
26368
26527
|
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
26528
|
|
|
26529
|
+
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.
|
|
26530
|
+
|
|
26370
26531
|
### Other Headless Exports
|
|
26371
26532
|
|
|
26372
26533
|
| Export | Description |
|
|
@@ -26379,7 +26540,6 @@ Keyboard-navigable list without open state. Supports vertical/horizontal/omni na
|
|
|
26379
26540
|
| `createDataGridControl()` | Data grid state (sorting, selection, column management, pagination)|
|
|
26380
26541
|
| `createTypeahead()` | Standalone typeahead buffer with debounced reset |
|
|
26381
26542
|
| `createDropdownPositioner()` | Floating dropdown positioner (wraps Orbit) |
|
|
26382
|
-
| `createDialogFocusControl()` | Dialog-specific focus entry and restoration |
|
|
26383
26543
|
| `createInteraction()` | Unified click/keyboard press handler for interactive elements |
|
|
26384
26544
|
| `dispatchKeyboardAction()` | Low-level keymap dispatcher |
|
|
26385
26545
|
| `createSelectionControl()` | Single/multi/none row-selection controller (used by the data grid) |
|
|
@@ -31276,7 +31436,7 @@ Scout builds a **trigram inverted index** at construction time. Query time is O(
|
|
|
31276
31436
|
| ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
|
|
31277
31437
|
| Bundle size | ~3 KB | | ~23 KB |
|
|
31278
31438
|
| Zero dependencies | | `@vielzeug/ripple` peer (reactive layer only) | |
|
|
31279
|
-
| Algorithm | Levenshtein | Trigram +
|
|
31439
|
+
| Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap |
|
|
31280
31440
|
| Query time | O(n·m) | O(candidates) | O(n·m) |
|
|
31281
31441
|
| Stateful index | | | |
|
|
31282
31442
|
| Match highlighting | | | |
|
|
@@ -31388,7 +31548,7 @@ function createIndex(items: T[], options: ScoutIndexOptions): ScoutIndex
|
|
|
31388
31548
|
| --- | --- | --- |
|
|
31389
31549
|
| `items` | `T[]` | Initial corpus to index. |
|
|
31390
31550
|
| `options.fields` | `ReadonlyArray>` | Fields to index. Required; at least one entry. |
|
|
31391
|
-
| `options.threshold` | `number` | Min
|
|
31551
|
+
| `options.threshold` | `number` | Min overlap score for a result (default `0.2`). |
|
|
31392
31552
|
| `options.limit` | `number` | Max results returned by `search()` (default `50`). |
|
|
31393
31553
|
| `options.minQueryLength` | `number` | Min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |
|
|
31394
31554
|
|
|
@@ -31533,7 +31693,7 @@ function createReactiveSearch(
|
|
|
31533
31693
|
| `items` | `T[]` | Initial corpus to index. |
|
|
31534
31694
|
| `options.fields` | `ReadonlyArray>` | Fields to index. Required. |
|
|
31535
31695
|
| `options.debounce` | `number` | Debounce ms (default `200`). |
|
|
31536
|
-
| `options.threshold` | `number` | Min
|
|
31696
|
+
| `options.threshold` | `number` | Min overlap score (default `0.2`). |
|
|
31537
31697
|
| `options.limit` | `number` | Max results (default `50`). |
|
|
31538
31698
|
| `options.minQueryLength` | `number` | Min chars before trigram scoring (default `3`). |
|
|
31539
31699
|
|
|
@@ -31903,12 +32063,18 @@ index.search('日本語'); // matches the first document
|
|
|
31903
32063
|
Pass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality.
|
|
31904
32064
|
|
|
31905
32065
|
```ts
|
|
31906
|
-
// At most 10 results, minimum
|
|
32066
|
+
// At most 10 results, minimum overlap score 0.3
|
|
31907
32067
|
const results = index.search('widget', { limit: 10, threshold: 0.3 });
|
|
31908
32068
|
```
|
|
31909
32069
|
|
|
31910
32070
|
Per-call options override the index-level defaults set in `createIndex`.
|
|
31911
32071
|
|
|
32072
|
+
Scores come from the overlap (Szymkiewicz–Simpson) coefficient — the fraction of the *shorter*
|
|
32073
|
+
trigram set (almost always the query) found in the longer one. This is deliberate for the
|
|
32074
|
+
autocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix
|
|
32075
|
+
of a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how
|
|
32076
|
+
much of the query matched, not diluted by how much longer the target field happens to be.
|
|
32077
|
+
|
|
31912
32078
|
### Controlling short-query behaviour
|
|
31913
32079
|
|
|
31914
32080
|
Queries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`.
|
|
@@ -32561,6 +32727,7 @@ interface VirtualizerState {
|
|
|
32561
32727
|
| `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset |
|
|
32562
32728
|
| `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` |
|
|
32563
32729
|
| `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list |
|
|
32730
|
+
| `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
32731
|
| `invalidate` | `() => void` | Clear all measurements and rebuild from estimates |
|
|
32565
32732
|
| `dispose` | `() => void` | Detach listeners; idempotent |
|
|
32566
32733
|
| `disposed` | `boolean` | `true` after `dispose()` is called |
|
|
@@ -32696,9 +32863,34 @@ ctrl.dispose();
|
|
|
32696
32863
|
| `overscan` | `number \| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |
|
|
32697
32864
|
| `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers |
|
|
32698
32865
|
| `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` |
|
|
32866
|
+
| `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
32867
|
|
|
32700
32868
|
Without `getItemKey`, each `setItems()` call drops cached measurements.
|
|
32701
32869
|
|
|
32870
|
+
### `StickToBottomOptions`
|
|
32871
|
+
|
|
32872
|
+
| Option | Type | Default | Description |
|
|
32873
|
+
| ----------- | --------- | ------- | --------------------------------------------------------------------------- |
|
|
32874
|
+
| `enabled` | `boolean` | `true` | Enable/disable at runtime — pass the object form to toggle without removing it |
|
|
32875
|
+
| `threshold` | `number` | `48` | Distance in pixels from the end still considered "at the end" |
|
|
32876
|
+
|
|
32877
|
+
`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.
|
|
32878
|
+
|
|
32879
|
+
```ts
|
|
32880
|
+
const chat = createDomVirtualList({
|
|
32881
|
+
estimateSize: 48,
|
|
32882
|
+
getItemKey: (_, m) => m.id,
|
|
32883
|
+
listElement: listEl,
|
|
32884
|
+
render: renderMessages,
|
|
32885
|
+
scrollElement: scrollEl,
|
|
32886
|
+
stickToBottom: true, // or { threshold: 80 } for a larger "still at bottom" tolerance
|
|
32887
|
+
});
|
|
32888
|
+
|
|
32889
|
+
chat.setItems(messages); // scrolls to bottom on first load
|
|
32890
|
+
// … later, a new message arrives (or the last one grows while streaming) …
|
|
32891
|
+
chat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom
|
|
32892
|
+
```
|
|
32893
|
+
|
|
32702
32894
|
### `DomVirtualListRenderArgs`
|
|
32703
32895
|
|
|
32704
32896
|
```ts
|
|
@@ -32734,6 +32926,9 @@ Extends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virt
|
|
|
32734
32926
|
| `invalidate` | Clear measurements and rebuild from estimates |
|
|
32735
32927
|
| `scrollToIndex` | Scroll to an item |
|
|
32736
32928
|
| `scrollToOffset` | Scroll to a pixel offset |
|
|
32929
|
+
| `scrollToTop` | Scroll to offset `0` |
|
|
32930
|
+
| `scrollToBottom` | Scroll to the end of the list |
|
|
32931
|
+
| `isAtEnd` | `true` when within `threshold` px of the end |
|
|
32737
32932
|
| `dispose` | Teardown; idempotent |
|
|
32738
32933
|
| `disposed` | `true` after `dispose()` is called (live getter) |
|
|
32739
32934
|
| `[Symbol.dispose]` | Delegates to `dispose()` |
|
|
@@ -33613,6 +33808,76 @@ virt.scrollToTop();
|
|
|
33613
33808
|
virt.scrollToBottom({ behavior: 'smooth' });
|
|
33614
33809
|
```
|
|
33615
33810
|
|
|
33811
|
+
### Chat "stick to bottom on new message"
|
|
33812
|
+
|
|
33813
|
+
`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.
|
|
33814
|
+
|
|
33815
|
+
```ts
|
|
33816
|
+
import { createDomVirtualList } from '@vielzeug/scroll';
|
|
33817
|
+
|
|
33818
|
+
const chat = createDomVirtualList({
|
|
33819
|
+
estimateSize: 48,
|
|
33820
|
+
getItemKey: (_, m) => m.id,
|
|
33821
|
+
listElement: listEl,
|
|
33822
|
+
render: renderMessages,
|
|
33823
|
+
scrollElement: scrollEl,
|
|
33824
|
+
stickToBottom: true, // or { threshold: 80 } to widen the "still at bottom" tolerance
|
|
33825
|
+
});
|
|
33826
|
+
|
|
33827
|
+
chat.setItems(messages);
|
|
33828
|
+
|
|
33829
|
+
// New message arrives — follows only if the user hasn't scrolled up.
|
|
33830
|
+
socket.on('message', (msg) => {
|
|
33831
|
+
messages = [...messages, msg];
|
|
33832
|
+
chat.setItems(messages);
|
|
33833
|
+
});
|
|
33834
|
+
```
|
|
33835
|
+
|
|
33836
|
+
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):
|
|
33837
|
+
|
|
33838
|
+
```ts
|
|
33839
|
+
const showJumpButton = !virt.isAtEnd();
|
|
33840
|
+
```
|
|
33841
|
+
|
|
33842
|
+
## Infinite Scroll — Loading More at the End
|
|
33843
|
+
|
|
33844
|
+
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.
|
|
33845
|
+
|
|
33846
|
+
```ts
|
|
33847
|
+
import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
|
|
33848
|
+
|
|
33849
|
+
let rows = await fetchPage(0);
|
|
33850
|
+
let loading = false;
|
|
33851
|
+
|
|
33852
|
+
let virt: Virtualizer;
|
|
33853
|
+
virt = createVirtualizer(scrollEl, {
|
|
33854
|
+
count: rows.length,
|
|
33855
|
+
estimateSize: 36,
|
|
33856
|
+
onChange: ({ items, totalSize }) => {
|
|
33857
|
+
listEl.style.height = `${totalSize}px`;
|
|
33858
|
+
listEl.innerHTML = '';
|
|
33859
|
+
|
|
33860
|
+
for (const item of items) {
|
|
33861
|
+
const el = document.createElement('div');
|
|
33862
|
+
el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
|
|
33863
|
+
el.textContent = rows[item.index]?.label ?? '';
|
|
33864
|
+
listEl.appendChild(el);
|
|
33865
|
+
}
|
|
33866
|
+
|
|
33867
|
+
if (!loading && virt.isAtEnd(200)) {
|
|
33868
|
+
loading = true;
|
|
33869
|
+
fetchPage(rows.length).then((nextRows) => {
|
|
33870
|
+
rows = [...rows, ...nextRows];
|
|
33871
|
+
virt.update({ count: rows.length });
|
|
33872
|
+
loading = false;
|
|
33873
|
+
});
|
|
33874
|
+
}
|
|
33875
|
+
},
|
|
33876
|
+
});
|
|
33877
|
+
```
|
|
33878
|
+
|
|
33879
|
+
`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.
|
|
33880
|
+
|
|
33616
33881
|
## Shared Measurement Cache
|
|
33617
33882
|
|
|
33618
33883
|
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 +33964,7 @@ Scroll is rendering-layer agnostic. The pattern is always the same: create the v
|
|
|
33699
33964
|
|
|
33700
33965
|
```tsx [React]
|
|
33701
33966
|
import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
|
|
33702
|
-
import { useEffect, useRef } from 'react';
|
|
33967
|
+
import { useEffect, useLayoutEffect, useRef } from 'react';
|
|
33703
33968
|
|
|
33704
33969
|
interface Row {
|
|
33705
33970
|
id: number;
|
|
@@ -33734,7 +33999,10 @@ function VirtualList({ rows }: { rows: Row[] }) {
|
|
|
33734
33999
|
return () => virt.dispose();
|
|
33735
34000
|
}, []); // attach once
|
|
33736
34001
|
|
|
33737
|
-
useEffect
|
|
34002
|
+
// useLayoutEffect, not useEffect: syncs count before paint. With useEffect,
|
|
34003
|
+
// the DOM (and anything reading `rows`) paints once with the new length before
|
|
34004
|
+
// the virtualizer's internal count catches up, which can render stale/out-of-bounds indices.
|
|
34005
|
+
useLayoutEffect(() => {
|
|
33738
34006
|
virtRef.current?.update({ count: rows.length });
|
|
33739
34007
|
}, [rows.length]);
|
|
33740
34008
|
|
|
@@ -33877,6 +34145,7 @@ class VirtualList extends LitElement {
|
|
|
33877
34145
|
### Pitfalls
|
|
33878
34146
|
|
|
33879
34147
|
- **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.
|
|
34148
|
+
- **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
34149
|
- **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`.
|
|
33881
34150
|
- **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
34151
|
- **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes.
|
|
@@ -34090,7 +34359,7 @@ console.log(source.current, source.meta.totalItems);
|
|
|
34090
34359
|
|
|
34091
34360
|
| Symbol | Purpose | Execution mode | Common gotcha |
|
|
34092
34361
|
| -------------------------------------- | --------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------- |
|
|
34093
|
-
| `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn`
|
|
34362
|
+
| `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn` JSON-stringifies each item for substring matching |
|
|
34094
34363
|
| `createRemoteSource()` | Async server-backed collection with page navigation | Async | Fetches on creation; set `autoFetch: false` to delay |
|
|
34095
34364
|
| `createCursorSource()` | Async collection navigated by cursor tokens | Async | `next()`/`prev()` are no-ops when the cursor is absent |
|
|
34096
34365
|
| `createInfiniteSource()` | Async append-mode (infinite scroll) collection | Async | `loadMore()` is a no-op once `meta.hasMore` is `false` |
|
|
@@ -34107,6 +34376,7 @@ console.log(source.current, source.meta.totalItems);
|
|
|
34107
34376
|
| `filterContains()` | Preset predicate: case-insensitive substring match | Sync | Matches against a getter's string value |
|
|
34108
34377
|
| `filterEquals()` | Preset predicate: strict equality match | Sync | Uses `Object.is` semantics |
|
|
34109
34378
|
| `filterRange()` | Preset predicate: inclusive min/max range | Sync | Works with numbers and Dates |
|
|
34379
|
+
| `searchBy()` | Preset search builder: field-based matching for `LocalSourceConfig.searchFn` | Sync | Prefer this over default JSON-stringify search on large collections |
|
|
34110
34380
|
| `sortBy()` | Preset comparator: sort by a getter value | Sync | Supports `'asc'` / `'desc'`; handles strings, numbers, Dates |
|
|
34111
34381
|
| `encodeQuery()` | Serialize source query to URL params | Sync | Filter and sort are JSON-stringified |
|
|
34112
34382
|
| `decodeQuery()` | Deserialize URL params (or `URLSearchParams`) to a source query | Sync | Malformed JSON is silently dropped by default |
|
|
@@ -34144,7 +34414,7 @@ type LocalSourceConfig = {
|
|
|
34144
34414
|
};
|
|
34145
34415
|
```
|
|
34146
34416
|
|
|
34147
|
-
The default `searchFn` performs a case-insensitive JSON substring match —
|
|
34417
|
+
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
34418
|
|
|
34149
34419
|
`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
34420
|
|
|
@@ -34197,7 +34467,7 @@ type RemoteConfig = {
|
|
|
34197
34467
|
```
|
|
34198
34468
|
|
|
34199
34469
|
`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.
|
|
34470
|
+
`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
34471
|
|
|
34202
34472
|
**Returns:** `RemoteSource` — async server-backed source with page navigation and optimistic update support.
|
|
34203
34473
|
|
|
@@ -34830,13 +35100,23 @@ createLocalSource(data, {
|
|
|
34830
35100
|
debounceMs: 300, // debounce delay for source.search() (default: 300)
|
|
34831
35101
|
filter: (u) => u.active, // initial synchronous filter predicate
|
|
34832
35102
|
sort: (a, b) => a.name.localeCompare(b.name), // initial sorter
|
|
34833
|
-
searchFn: (
|
|
35103
|
+
searchFn: searchBy([(u) => u.name, (u) => u.email]), // override default search
|
|
34834
35104
|
// Async variants — enable Web Worker offloading via @vielzeug/familiar:
|
|
34835
35105
|
filterAsync: async (items, signal) => items.filter(/* expensive filter */),
|
|
34836
35106
|
sortAsync: async (items, signal) => [...items].sort(/* expensive sort */),
|
|
34837
35107
|
});
|
|
34838
35108
|
```
|
|
34839
35109
|
|
|
35110
|
+
For large in-memory datasets, prefer the `searchBy(...)` preset over the default `JSON.stringify` search:
|
|
35111
|
+
|
|
35112
|
+
```ts
|
|
35113
|
+
import { createLocalSource, searchBy } from '@vielzeug/sourcerer';
|
|
35114
|
+
|
|
35115
|
+
const source = createLocalSource(users, {
|
|
35116
|
+
searchFn: searchBy([(u) => u.name, (u) => u.email]),
|
|
35117
|
+
});
|
|
35118
|
+
```
|
|
35119
|
+
|
|
34840
35120
|
`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
35121
|
|
|
34842
35122
|
### Mutations
|
|
@@ -34907,6 +35187,7 @@ createRemoteSource({
|
|
|
34907
35187
|
```
|
|
34908
35188
|
|
|
34909
35189
|
`staleTime` compares the **query key** — navigating to a different page always fetches even when the previous result is still within the stale window.
|
|
35190
|
+
When an `optimisticUpdate()` is active, `refresh()` always fetches even within `staleTime` so the optimistic state can settle deterministically.
|
|
34910
35191
|
|
|
34911
35192
|
### The `fetch` callback
|
|
34912
35193
|
|
|
@@ -35328,6 +35609,7 @@ effect(() => {
|
|
|
35328
35609
|
- Always call the unsubscribe function returned by `subscribe()` when the component is torn down.
|
|
35329
35610
|
- For URL sync, use `decodeQuery()` + `applyQuery()` rather than reconstructing source state from params manually.
|
|
35330
35611
|
- Use `staleTime` with `refreshInterval` for stale-while-revalidate patterns on dashboards.
|
|
35612
|
+
- If you use `optimisticUpdate()`, call `refresh()` after mutation confirmation; it bypasses `staleTime` while optimistic state is active.
|
|
35331
35613
|
- Only one `optimisticUpdate()` can be active at a time — always handle the thrown error or check before calling.
|
|
35332
35614
|
- 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
35615
|
- For infinite sources, pass `{ limit: query.limit, search: query.search }` to `applyQuery()` for URL state sync — `page` is not restorable since items accumulate across pages.
|
|
@@ -35366,7 +35648,7 @@ effect(() => {
|
|
|
35366
35648
|
|
|
35367
35649
|
**Category:** validation
|
|
35368
35650
|
**Keywords:** schema, validation, parsing, json-schema, locale, typescript, descriptors
|
|
35369
|
-
**Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, setMessages, setLogger
|
|
35651
|
+
**Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, createParseContext, setMessages, setLogger (+4 more)
|
|
35370
35652
|
**Related:** forge, courier, vault
|
|
35371
35653
|
|
|
35372
35654
|
### Overview
|
|
@@ -35501,7 +35783,9 @@ const user = User.parse(payload);
|
|
|
35501
35783
|
| `Schema.parseAsync()` / `safeParseAsync()` | Validate including async `validate()` callbacks | Async | Required when any nested rule uses an async `validate()` callback. |
|
|
35502
35784
|
| `descriptorToJsonSchema()` | Convert a `SchemaDescriptor` to JSON Schema | Sync setup | Uses `toDescriptor()` output, not custom transforms. |
|
|
35503
35785
|
| `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
|
|
35786
|
+
| `setMessages()` / `setLogger()` / `resetMessages()` | Override validation messages and warning logger globally | Sync setup | `setMessages()` replaces the active message set each call. |
|
|
35787
|
+
| `createParseContext()` | Create request-scoped message overrides for a parse call | Sync setup | Overrides apply only to calls that receive the returned context. |
|
|
35788
|
+
| `withMessages()` / `withLogger()` | Run a callback with temporary global message/logger overrides | Sync/async setup | Restores previous global state after the callback settles. |
|
|
35505
35789
|
| `SpellValidationError` | Inspect validation failures | Sync/async failures | `format()` returns nested objects, `flatten()` returns path arrays. |
|
|
35506
35790
|
| `prependIssuePath()` | Prefix a path segment to an array of issues | Sync | Use inside custom parsers that delegate to inner schemas. |
|
|
35507
35791
|
|
|
@@ -35518,7 +35802,7 @@ Use this table to scan every runtime export.
|
|
|
35518
35802
|
| Category | Exports |
|
|
35519
35803
|
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35520
35804
|
| Classes | `Schema`, `PipeSchema`, `SpellError`, `SpellValidationError` |
|
|
35521
|
-
| Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages` |
|
|
35805
|
+
| Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages`, `createParseContext`, `withMessages`, `withLogger` |
|
|
35522
35806
|
| Descriptor helpers | `descriptorToJsonSchema`, `schemaToJsonSchema` |
|
|
35523
35807
|
| Pure validators | `hasMaxLength`, `hasMinLength`, `isArray`, `isBoolean`, `isDate`, `isInteger`, `isMultipleOf`, `isNegative`, `isNonNegative`, `isNullOrUndefined`, `isNumber`, `isPositive`, `isString`, `isInRange` |
|
|
35524
35808
|
| 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 +35900,7 @@ Builder reference:
|
|
|
35616
35900
|
| `s.or(a, b)` | `UnionSchema` | Alias for `s.union()` with exactly two schemas. |
|
|
35617
35901
|
| `s.and(a, b)` | `IntersectSchema` | Alias for `s.intersect()` with two schemas. |
|
|
35618
35902
|
| `s.intersect(...items)` | `IntersectSchema` | Merges compatible outputs deeply and safely. |
|
|
35619
|
-
| `s.variant(key, map)` | `VariantSchema` | Discriminated object union
|
|
35903
|
+
| `s.variant(key, map)` | `VariantSchema` | Discriminated object union with async-aware branch parsing in `parseAsync()`. |
|
|
35620
35904
|
| `s.lazy(getter)` | `LazySchema` | Recursive schema definitions. |
|
|
35621
35905
|
| `s.instanceof(cls)` | `InstanceOfSchema` | Runtime class instance checks. |
|
|
35622
35906
|
|
|
@@ -36496,7 +36780,7 @@ const Signup = s.object({ confirm: s.string(), password: s.string() }).validate(
|
|
|
36496
36780
|
});
|
|
36497
36781
|
```
|
|
36498
36782
|
|
|
36499
|
-
Async rules work in the same method. Spell awaits them
|
|
36783
|
+
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
36784
|
|
|
36501
36785
|
```ts
|
|
36502
36786
|
import { s } from '@vielzeug/spell';
|
|
@@ -36599,7 +36883,7 @@ Descriptors are serializable snapshots of the schema structure. Use `toDescripto
|
|
|
36599
36883
|
|
|
36600
36884
|
## Messages
|
|
36601
36885
|
|
|
36602
|
-
Use `setMessages()` to replace the active validation message catalog. Each call replaces the current overrides — it does not accumulate.
|
|
36886
|
+
Use `setMessages()` to replace the active validation message catalog globally. Each call replaces the current overrides — it does not accumulate.
|
|
36603
36887
|
|
|
36604
36888
|
```ts
|
|
36605
36889
|
import { resetMessages, setMessages } from '@vielzeug/spell';
|
|
@@ -36627,6 +36911,35 @@ setLogger(null); // silence
|
|
|
36627
36911
|
setLogger((msg) => myLogger.warn(msg)); // redirect
|
|
36628
36912
|
```
|
|
36629
36913
|
|
|
36914
|
+
Use `createParseContext()` when you need request-scoped message overrides without mutating global state.
|
|
36915
|
+
|
|
36916
|
+
```ts
|
|
36917
|
+
import { createParseContext, s } from '@vielzeug/spell';
|
|
36918
|
+
|
|
36919
|
+
const User = s.object({ email: s.string().email() });
|
|
36920
|
+
|
|
36921
|
+
User.safeParse(
|
|
36922
|
+
{ email: 'ada@example.com', extra: true },
|
|
36923
|
+
createParseContext({ object: { invalidKeys: () => 'No unknown keys in this endpoint' } }),
|
|
36924
|
+
);
|
|
36925
|
+
```
|
|
36926
|
+
|
|
36927
|
+
Use `withMessages()` / `withLogger()` to apply temporary global overrides inside a bounded sync or async callback.
|
|
36928
|
+
|
|
36929
|
+
```ts
|
|
36930
|
+
import { s, withLogger, withMessages } from '@vielzeug/spell';
|
|
36931
|
+
|
|
36932
|
+
const Email = s.string().email();
|
|
36933
|
+
|
|
36934
|
+
await withMessages({ string: { email: () => 'Scoped email' } }, async () => {
|
|
36935
|
+
Email.safeParse('bad'); // issue message uses "Scoped email"
|
|
36936
|
+
});
|
|
36937
|
+
|
|
36938
|
+
withLogger((msg) => myLogger.warn(msg), () => {
|
|
36939
|
+
s.string().regex(/^a$/).regex(/^b$/);
|
|
36940
|
+
});
|
|
36941
|
+
```
|
|
36942
|
+
|
|
36630
36943
|
To integrate with `@vielzeug/lingua`, call `setMessages()` from your locale change callback:
|
|
36631
36944
|
|
|
36632
36945
|
```ts
|
|
@@ -40270,888 +40583,267 @@ const db = createIndexedDB({
|
|
|
40270
40583
|
## @vielzeug/ward
|
|
40271
40584
|
|
|
40272
40585
|
**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
40586
|
|
|
40277
40587
|
### Overview
|
|
40278
40588
|
|
|
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
|
-
```
|
|
40589
|
+
`@vielzeug/ward` is a zero-dependency authorization engine for role/resource/action policies.
|
|
40333
40590
|
|
|
40334
40591
|
## Quick Start
|
|
40335
40592
|
|
|
40336
40593
|
```ts
|
|
40337
|
-
import { ANONYMOUS, WILDCARD, allow, createWard, deny,
|
|
40594
|
+
import { ANONYMOUS, WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
|
|
40338
40595
|
|
|
40339
40596
|
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
|
|
40597
|
+
...allow([ANONYMOUS, 'viewer'], 'posts', ['read']),
|
|
40598
|
+
...allow('editor', 'posts', ['update'], { when: owns('authorId') }),
|
|
40345
40599
|
...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
40600
|
]);
|
|
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
|
-
|
|
40491
|
-
Creates an immutable ward instance with the given rules. All rules are compiled once at creation time — pass a new array to update the policy.
|
|
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
40601
|
|
|
40561
|
-
|
|
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']);
|
|
40602
|
+
const principal = { id: 'u1', roles: ['editor'] };
|
|
40570
40603
|
|
|
40571
|
-
|
|
40572
|
-
|
|
40573
|
-
|
|
40604
|
+
const decision = ward.explain({
|
|
40605
|
+
principal,
|
|
40606
|
+
resource: 'posts',
|
|
40607
|
+
action: 'update',
|
|
40608
|
+
data: { authorId: 'u2' },
|
|
40574
40609
|
});
|
|
40575
40610
|
```
|
|
40576
40611
|
|
|
40577
|
-
|
|
40612
|
+
`explain()` returns a discriminated decision (`allowed: true | false`) and optional matching `rule`.
|
|
40578
40613
|
|
|
40579
|
-
|
|
40614
|
+
## Bound View
|
|
40580
40615
|
|
|
40581
|
-
|
|
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.
|
|
40591
|
-
|
|
40592
|
-
**Returns:** `WardDecision`
|
|
40593
|
-
|
|
40594
|
-
**Example:**
|
|
40616
|
+
Use `forUser()` when checking many permissions for the same principal:
|
|
40595
40617
|
|
|
40596
40618
|
```ts
|
|
40597
|
-
const
|
|
40598
|
-
|
|
40599
|
-
if (!decision.allowed) {
|
|
40600
|
-
console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
|
|
40601
|
-
if (decision.reason === 'explicit-deny') {
|
|
40602
|
-
console.log(decision.rule.effect); // safe — rule is present
|
|
40603
|
-
}
|
|
40604
|
-
}
|
|
40605
|
-
```
|
|
40606
|
-
|
|
40607
|
-
---
|
|
40608
|
-
|
|
40609
|
-
### `trace()`
|
|
40619
|
+
const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
|
|
40610
40620
|
|
|
40611
|
-
|
|
40612
|
-
|
|
40613
|
-
principal: Principal,
|
|
40614
|
-
resource: string,
|
|
40615
|
-
action: TAction,
|
|
40616
|
-
data?: TData,
|
|
40617
|
-
): WardTrace
|
|
40621
|
+
bound.explain({ resource: 'posts', action: 'read' });
|
|
40622
|
+
bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
|
|
40618
40623
|
```
|
|
40619
40624
|
|
|
40620
|
-
|
|
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:**
|
|
40625
|
+
## Middleware Guards
|
|
40627
40626
|
|
|
40628
40627
|
```ts
|
|
40629
|
-
|
|
40628
|
+
import { guardRequest, guardRequestWith } from '@vielzeug/ward';
|
|
40630
40629
|
|
|
40631
|
-
|
|
40632
|
-
|
|
40630
|
+
const direct = guardRequest({
|
|
40631
|
+
ward,
|
|
40632
|
+
principal,
|
|
40633
|
+
resource: 'posts',
|
|
40634
|
+
action: 'read',
|
|
40633
40635
|
});
|
|
40634
|
-
```
|
|
40635
|
-
|
|
40636
|
-
---
|
|
40637
|
-
|
|
40638
|
-
### `rulesInScope()`
|
|
40639
|
-
|
|
40640
|
-
```ts
|
|
40641
|
-
ward.rulesInScope(
|
|
40642
|
-
principal: Principal,
|
|
40643
|
-
resource: string,
|
|
40644
|
-
data?: TData,
|
|
40645
|
-
): ReadonlyArray>>
|
|
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
40636
|
|
|
40674
|
-
|
|
40675
|
-
|
|
40676
|
-
|
|
40677
|
-
|
|
40678
|
-
|
|
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
|
-
}
|
|
40637
|
+
const extracted = await guardRequestWith({
|
|
40638
|
+
ward,
|
|
40639
|
+
req,
|
|
40640
|
+
extractPrincipal: async (request) => request.user ?? null,
|
|
40641
|
+
resource: 'posts',
|
|
40642
|
+
action: 'read',
|
|
40689
40643
|
});
|
|
40690
40644
|
```
|
|
40691
40645
|
|
|
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) |
|
|
40646
|
+
See the [usage guide](./usage.md), [API reference](./api.md), and [examples](./examples/blog-roles.md).
|
|
40713
40647
|
|
|
40714
|
-
|
|
40715
|
-
|
|
40716
|
-
```ts
|
|
40717
|
-
const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
|
|
40718
|
-
|
|
40719
|
-
bound.explain('posts', 'read').allowed;
|
|
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
|
-
```
|
|
40648
|
+
### API Reference
|
|
40730
40649
|
|
|
40731
|
-
##
|
|
40650
|
+
## Core Factory
|
|
40732
40651
|
|
|
40733
|
-
### `
|
|
40652
|
+
### `createWard(rules, options?)`
|
|
40734
40653
|
|
|
40735
40654
|
```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[]
|
|
40655
|
+
createWard(
|
|
40656
|
+
rules: ReadonlyArray>>,
|
|
40657
|
+
options?: WardOptions,
|
|
40658
|
+
): Ward;
|
|
40749
40659
|
```
|
|
40750
40660
|
|
|
40751
|
-
|
|
40661
|
+
Creates an immutable ward instance.
|
|
40752
40662
|
|
|
40753
|
-
|
|
40663
|
+
## Rule Builders
|
|
40754
40664
|
|
|
40755
|
-
|
|
40665
|
+
### `allow(role, resource, actions, options?)`
|
|
40666
|
+
### `deny(role, resource, actions, options?)`
|
|
40667
|
+
### `ruleFor(effect, role, resource, actions, options?)`
|
|
40756
40668
|
|
|
40757
|
-
|
|
40758
|
-
import { WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
|
|
40669
|
+
All three return `WardRule[]` (one rule per action).
|
|
40759
40670
|
|
|
40760
|
-
|
|
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
|
-
```
|
|
40766
|
-
|
|
40767
|
-
---
|
|
40671
|
+
## Ward Methods
|
|
40768
40672
|
|
|
40769
|
-
### `
|
|
40673
|
+
### `checkAll(principal, checks)`
|
|
40770
40674
|
|
|
40771
40675
|
```ts
|
|
40772
|
-
|
|
40773
|
-
|
|
40774
|
-
|
|
40775
|
-
|
|
40776
|
-
actions: readonly (TAction | typeof WILDCARD)[],
|
|
40777
|
-
options?: { priority?: number; when?: WardPredicate },
|
|
40778
|
-
): WardRule[]
|
|
40676
|
+
checkAll(
|
|
40677
|
+
principal: UserPrincipal,
|
|
40678
|
+
checks: ReadonlyArray>,
|
|
40679
|
+
): WardDecisionResult[];
|
|
40779
40680
|
```
|
|
40780
40681
|
|
|
40781
|
-
|
|
40782
|
-
|
|
40783
|
-
**Returns:** `WardRule[]`
|
|
40784
|
-
|
|
40785
|
-
**Example:**
|
|
40682
|
+
### `explain(input)`
|
|
40786
40683
|
|
|
40787
40684
|
```ts
|
|
40788
|
-
|
|
40789
|
-
|
|
40790
|
-
ruleFor('allow', 'viewer', 'posts', ['read', 'update']);
|
|
40791
|
-
ruleFor('deny', ['blocked', 'suspended'], WILDCARD, [WILDCARD], { priority: 100 });
|
|
40685
|
+
explain(input: WardExplainInput): WardDecision;
|
|
40792
40686
|
```
|
|
40793
40687
|
|
|
40794
|
-
|
|
40795
|
-
|
|
40796
|
-
### `owns()` / `predicate`
|
|
40688
|
+
`WardExplainInput`:
|
|
40797
40689
|
|
|
40798
40690
|
```ts
|
|
40799
|
-
|
|
40800
|
-
|
|
40801
|
-
|
|
40802
|
-
|
|
40803
|
-
|
|
40804
|
-
|
|
40805
|
-
owns(attributeKey: keyof TData & string): WardPredicate;
|
|
40806
|
-
};
|
|
40691
|
+
{
|
|
40692
|
+
principal: UserPrincipal;
|
|
40693
|
+
resource: string;
|
|
40694
|
+
action: TAction;
|
|
40695
|
+
data?: TData;
|
|
40696
|
+
}
|
|
40807
40697
|
```
|
|
40808
40698
|
|
|
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:**
|
|
40699
|
+
### `trace(input)`
|
|
40818
40700
|
|
|
40819
40701
|
```ts
|
|
40820
|
-
|
|
40821
|
-
|
|
40822
|
-
allow('editor', 'posts:*', ['update'], { when: predicate.owns('authorId') });
|
|
40823
|
-
allow('user', 'posts:*', ['read'], { when: predicate.and(predicate.owns('authorId'), isBusinessHours) });
|
|
40702
|
+
trace(input: WardTraceInput): WardTrace;
|
|
40824
40703
|
```
|
|
40825
40704
|
|
|
40826
|
-
|
|
40705
|
+
Same request shape as `explain()`. Returns winner + candidate list. Does not fire logger.
|
|
40827
40706
|
|
|
40828
|
-
### `
|
|
40707
|
+
### `allowedActions(input)`
|
|
40829
40708
|
|
|
40830
40709
|
```ts
|
|
40831
|
-
|
|
40710
|
+
allowedActions(
|
|
40711
|
+
input: WardAllowedActionsInput,
|
|
40712
|
+
): TKnown[];
|
|
40832
40713
|
```
|
|
40833
40714
|
|
|
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:**
|
|
40715
|
+
Input shape:
|
|
40847
40716
|
|
|
40848
40717
|
```ts
|
|
40849
|
-
|
|
40850
|
-
|
|
40851
|
-
|
|
40852
|
-
|
|
40853
|
-
|
|
40718
|
+
{
|
|
40719
|
+
principal: UserPrincipal;
|
|
40720
|
+
resource: string;
|
|
40721
|
+
knownActions: readonly TKnown[];
|
|
40722
|
+
data?: TData;
|
|
40723
|
+
}
|
|
40854
40724
|
```
|
|
40855
40725
|
|
|
40856
|
-
|
|
40857
|
-
|
|
40858
|
-
### `patternCovers()`
|
|
40726
|
+
### `rulesInScope(input)`
|
|
40859
40727
|
|
|
40860
40728
|
```ts
|
|
40861
|
-
|
|
40729
|
+
rulesInScope(input: WardRulesInScopeInput): ReadonlyArray>>;
|
|
40862
40730
|
```
|
|
40863
40731
|
|
|
40864
|
-
|
|
40865
|
-
|
|
40866
|
-
**Example:**
|
|
40732
|
+
Input shape:
|
|
40867
40733
|
|
|
40868
40734
|
```ts
|
|
40869
|
-
|
|
40870
|
-
|
|
40871
|
-
|
|
40872
|
-
|
|
40873
|
-
|
|
40874
|
-
patternCovers('posts', 'posts:*'); // false
|
|
40735
|
+
{
|
|
40736
|
+
principal: UserPrincipal;
|
|
40737
|
+
resource: string;
|
|
40738
|
+
data?: TData;
|
|
40739
|
+
}
|
|
40875
40740
|
```
|
|
40876
40741
|
|
|
40877
|
-
|
|
40878
|
-
|
|
40879
|
-
### Middleware Factories
|
|
40880
|
-
|
|
40881
|
-
#### `guardRequest()`
|
|
40742
|
+
### `detectConflicts()`
|
|
40882
40743
|
|
|
40883
40744
|
```ts
|
|
40884
|
-
|
|
40885
|
-
ward: Ward,
|
|
40886
|
-
principal: Principal,
|
|
40887
|
-
resource: string,
|
|
40888
|
-
action: TAction,
|
|
40889
|
-
data?: TData,
|
|
40890
|
-
): GuardResult
|
|
40745
|
+
detectConflicts(): WardConflict[];
|
|
40891
40746
|
```
|
|
40892
40747
|
|
|
40893
|
-
|
|
40748
|
+
### `forUser(principal)`
|
|
40894
40749
|
|
|
40895
40750
|
```ts
|
|
40896
|
-
|
|
40897
|
-
| { granted: true; principal: Principal }
|
|
40898
|
-
| { granted: false; decision: WardDecision; principal: Principal; reason: 'explicit-deny' | 'no-matching-rule' };
|
|
40751
|
+
forUser(principal: UserPrincipal): BoundWard;
|
|
40899
40752
|
```
|
|
40900
40753
|
|
|
40901
|
-
|
|
40754
|
+
Returns a principal-bound view.
|
|
40902
40755
|
|
|
40903
|
-
|
|
40756
|
+
## `BoundWard` Methods
|
|
40904
40757
|
|
|
40905
40758
|
```ts
|
|
40906
|
-
|
|
40907
|
-
|
|
40908
|
-
|
|
40909
|
-
|
|
40910
|
-
|
|
40911
|
-
|
|
40759
|
+
interface BoundWard {
|
|
40760
|
+
checkAll(checks: ReadonlyArray>): WardDecisionResult[];
|
|
40761
|
+
explain(input: BoundWardExplainInput): WardDecision;
|
|
40762
|
+
trace(input: BoundWardTraceInput): WardTrace;
|
|
40763
|
+
allowedActions(input: BoundWardAllowedActionsInput): TKnown[];
|
|
40764
|
+
rulesInScope(input: BoundWardRulesInScopeInput): ReadonlyArray>>;
|
|
40912
40765
|
}
|
|
40913
40766
|
```
|
|
40914
40767
|
|
|
40915
|
-
|
|
40916
|
-
|
|
40917
|
-
#### `guardRequestWith()`
|
|
40768
|
+
Bound input shapes remove `principal`:
|
|
40918
40769
|
|
|
40919
40770
|
```ts
|
|
40920
|
-
|
|
40921
|
-
|
|
40922
|
-
|
|
40923
|
-
extractPrincipal: (req: TReq) => Principal | Promise,
|
|
40924
|
-
resource: string,
|
|
40925
|
-
action: TAction,
|
|
40926
|
-
data?: TData,
|
|
40927
|
-
): Promise>
|
|
40771
|
+
{ resource: string; action: TAction; data?: TData } // explain/trace
|
|
40772
|
+
{ resource: string; knownActions: readonly TKnown[]; data?: TData } // allowedActions
|
|
40773
|
+
{ resource: string; data?: TData } // rulesInScope
|
|
40928
40774
|
```
|
|
40929
40775
|
|
|
40930
|
-
|
|
40776
|
+
## Predicate Helpers
|
|
40931
40777
|
|
|
40932
|
-
|
|
40778
|
+
### `predicate.owns(attributeKey)`
|
|
40779
|
+
### `predicate.and(...predicates)`
|
|
40780
|
+
### `predicate.or(...predicates)`
|
|
40781
|
+
### `predicate.not(predicate)`
|
|
40782
|
+
### `owns(attributeKey)` (alias)
|
|
40933
40783
|
|
|
40934
|
-
|
|
40935
|
-
import { guardRequestWith } from '@vielzeug/ward';
|
|
40784
|
+
Predicates run synchronously. Returning a Promise throws `WardPredicateError`.
|
|
40936
40785
|
|
|
40937
|
-
|
|
40786
|
+
## Pattern Helpers
|
|
40938
40787
|
|
|
40939
|
-
|
|
40940
|
-
|
|
40941
|
-
}
|
|
40942
|
-
```
|
|
40788
|
+
### `matchesPattern(pattern, value): boolean`
|
|
40789
|
+
### `patternCovers(broad, narrow): boolean`
|
|
40943
40790
|
|
|
40944
|
-
##
|
|
40791
|
+
## Middleware Guards
|
|
40945
40792
|
|
|
40946
|
-
### `
|
|
40793
|
+
### `guardRequest(input)`
|
|
40947
40794
|
|
|
40948
40795
|
```ts
|
|
40949
|
-
|
|
40950
|
-
|
|
40951
|
-
|
|
40952
|
-
attributes?: Record;
|
|
40953
|
-
};
|
|
40954
|
-
```
|
|
40955
|
-
|
|
40956
|
-
### `Principal`
|
|
40957
|
-
|
|
40958
|
-
```ts
|
|
40959
|
-
type Principal = UserPrincipal | null;
|
|
40796
|
+
guardRequest(
|
|
40797
|
+
input: GuardRequestInput,
|
|
40798
|
+
): GuardResult;
|
|
40960
40799
|
```
|
|
40961
40800
|
|
|
40962
|
-
|
|
40963
|
-
|
|
40964
|
-
### `RuleContext`
|
|
40801
|
+
Input:
|
|
40965
40802
|
|
|
40966
40803
|
```ts
|
|
40967
|
-
|
|
40804
|
+
{
|
|
40805
|
+
ward: Ward;
|
|
40968
40806
|
principal: UserPrincipal;
|
|
40807
|
+
resource: string;
|
|
40808
|
+
action: TAction;
|
|
40969
40809
|
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
40810
|
}
|
|
41017
|
-
|
|
41018
|
-
// Generic narrowing:
|
|
41019
|
-
if ('rule' in d) console.log(d.rule);
|
|
41020
40811
|
```
|
|
41021
40812
|
|
|
41022
|
-
### `
|
|
40813
|
+
### `guardRequestWith(input)`
|
|
41023
40814
|
|
|
41024
40815
|
```ts
|
|
41025
|
-
|
|
41026
|
-
|
|
41027
|
-
|
|
41028
|
-
data?: TData;
|
|
41029
|
-
};
|
|
40816
|
+
guardRequestWith(
|
|
40817
|
+
input: GuardRequestWithInput,
|
|
40818
|
+
): Promise>;
|
|
41030
40819
|
```
|
|
41031
40820
|
|
|
41032
|
-
|
|
41033
|
-
|
|
41034
|
-
Structurally identical to `WardDecision` plus the request fields — narrow `rule` with the same `if (ctx.allowed)` pattern used for decisions:
|
|
40821
|
+
Input:
|
|
41035
40822
|
|
|
41036
40823
|
```ts
|
|
41037
|
-
|
|
40824
|
+
{
|
|
40825
|
+
ward: Ward;
|
|
40826
|
+
req: TReq;
|
|
40827
|
+
extractPrincipal: PrincipalExtractor;
|
|
40828
|
+
resource: string;
|
|
41038
40829
|
action: TAction;
|
|
41039
40830
|
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
|
-
};
|
|
40831
|
+
}
|
|
41088
40832
|
```
|
|
41089
40833
|
|
|
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
|
-
};
|
|
40834
|
+
## Devtools
|
|
41100
40835
|
|
|
41101
|
-
|
|
41102
|
-
candidates: WardTraceCandidate[];
|
|
41103
|
-
decision: WardDecision;
|
|
41104
|
-
};
|
|
41105
|
-
```
|
|
40836
|
+
### `debugWard(ward, logger?)`
|
|
41106
40837
|
|
|
41107
|
-
|
|
40838
|
+
Sub-path import: `@vielzeug/ward/devtools`.
|
|
41108
40839
|
|
|
41109
40840
|
```ts
|
|
41110
40841
|
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
40842
|
```
|
|
41135
40843
|
|
|
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
|
-
```
|
|
41143
|
-
|
|
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
40844
|
### Usage Guide
|
|
41151
40845
|
|
|
41152
|
-
##
|
|
41153
|
-
|
|
41154
|
-
Create a ward instance with an array of rules. Rules are compiled once at creation time.
|
|
40846
|
+
## Create a Ward
|
|
41155
40847
|
|
|
41156
40848
|
```ts
|
|
41157
40849
|
import { WILDCARD, createWard } from '@vielzeug/ward';
|
|
@@ -41159,592 +40851,124 @@ import { WILDCARD, createWard } from '@vielzeug/ward';
|
|
|
41159
40851
|
const ward = createWard([
|
|
41160
40852
|
{ role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
|
|
41161
40853
|
{ role: 'editor', resource: 'posts', action: 'update', effect: 'allow' },
|
|
41162
|
-
// High-priority deny blocks the blocked role from every action on posts
|
|
41163
40854
|
{ role: 'blocked', resource: 'posts', action: WILDCARD, effect: 'deny', priority: 100 },
|
|
41164
40855
|
]);
|
|
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
40856
|
```
|
|
41170
40857
|
|
|
41171
|
-
|
|
41172
|
-
|
|
41173
|
-
## Rule Factories
|
|
40858
|
+
Rules are immutable after creation. Create a new ward to update policy.
|
|
41174
40859
|
|
|
41175
|
-
|
|
40860
|
+
## Explain a Decision
|
|
41176
40861
|
|
|
41177
40862
|
```ts
|
|
41178
|
-
|
|
41179
|
-
|
|
41180
|
-
|
|
41181
|
-
|
|
41182
|
-
|
|
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
|
-
```
|
|
41223
|
-
|
|
41224
|
-
`matchesPattern(pattern, value)` is exported for custom integration code. `patternCovers(broad, narrow)` is exported to test whether one pattern statically covers another (used by `detectConflicts`).
|
|
41225
|
-
|
|
41226
|
-
## Check Permissions
|
|
41227
|
-
|
|
41228
|
-
```ts
|
|
41229
|
-
const principal = { id: 'u1', roles: ['editor'] };
|
|
41230
|
-
|
|
41231
|
-
ward.explain(principal, 'posts', 'read').allowed;
|
|
41232
|
-
ward.explain(principal, 'posts', 'delete').allowed;
|
|
41233
|
-
ward.explain(null, 'posts', 'read').allowed;
|
|
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);
|
|
40863
|
+
const decision = ward.explain({
|
|
40864
|
+
principal: { id: 'u1', roles: ['editor'] },
|
|
40865
|
+
resource: 'posts',
|
|
40866
|
+
action: 'update',
|
|
40867
|
+
data: { authorId: 'u1' },
|
|
40868
|
+
});
|
|
41267
40869
|
|
|
41268
|
-
|
|
41269
|
-
|
|
40870
|
+
if (decision.allowed) {
|
|
40871
|
+
console.log(decision.rule);
|
|
40872
|
+
} else {
|
|
40873
|
+
console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
|
|
40874
|
+
}
|
|
41270
40875
|
```
|
|
41271
40876
|
|
|
41272
|
-
## Batch Decisions
|
|
40877
|
+
## Batch Decisions
|
|
41273
40878
|
|
|
41274
40879
|
```ts
|
|
41275
|
-
const
|
|
40880
|
+
const results = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
|
|
41276
40881
|
{ resource: 'posts', action: 'read' },
|
|
41277
40882
|
{ resource: 'posts', action: 'update', data: { authorId: 'u1' } },
|
|
41278
40883
|
]);
|
|
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
40884
|
```
|
|
41304
40885
|
|
|
41305
|
-
|
|
41306
|
-
|
|
41307
|
-
## Inspect Rule Scope with `rulesInScope`
|
|
40886
|
+
## Bound Ward (`forUser`)
|
|
41308
40887
|
|
|
41309
40888
|
```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
40889
|
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
40890
|
|
|
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
|
-
}
|
|
40891
|
+
bound.explain({ resource: 'posts', action: 'read' });
|
|
40892
|
+
bound.trace({ resource: 'posts', action: 'update', data: { authorId: 'u1' } });
|
|
40893
|
+
bound.rulesInScope({ resource: 'posts' });
|
|
40894
|
+
bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
|
|
41332
40895
|
```
|
|
41333
40896
|
|
|
41334
|
-
`
|
|
41335
|
-
|
|
41336
|
-
| Variant | `allowed` | `reason` | `rule` |
|
|
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 |
|
|
40897
|
+
`forUser()` snapshots the principal. Re-bind when roles/identity change.
|
|
41341
40898
|
|
|
41342
|
-
|
|
40899
|
+
## Allowed Actions
|
|
41343
40900
|
|
|
41344
|
-
|
|
41345
|
-
|
|
41346
|
-
`trace()` returns the complete decision trace: every rule that matched the request before the winner was selected, with per-candidate scoring details.
|
|
40901
|
+
`allowedActions()` evaluates a provided action set:
|
|
41347
40902
|
|
|
41348
40903
|
```ts
|
|
41349
|
-
const
|
|
41350
|
-
|
|
41351
|
-
|
|
41352
|
-
|
|
40904
|
+
const actions = ward.allowedActions({
|
|
40905
|
+
principal: { id: 'u1', roles: ['admin'] },
|
|
40906
|
+
resource: 'posts',
|
|
40907
|
+
knownActions: ['read', 'update', 'delete'] as const,
|
|
41353
40908
|
});
|
|
41354
40909
|
```
|
|
41355
40910
|
|
|
41356
|
-
|
|
41357
|
-
|
|
41358
|
-
`trace()` is also available on `BoundWard`: `bound.trace(resource, action, data?)`.
|
|
40911
|
+
It does not fire the logger.
|
|
41359
40912
|
|
|
41360
|
-
##
|
|
41361
|
-
|
|
41362
|
-
`detectConflicts()` performs a static O(n²) analysis of your rule set and returns all detected conflicts. The result is lazily computed — every call after the first returns the same array reference.
|
|
40913
|
+
## Rule Introspection
|
|
41363
40914
|
|
|
41364
40915
|
```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
|
-
}
|
|
40916
|
+
const scoped = ward.rulesInScope({
|
|
40917
|
+
principal: { id: 'u1', roles: ['editor'] },
|
|
40918
|
+
resource: 'posts',
|
|
41373
40919
|
});
|
|
41374
40920
|
```
|
|
41375
40921
|
|
|
41376
|
-
|
|
40922
|
+
Use optional `data` to filter predicate-gated matches.
|
|
41377
40923
|
|
|
41378
|
-
|
|
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.
|
|
41382
|
-
|
|
41383
|
-
To surface conflicts eagerly at startup:
|
|
40924
|
+
## Trace Candidates
|
|
41384
40925
|
|
|
41385
40926
|
```ts
|
|
41386
|
-
|
|
41387
|
-
|
|
41388
|
-
|
|
41389
|
-
|
|
40927
|
+
const trace = ward.trace({
|
|
40928
|
+
principal: { id: 'u1', roles: ['editor', 'blocked'] },
|
|
40929
|
+
resource: 'posts',
|
|
40930
|
+
action: 'read',
|
|
41390
40931
|
});
|
|
41391
40932
|
|
|
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
|
-
},
|
|
40933
|
+
trace.candidates.forEach((c) => {
|
|
40934
|
+
console.log(c.index, c.priority, c.score, c.won);
|
|
41504
40935
|
});
|
|
41505
40936
|
```
|
|
41506
40937
|
|
|
41507
|
-
|
|
41508
|
-
Enumeration and introspection helpers (`allowedActions()`, `rulesInScope()`, `detectConflicts()`) stay side-effect free.
|
|
40938
|
+
`trace()` does not fire the logger.
|
|
41509
40939
|
|
|
41510
|
-
|
|
40940
|
+
## Predicate Helpers
|
|
41511
40941
|
|
|
41512
40942
|
```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
|
-
]);
|
|
40943
|
+
import { owns, predicate } from '@vielzeug/ward';
|
|
41561
40944
|
|
|
41562
|
-
const
|
|
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;
|
|
41607
|
-
|
|
41608
|
-
{#if canEdit}Edit{/if}
|
|
40945
|
+
const isOwner = owns('authorId');
|
|
40946
|
+
const canEdit = predicate.and(isOwner, ({ principal }) => principal !== null);
|
|
41609
40947
|
```
|
|
41610
40948
|
|
|
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.
|
|
40949
|
+
Async predicates are rejected at runtime with `WardPredicateError`.
|
|
41616
40950
|
|
|
41617
|
-
##
|
|
41618
|
-
|
|
41619
|
-
Ward has no framework-specific middleware — `guardRequest` and `guardRequestWith` are small, framework-agnostic helpers you wire into a 2–3 line adapter for whichever server you use.
|
|
40951
|
+
## Framework Guards
|
|
41620
40952
|
|
|
41621
40953
|
```ts
|
|
41622
40954
|
import { guardRequest, guardRequestWith } from '@vielzeug/ward';
|
|
41623
40955
|
|
|
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 });
|
|
40956
|
+
const direct = guardRequest({
|
|
40957
|
+
ward,
|
|
40958
|
+
principal: { id: 'u1', roles: ['viewer'] },
|
|
40959
|
+
resource: 'posts',
|
|
40960
|
+
action: 'read',
|
|
41641
40961
|
});
|
|
41642
|
-
```
|
|
41643
|
-
|
|
41644
|
-
### Hono
|
|
41645
40962
|
|
|
41646
|
-
|
|
41647
|
-
|
|
41648
|
-
|
|
41649
|
-
|
|
40963
|
+
const extracted = await guardRequestWith({
|
|
40964
|
+
ward,
|
|
40965
|
+
req,
|
|
40966
|
+
extractPrincipal: async (request) => request.user ?? null,
|
|
40967
|
+
resource: 'posts',
|
|
40968
|
+
action: 'read',
|
|
41650
40969
|
});
|
|
41651
40970
|
```
|
|
41652
40971
|
|
|
41653
|
-
## Debug Mode
|
|
41654
|
-
|
|
41655
|
-
Import `debugWard` from the dedicated sub-path to create a ward with decision logging pre-enabled. The sub-path is tree-shaken from production bundles when not imported.
|
|
41656
|
-
|
|
41657
|
-
```ts
|
|
41658
|
-
import { debugWard } from '@vielzeug/ward/devtools';
|
|
41659
|
-
|
|
41660
|
-
const permit = debugWard([
|
|
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),
|
|
41682
|
-
});
|
|
41683
|
-
```
|
|
41684
|
-
|
|
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
40972
|
### Examples
|
|
41749
40973
|
|
|
41750
40974
|
## Examples
|