@vielzeug/codex 2.2.8 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,8 +2,8 @@
2
2
  "apiSource": "export * from './drop-zone';\nexport { DndError, DndScopeError } from './errors';\nexport * from './sortable';\nexport * from './types';\n",
3
3
  "docs": {
4
4
  "index": "---\ntitle: Dnd — Drag-and-drop primitives for the DOM\ndescription: Framework-agnostic drag-and-drop. Drop zones with MIME filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.\npackage: dnd\ncategory: ui-interaction\nkeywords: [drag-drop, sortable, file-upload, drop-zone, dnd, reorder]\nrelated: [ore, scroll, refine]\nexports: [createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"dnd\" />\n\n## Why Dnd?\n\nThe HTML5 Drag & Drop API requires careful counter tracking to avoid hover state flicker, has no MIME type pre-filtering, and provides no sortable list abstraction.\n\n```ts\n// Before — raw HTML5 Drag & Drop\nlet enterCount = 0;\ndropzone.addEventListener('dragenter', () => {\n enterCount++;\n dropzone.classList.add('over');\n});\ndropzone.addEventListener('dragleave', () => {\n if (--enterCount === 0) dropzone.classList.remove('over');\n});\ndropzone.addEventListener('dragover', (e) => e.preventDefault());\ndropzone.addEventListener('drop', (e) => {\n e.preventDefault();\n enterCount = 0;\n const files = [...e.dataTransfer!.files];\n if (!files.every((f) => f.type.startsWith('image/'))) return showError('Images only');\n uploadFiles(files);\n});\n\n// After — Dnd\nimport { createDropZone } from '@vielzeug/dnd';\nconst zone = createDropZone({\n element: dropzone,\n accept: ['image/*'],\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError(`${files.length} file(s) not accepted`),\n onHoverChange: (hovered) => dropzone.classList.toggle('over', hovered),\n});\n```\n\n| Feature | DND | SortableJS | dnd-kit |\n| ------------------- | -------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ |\n| Bundle size | <PackageInfo package=\"dnd\" type=\"size\" /> | ~15 kB | ~30 kB |\n| Framework agnostic | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| MIME type filtering | <ore-icon name=\"check\" size=\"16\"></ore-icon> Pre-validated | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Counter-based hover | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | N/A |\n| Sortable lists | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Drag handles | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| `using` support | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Touch support | <ore-icon name=\"check\" size=\"16\"></ore-icon> Scoped opt-in | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.\n\n**Consider dnd-kit** if you are building a React app and need complex multi-container drag interactions or accessibility-first sortable trees.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/dnd\n```\n\n```sh [npm]\nnpm install @vielzeug/dnd\n```\n\n```sh [yarn]\nyarn add @vielzeug/dnd\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createDropZone, createSortable } from '@vielzeug/dnd';\n\n// File drop zone — with async validation and paste support\nconst dropzone = document.getElementById('dropzone')!;\n\nusing zone = createDropZone({\n element: dropzone,\n accept: ['image/*', '.pdf'],\n paste: true,\n onValidate: (files) => files.every((file) => file.size <= 5_000_000),\n onDrop: (files) => console.log('Upload', files),\n onDropRejected: (files) => {\n console.warn(`${files.length} file(s) rejected`);\n },\n onHoverChange: (hovered) => {\n dropzone.classList.toggle('drag-over', hovered);\n },\n});\n\n// Sortable list — with revert support for optimistic updates\nlet currentOrder = ['a', 'b', 'c'];\n\nusing sortable = createSortable({\n element: document.getElementById('list')!,\n keyboard: true,\n onBeforeReorder: (from, to) => {\n // record positions here before the DOM commits (for FLIP animations)\n },\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n currentOrder = ids;\n setRevert(() => {\n currentOrder = prev;\n });\n },\n});\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **Counter-based hover state** — `onHoverChange` stays accurate when dragging over child elements; hover only activates when the drag payload passes the `accept` filter, with symmetric enter/leave pairing to prevent flicker\n- **MIME type pre-validation** — queries `dataTransfer.items` during drag to set `dropEffect='none'` before the drop; confirmed against `File.type` on drop\n- **Flexible accept patterns** — MIME types (`image/png`), wildcards (`image/*`), and file extensions (`.pdf`)\n- **`maxFiles` limit** — cap the number of accepted files per drop; excess files are forwarded to `onDropRejected`\n- **`onValidate` async gating** — optional cancellable async step after type filtering; `zone.validating` remains `true` until every pending validation settles\n- **Clipboard paste support** — `paste: true` routes pasted files through the same `accept`, `maxFiles`, and `onValidate` pipeline; `onPaste` provides a separate callback; paste rejections are forwarded to `onDropRejected` with the same `(files: File[]) => void` signature as drop rejections\n- **`onDropRejected`** — separate callback for files that didn't match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`; event type reflects whether the rejection came from a drop or a paste\n- **Sortable lists** — reorders DOM children with a placeholder indicator; fires `onReorder` only when the order actually changes\n- **Drag handles** — scope dragging to a child selector via `handle`; whole item is draggable when omitted\n- **Custom drag preview** — pass an element or a `(id, item, event) => element | null` factory; control hotspot with `dragImageOffset`\n- **`onBeforeReorder` FLIP hook** — fires before commit for both drag and keyboard moves; pair it with [`captureLayout()`](/necromancer/api.md#capturelayout) for lifecycle-owned FLIP animation\n- **`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\n- **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally\n- **Transactional connected scopes** — one `onMove` callback receives each cross-list transfer with both final orders\n- **Scoped touch support** — `createSortableScope({ touch: true })` handles only items registered to that scope and uses an inert outline preview\n- **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers\n- **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup\n- **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\n- **Zero dependencies** — <PackageInfo package=\"dnd\" type=\"size\" /> gzipped, <PackageInfo package=\"dnd\" type=\"dependencies\" /> dependencies\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Orbit](/orbit/) — floating element positioning; use alongside Dnd to anchor drag previews and drop-zone indicators to precise positions\n- [Ore](/ore/) — web-component authoring framework; build draggable custom elements with Dnd's pointer event primitives\n- [Refine](/refine/) — accessible web components; Dnd powers the drag-and-drop inside Refine's sortable list and kanban components\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Dnd — API Reference\ndescription: Complete API reference for Dnd.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- |\n| `createDropZone()` | Create a typed drop-zone controller | Sync | Dispose the controller during teardown |\n| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |\n| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |\n| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |\n| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |\n| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |\n| `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all |\n| `DndError` | Base class for Dnd errors | Sync | Use `DndError.is()` to narrow unknown errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --------------- | ---------------------- |\n| `@vielzeug/dnd` | Main exports and types |\n\n## Types\n\n### `Disposable`\n\n```ts\ninterface Disposable {\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n [Symbol.dispose](): void;\n}\n```\n\n### `DropZoneOptions`\n\n```ts\ninterface DropZoneOptions {\n element: HTMLElement;\n accept?: string[];\n maxFiles?: number;\n onValidate?: (files: File[], context: DropValidationContext) => boolean | Promise<boolean>;\n disabled?: boolean;\n dropEffect?: DataTransfer['dropEffect'];\n onDrop?: (files: File[]) => void;\n onDropRejected?: (files: File[]) => void;\n onHoverChange?: (hovered: boolean) => void;\n onValidatingChange?: (validating: boolean) => void;\n paste?: boolean;\n onPaste?: (files: File[]) => void;\n}\n```\n\n### `DropZone`\n\n```ts\ninterface DropZone extends Disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `DropValidationContext`\n\n```ts\ninterface DropValidationContext {\n readonly signal: AbortSignal;\n}\n```\n\n### `SortableOptions`\n\n```ts\ninterface SortableOptions {\n element: HTMLElement;\n getKey: (element: HTMLElement) => string;\n scope?: SortableScope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoScroll?: boolean | AutoScrollOptions;\n dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined);\n dragImageOffset?: [number, number];\n placeholderClass?: string;\n disabled?: boolean;\n onDragStart?: (id: string, event: DragEvent) => void;\n onDragEnd?: (id: string, event: DragEvent) => void;\n onBeforeReorder?: (from: string[], to: string[]) => void;\n onReorder?: (event: ReorderEvent) => void;\n}\n```\n\n### `AutoScrollOptions`\n\n```ts\ninterface AutoScrollOptions {\n edgeThreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `ReorderEvent`\n\n```ts\ninterface ReorderEvent {\n ids: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `Sortable`\n\n```ts\ninterface Sortable extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `SortableScope`\n\n```ts\ninterface SortableScope extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n}\n```\n\n### `SortableScopeOptions`\n\n```ts\ninterface SortableScopeOptions {\n onMove?: (event: SortableMoveEvent) => void;\n touch?: boolean | SortableTouchOptions;\n}\n```\n\n### `SortableMoveEvent`\n\n```ts\ninterface SortableMoveEvent {\n readonly itemId: string;\n readonly source: HTMLElement;\n readonly sourceIds: string[];\n readonly target: HTMLElement;\n readonly targetIds: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `SortableTouchOptions`\n\n```ts\ninterface SortableTouchOptions {\n preview?: false | ((item: HTMLElement) => HTMLElement | null);\n}\n```\n\n`preview` returns a template that Dnd clones before mounting it as a transient touch preview, so returning an element from the sortable item does not reparent or remove caller-owned DOM. Return `false` to disable the preview.\n\n## `createDropZone()`\n\n```ts\ndeclare function createDropZone(options: DropZoneOptions): DropZone;\n```\n\nAttaches drag-and-drop file handling to a DOM element. Returns a `DropZone` handle.\n\n| Option | Type | Default | Description |\n| ---------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `element` | `HTMLElement` | — | **Required.** The element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | Accepted file types. Empty array accepts everything. Each entry is a MIME type (`'image/png'`), MIME wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxFiles` | `number` | — | Maximum files accepted per drop. Files beyond this limit are passed to `onDropRejected`. When omitted there is no limit. |\n| `onValidate` | `(files, { signal }) => boolean \\| Promise<boolean>` | — | Optional async gating step. Return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | When `true`, all drag and paste events are ignored. A disabled zone does not call `preventDefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropEffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | The `dropEffect` set on `dataTransfer` during `dragover`. Controls the cursor indicator. |\n| `onDrop` | `(files: File[]) => void` | — | Called with accepted files only. Not called if all dropped files are rejected. Also receives paste events when `paste: true` and `onPaste` is omitted. |\n| `onDropRejected` | `(files: File[]) => void` | — | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. |\n| `onHoverChange` | `(hovered: boolean) => void` | — | Called when hover state toggles. Use this callback for drag-over styling. |\n| `onValidatingChange` | `(validating: boolean) => void` | — | Called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `window`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. |\n| `onPaste` | `(files: File[]) => void` | — | Called when files are pasted from the clipboard. Falls back to `onDrop` when omitted. Only active when `paste: true`. |\n\n**Returns:** `DropZone`\n\nNotes:\n\n- Extension accept patterns are approximate during pre-check (`DataTransferItem` has no filename); exact filtering is applied at drop time.\n- Hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. Drags carrying rejected file types enter and leave the zone without triggering `onHoverChange`.\n- Hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf'],\n onDrop: (files) => {\n upload(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} rejected`);\n },\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\n## `DropZone` Interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. Updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onValidate` promise is pending. Use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onValidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. Safe to read at any time.\n\n### `zone.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called. Use it to cancel in-flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. Idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`. Called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n} // zone.dispose() runs here\n```\n\n## `createSortable()`\n\n```ts\ndeclare function createSortable(options: SortableOptions): Sortable;\n```\n\nMakes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.\n\n`createSortable` adds drag and keyboard defaults only when callers have not already supplied semantics. Every changed attribute and inline style is restored to its prior value on disposal.\n\n- `element`: `HTMLElement`, required. The container whose children become sortable.\n- `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.\n- `scope`: `SortableScope`, default private scope. Connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n- `handle`: `string`. CSS selector for a drag handle inside each item. When omitted, the whole item is draggable.\n- `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`.\n- `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion.\n- `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls the container near its edges; enable viewport scrolling with `autoScroll.viewport`.\n- `dragImage`: `HTMLElement | ((id, item, event) => HTMLElement | null | undefined)`. Custom native drag preview passed to `dataTransfer.setDragImage()`. A `null` or `undefined` return skips `setDragImage` entirely.\n- `dragImageOffset`: `[number, number]`, default `[0, 0]`. The `[x, y]` hotspot offset passed to `setDragImage`. Controls which point of the preview image follows the cursor.\n- `placeholderClass`: `string`, default `'dnd-placeholder'`. CSS class applied to the generated placeholder element.\n- `disabled`: `boolean`. Blocks drag interactions. If a list becomes disabled mid-drag, Dnd cancels the drag and restores the original order.\n- `onDragStart`: `(id: string, event: DragEvent) => void`. Called when a drag starts.\n- `onDragEnd`: `(id: string, event: DragEvent) => void`. Called when a drag ends, whether completed or cancelled.\n- `onBeforeReorder`: `(from: string[], to: string[]) => void`. Called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. Items are still in their pre-commit positions at the time of the call, making it ideal for [`captureLayout()`](/necromancer/api.md#capturelayout) setup.\n- `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. Use `event.setRevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**Returns:** `Sortable`\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => saveMove(itemId, sourceIds, targetIds),\n touch: true,\n});\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.id!,\n handle: '.drag-handle',\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n saveOrder(ids);\n setRevert(() => saveOrder(prev));\n },\n scope: boardScope,\n});\n```\n\n### `createSortableScope()`\n\n```ts\ndeclare function createSortableScope(options?: SortableScopeOptions): SortableScope;\n```\n\nUse one scope per connected set of containers. `onMove` fires once for cross-list moves with both final orders; local reorders continue to call the sortable's `onReorder`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `SortableScopeOptions` | Optional cross-list move callback and scope-owned touch configuration |\n\n**Returns:** `SortableScope`.\n\n```ts\nimport { createSortableScope } from '@vielzeug/dnd';\n\nconst scope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n```\n\n## `Sortable` Interface\n\n### `sortable.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\nCalls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it. A no-op when no revert function was registered or it has already been consumed. Works for both drag-based and keyboard-based reorders.\n\nOnly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids);\n setRevert(() => setOrder(prev)); // ← enable revert\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nRe-applies `draggable`, `role`, and handle attributes after DOM mutations. Call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the container, strips sortable attributes from items and handles, and cancels any in-progress drag by restoring the original order. Idempotent — safe to call multiple times.\n\n### `sortable[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`.\n\n## `SortableScope` Interface\n\n### `scope.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\nCalls and clears the rollback registered with `SortableMoveEvent.setRevert()` for the latest cross-list move. It is a no-op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\nDisposes scope-owned touch input and prevents registered lists from participating in future connected moves.\n\n## DOM Attributes\n\nDnd reads and writes the following DOM attributes:\n\n- `data-dnd-item`: internal marker applied by `createSortable` to children that return a truthy key from `getKey`. Restored on `dispose()`.\n- `draggable`, roles, tabindex, and `touchAction`: managed only as needed and restored to their exact prior values on `dispose()`.\n- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.\n- `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.\n- `aria-hidden=\"true\"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.\n- `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## CSS Classes\n\n| Class | Applied to | When |\n| ----------------- | ---------------------------- | ------------------------------------------------------------- |\n| `dnd-placeholder` | `<div>` inserted by sortable | While an item is being dragged, in the placeholder's position |\n\n## `matchesAccept()`\n\n```ts\ndeclare function matchesAccept(file: File, accept: string[]): boolean;\n```\n\nTests whether a `File` matches an accept pattern list. Each pattern can be:\n\n- A MIME type: `'image/png'`\n- A MIME wildcard: `'image/*'`\n- A file extension: `'.pdf'`\n\nAn empty list accepts everything. Extension matching is case-insensitive.\n\n**Returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesAccept } from '@vielzeug/dnd';\n\nmatchesAccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyReorder()`\n\n```ts\ndeclare function applyReorder<T>(items: T[], ids: string[], getKey: (item: T) => string): T[];\n```\n\nApplies a DOM reorder result (`orderedIds`) to your backing array.\n\n- IDs missing from `items` are ignored.\n- Items not listed in `ids` are appended in original order.\n- Duplicate IDs in `ids` — first occurrence wins, later occurrences are ignored.\n\n**Returns:** A new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyReorder(items, orderedIds, (item) => item.id);\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `DndError` | Base class for package errors | `DndError.is(error)` |\n| `DndScopeError` | A sortable receives a scope not created by `createSortableScope()` | — |\n",
6
- "usage": "---\ntitle: Dnd — Usage Guide\ndescription: Drop zones, sortable lists, explicit connected scopes, keyboard sorting, and cleanup patterns with Dnd.\n---\n\n[[toc]]\n\n## Basic Usage\n\n`createDropZone` attaches drag-and-drop behavior to any DOM element and keeps hover state stable with a counter.\n\n```ts\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst dropzone = document.getElementById('dropzone')!;\n\nconst zone = createDropZone({\n element: dropzone,\n onDrop: (files) => {\n console.log('Accepted files:', files);\n },\n});\n```\n\n### Accept filtering\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf', 'application/json'],\n onDrop: (files) => {\n // accepted files only\n },\n onDropRejected: (files) => {\n showToast(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nThe `accept` list is read at drop-time, so mutating the array dynamically adjusts what is accepted for the next drop.\n\n### Hover state\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\nRead zone state imperatively:\n\n```ts\nconsole.log(zone.hovered);\nconsole.log(zone.validating);\n```\n\n### Drop effect\n\n```ts\ncreateDropZone({\n element: dropEl,\n dropEffect: 'move',\n onDrop: (files) => {\n // ...\n },\n});\n```\n\n### Disabled state\n\n```ts\nconst options = { disabled: false, element: dropEl, onDrop: handleFiles };\nconst zone = createDropZone(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isReadOnly;\n```\n\n### File limit\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n maxFiles: 5,\n onDrop: (files) => {\n // 1-5 accepted files\n },\n onDropRejected: (files) => {\n showToast(`Only 5 files at a time. ${files.length} were ignored.`);\n },\n});\n```\n\n### Cleanup\n\n```ts\nzone.dispose();\n// or:\nusing zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n```\n\n### Async validation\n\nGate drops behind an async check with `onValidate`. The zone remains `validating: true` until every pending validation settles, and disposal aborts each validation signal.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n onValidate: async (files, { signal }) => {\n const ok = await checkServerQuota(files, { signal });\n return ok; // false → all files forwarded to onDropRejected\n },\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError('Quota exceeded'),\n});\n\n// show a spinner while checking\nconsole.log(zone.validating); // true during pending check\n```\n\nA synchronous boolean return skips the microtask queue entirely:\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onValidate: (files) => files.every((f) => f.size < 5_000_000), // sync\n onDrop: handleFiles,\n});\n```\n\n### Clipboard paste\n\nSet `paste: true` to accept files pasted from the clipboard. The same `accept`, `maxFiles`, and `onValidate` pipeline applies.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n paste: true,\n accept: ['image/*'],\n onPaste: (files) => {\n uploadFiles(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nWhen `onPaste` is omitted, accepted pasted files fall through to `onDrop`.\n\n## Sortable\n\n`createSortable` makes direct children of a container reorderable via drag.\n\n### Setup\n\n```html\n<ul id=\"task-list\">\n <li data-sort-id=\"task-1\">Design</li>\n <li data-sort-id=\"task-2\">Develop</li>\n <li data-sort-id=\"task-3\">Review</li>\n</ul>\n```\n\n```ts\nconst sortable = createSortable({\n element: document.getElementById('task-list')!,\n getKey: (el) => el.dataset.sortId!,\n axis: 'vertical',\n onReorder: ({ ids }) => {\n saveTaskOrder(ids);\n },\n});\n```\n\nDnd automatically sets:\n\n- `draggable=\"true\"` on sortable nodes (or handles)\n- `role=\"listitem\"` on each item\n- `role=\"list\"` on the container\n- `tabindex=\"0\"` on each item for keyboard reordering\n\n### Drag handles\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n handle: '.drag-handle',\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Keyboard reordering\n\nFocus an item and use arrow keys to move it. `Home` and `End` move to the boundary positions.\n\nWhen an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). Only keys that actually move an item call `preventDefault`.\n\n### Connected lists\n\nCreate a shared scope when items should move between containers:\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n\ncreateSortable({\n element: todoEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\ncreateSortable({\n element: doneEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\n```\n\n### Auto-scroll and drag preview\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n autoScroll: { edgeThreshold: 40, speed: 24, viewport: true },\n dragImage: (id, item) => item,\n dragImageOffset: [8, 8],\n});\n```\n\nViewport scrolling is opt-in. Container scrolling stays enabled by default.\n\n### Lifecycle hooks\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Custom identity function\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.getAttribute('data-id')!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Dynamic lists\n\nCall `sortable.sync()` after adding, removing, or replacing sortable items.\n\n```ts\nconst item = document.createElement('li');\nitem.dataset.sortId = 'task-4';\nitem.textContent = 'Deploy';\nlistEl.appendChild(item);\nsortable.sync();\n```\n\n### Disabled state\n\n```ts\nimport { createSortable, type SortableOptions } from '@vielzeug/dnd';\n\nconst options: SortableOptions = {\n disabled: false,\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n};\nconst sortable = createSortable(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isLocked;\n```\n\n### Placeholder styling\n\n```css\n.dnd-placeholder {\n background: var(--color-primary-50);\n border: 2px dashed var(--color-primary-300);\n border-radius: 4px;\n box-sizing: border-box;\n}\n\n[data-dragging] {\n opacity: 0.35;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n```\n\n### Mapping DOM order back to data\n\n```ts\nimport { applyReorder, createSortable } from '@vielzeug/dnd';\n\nlet items = [\n { id: 'task-1', title: 'Design' },\n { id: 'task-2', title: 'Develop' },\n { id: 'task-3', title: 'Review' },\n];\n\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items = applyReorder(items, ids, (item) => item.id);\n },\n});\n```\n\n### Cleanup\n\n```ts\nsortable.dispose();\n// or:\nusing sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### FLIP animation hook\n\n`onBeforeReorder` fires just before the DOM reorder commits, for both drag and keyboard moves. Pair it with [`captureLayout()`](/necromancer/api.md#capturelayout) to animate the resulting layout without managing rectangles, transforms, or animation frames yourself.\n\n```ts\nimport { captureLayout, type LayoutTransition } from '@vielzeug/necromancer';\n\nlet layout: LayoutTransition | undefined;\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onBeforeReorder: () => {\n layout = captureLayout(listEl.querySelectorAll('[data-sort-id]'), {\n getKey: (el) => el.dataset.sortId!,\n });\n },\n onReorder: ({ ids }) => {\n saveOrder(ids); // Commit a framework render here when needed.\n layout?.animate({\n duration: 200,\n easing: 'ease-out',\n elements: listEl.querySelectorAll('[data-sort-id]'),\n });\n layout = undefined;\n },\n});\n```\n\nIf `saveOrder()` triggers a render that replaces list items, call `layout?.animate({ elements: committedItems })` after that render commits. When DnD's own reordered elements remain in the DOM, call `layout?.animate()` directly. DnD stays dependency-free: the application chooses to install and import Necromancer when it wants this integration.\n\n### Optimistic updates and revert\n\nCall `sortable.revert()` to roll back the most recent reorder. Register a revert function via `setRevert` inside `onReorder`.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids); // optimistic update\n setRevert(() => setOrder(prev)); // registered for sortable.revert()\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(currentOrder);\n} catch {\n sortable.revert();\n}\n```\n\n## Touch Support\n\nHTML5 drag-and-drop has no native touch story. Enable touch on a sortable scope; it only recognizes items registered to that scope, never unrelated `draggable` elements.\n\n```ts\nimport { createSortable, createSortableScope } from '@vielzeug/dnd';\n\nusing scope = createSortableScope({ touch: true });\nusing sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.id!, scope });\n```\n\n### Touch preview\n\nTouch uses an inert outline by default, avoiding cloned application DOM. Provide a preview factory or opt out when your item styling supplies its own feedback.\n\n```ts\nconst scope = createSortableScope({\n touch: {\n // The returned element is cloned before Dnd mounts it as a transient preview.\n preview: (item) => item.querySelector<HTMLElement>('.drag-preview'),\n },\n});\n```\n\n### Why draggable items get `touch-action: none`\n\n`createSortable` sets `touch-action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set). This prevents a mobile browser from treating the initial movement as page scrolling before the scope controller can start the drag.\n\nThis has no effect on mouse/pointer input.\n\n## Testing\n\nTest observable callbacks and controller state with your DOM test runner. Construct the zone in each test, dispatch a real `drop` event, then dispose it during teardown.\n\n```ts\nimport { afterEach, expect, it, vi } from 'vitest';\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst zones: Array<{ dispose(): void }> = [];\n\nafterEach(() => zones.splice(0).forEach((zone) => zone.dispose()));\n\nit('forwards accepted files', async () => {\n const element = document.createElement('div');\n const onDrop = vi.fn();\n const zone = createDropZone({ element, onDrop });\n zones.push(zone);\n const file = new File(['content'], 'readme.txt', { type: 'text/plain' });\n const event = new Event('drop') as DragEvent;\n\n Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } });\n element.dispatchEvent(event);\n\n await Promise.resolve();\n\n expect(onDrop).toHaveBeenCalledWith([file]);\n expect(zone.disposed).toBe(false);\n});\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createSortable, applyReorder } from '@vielzeug/dnd';\n\nfunction SortableList({ initialItems }: { initialItems: { id: string; text: string }[] }) {\n const listRef = useRef<HTMLUListElement>(null);\n const items = useRef(initialItems);\n\n useEffect(() => {\n const sortable = createSortable({\n element: listRef.current!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items.current = applyReorder(items.current, ids, (i) => i.id);\n },\n });\n return () => sortable.dispose();\n }, []);\n\n return (\n <ul ref={listRef}>\n {initialItems.map((item) => (\n <li key={item.id} data-sort-id={item.id}>\n {item.text}\n </li>\n ))}\n </ul>\n );\n}\n```\n\n```ts [Vue 3]\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport { createSortable, applyReorder, type Sortable } from '@vielzeug/dnd';\n\nfunction useSortable(items: { id: string; text: string }[]) {\n const listRef = ref<HTMLElement | null>(null);\n const orderedItems = ref(items);\n let sortable: Sortable | null = null;\n\n onMounted(() => {\n sortable = createSortable({\n element: listRef.value!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n orderedItems.value = applyReorder(orderedItems.value, ids, (i) => i.id);\n },\n });\n });\n\n onUnmounted(() => sortable?.dispose());\n return { listRef, orderedItems };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createSortable, applyReorder } from '@vielzeug/dnd';\n\n export let initialItems: { id: string; text: string }[] = [];\n let items = initialItems;\n let listEl: HTMLUListElement;\n\n onMount(() => {\n const sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => { items = applyReorder(items, ids, (i) => i.id); },\n });\n return () => sortable.dispose();\n });\n</script>\n\n<ul bind:this={listEl}>\n {#each items as item (item.id)}\n <li data-sort-id={item.id}>{item.text}</li>\n {/each}\n</ul>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nUse Dnd in custom web components by attaching behavior in component lifecycle hooks.\n\n```ts\nimport { createSortable } from '@vielzeug/dnd';\nimport { define, getHost, html, onMounted } from '@vielzeug/ore';\n\ndefine('task-list', {\n setup(_props) {\n const el = getHost();\n\n onMounted(() => {\n const sortable = createSortable({\n element: el,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => save(ids),\n });\n return () => sortable.dispose();\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## Best Practices\n\n- Attach `createDropZone` and `createSortable` after the container element is in the DOM — use `onMounted` in component frameworks.\n- Call `.dispose()` in the cleanup phase of your framework (useEffect return, onUnmounted, onDestroy) to prevent memory leaks.\n- Use `data-sort-id` attributes that match your data's identity field — do not use DOM index as an identifier.\n- Prefer `applyReorder()` over manual array splicing to keep your data array in sync with DOM order.\n- Use `createSortableScope()` only when items should genuinely move between containers.\n- Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.\n- Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.\n- Enable `touch: true` only on scopes that own touch-sortable lists.\n",
5
+ "api": "---\ntitle: Dnd — API Reference\ndescription: Complete API reference for Dnd.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| -------------------------- | -------------------------------------------- | -------------- | ----------------------------------------------------------------- |\n| `createDropZone()` | Create a typed drop-zone controller | Sync | Dispose the controller during teardown |\n| `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |\n| `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |\n| `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |\n| `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |\n| `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |\n| `matchesAccept()` | Test a single `File` against an accept list | Sync | Extension patterns are case-insensitive; empty list accepts all |\n| `DndError` | Base class for Dnd errors | Sync | Use `instanceof DndError` to narrow unknown errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --------------- | ---------------------- |\n| `@vielzeug/dnd` | Main exports and types |\n\n## Types\n\n### `Disposable`\n\n```ts\ninterface Disposable {\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n [Symbol.dispose](): void;\n}\n```\n\n### `DropZoneOptions`\n\n```ts\ninterface DropZoneOptions {\n element: HTMLElement;\n accept?: string[];\n maxFiles?: number;\n onValidate?: (files: File[], context: DropValidationContext) => boolean | Promise<boolean>;\n disabled?: boolean;\n dropEffect?: DataTransfer['dropEffect'];\n onDrop?: (files: File[]) => void;\n onDropRejected?: (files: File[]) => void;\n onHoverChange?: (hovered: boolean) => void;\n onValidatingChange?: (validating: boolean) => void;\n paste?: boolean;\n onPaste?: (files: File[]) => void;\n}\n```\n\n### `DropZone`\n\n```ts\ninterface DropZone extends Disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `DropValidationContext`\n\n```ts\ninterface DropValidationContext {\n readonly signal: AbortSignal;\n}\n```\n\n### `SortableOptions`\n\n```ts\ninterface SortableOptions {\n element: HTMLElement;\n getKey: (element: HTMLElement) => string;\n scope?: SortableScope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoScroll?: boolean | AutoScrollOptions;\n dragImage?: HTMLElement | ((id: string, item: HTMLElement, event: DragEvent) => HTMLElement | null | undefined);\n dragImageOffset?: [number, number];\n placeholderClass?: string;\n disabled?: boolean;\n onDragStart?: (id: string, event: DragEvent) => void;\n onDragEnd?: (id: string, event: DragEvent) => void;\n onBeforeReorder?: (from: string[], to: string[]) => void;\n onReorder?: (event: ReorderEvent) => void;\n}\n```\n\n### `AutoScrollOptions`\n\n```ts\ninterface AutoScrollOptions {\n edgeThreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `ReorderEvent`\n\n```ts\ninterface ReorderEvent {\n ids: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `Sortable`\n\n```ts\ninterface Sortable extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `SortableScope`\n\n```ts\ninterface SortableScope extends Disposable {\n readonly isDragging: boolean;\n revert(): void;\n}\n```\n\n### `SortableScopeOptions`\n\n```ts\ninterface SortableScopeOptions {\n onMove?: (event: SortableMoveEvent) => void;\n touch?: boolean | SortableTouchOptions;\n}\n```\n\n### `SortableMoveEvent`\n\n```ts\ninterface SortableMoveEvent {\n readonly itemId: string;\n readonly source: HTMLElement;\n readonly sourceIds: string[];\n readonly target: HTMLElement;\n readonly targetIds: string[];\n setRevert(fn: () => void): void;\n}\n```\n\n### `SortableTouchOptions`\n\n```ts\ninterface SortableTouchOptions {\n preview?: false | ((item: HTMLElement) => HTMLElement | null);\n}\n```\n\n`preview` returns a template that Dnd clones before mounting it as a transient touch preview, so returning an element from the sortable item does not reparent or remove caller-owned DOM. Return `false` to disable the preview.\n\nTouch sorting tracks the initiating touch by identifier. Secondary touches are ignored, and cancellation of the initiating touch restores the pre-drag order without firing `onReorder`.\n\n## `createDropZone()`\n\n```ts\ndeclare function createDropZone(options: DropZoneOptions): DropZone;\n```\n\nAttaches drag-and-drop file handling to a DOM element. Returns a `DropZone` handle.\n\n| Option | Type | Default | Description |\n| ---------------- | ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| `element` | `HTMLElement` | — | **Required.** The element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | Accepted file types. Empty array accepts everything. Each entry is a MIME type (`'image/png'`), MIME wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxFiles` | `number` | — | Maximum files accepted per drop. Files beyond this limit are passed to `onDropRejected`. When omitted there is no limit. |\n| `onValidate` | `(files, { signal }) => boolean \\| Promise<boolean>` | — | Optional async gating step. Return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | When `true`, all drag and paste events are ignored. A disabled zone does not call `preventDefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropEffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | The `dropEffect` set on `dataTransfer` during `dragover`. Controls the cursor indicator. |\n| `onDrop` | `(files: File[]) => void` | — | Called with accepted files only. Not called if all dropped files are rejected. Also receives paste events when `paste: true` and `onPaste` is omitted. |\n| `onDropRejected` | `(files: File[]) => void` | — | Called with files that did not match `accept`, exceeded `maxFiles`, or were rejected by `onValidate`. |\n| `onHoverChange` | `(hovered: boolean) => void` | — | Called when hover state toggles. Use this callback for drag-over styling. |\n| `onValidatingChange` | `(validating: boolean) => void` | — | Called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | When `true`, attaches a `paste` listener to `window`. Pasted files run through the same `accept`, `maxFiles`, and `onValidate` pipeline as dropped files. |\n| `onPaste` | `(files: File[]) => void` | — | Called when files are pasted from the clipboard. Falls back to `onDrop` when omitted. Only active when `paste: true`. |\n\n**Returns:** `DropZone`\n\nNotes:\n\n- Extension accept patterns are approximate during pre-check (`DataTransferItem` has no filename); exact filtering is applied at drop time.\n- Hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. Drags carrying rejected file types enter and leave the zone without triggering `onHoverChange`.\n- Hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf'],\n onDrop: (files) => {\n upload(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} rejected`);\n },\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\n## `DropZone` Interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. Updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onValidate` promise is pending. Use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onValidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. Safe to read at any time.\n\n### `zone.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called. Use it to cancel in-flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. Idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`. Called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n} // zone.dispose() runs here\n```\n\n## `createSortable()`\n\n```ts\ndeclare function createSortable(options: SortableOptions): Sortable;\n```\n\nMakes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.\n\n`createSortable` adds drag and keyboard defaults only when callers have not already supplied semantics. Every changed attribute and inline style is restored to its prior value on disposal.\n\n- `element`: `HTMLElement`, required. The container whose children become sortable.\n- `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.\n- `scope`: `SortableScope`, default private scope. Connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n- `handle`: `string`. CSS selector for a drag handle inside each item. When omitted, the whole item is draggable.\n- `keyboard`: `boolean`, default `true`. Enables keyboard reordering with arrow keys plus `Home` and `End`.\n- `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. Controls midpoint calculation for placeholder insertion.\n- `autoScroll`: `boolean | AutoScrollOptions`, default `true`. Scrolls the container near its edges; enable viewport scrolling with `autoScroll.viewport`.\n- `dragImage`: `HTMLElement | ((id, item, event) => HTMLElement | null | undefined)`. Custom native drag preview passed to `dataTransfer.setDragImage()`. A `null` or `undefined` return skips `setDragImage` entirely.\n- `dragImageOffset`: `[number, number]`, default `[0, 0]`. The `[x, y]` hotspot offset passed to `setDragImage`. Controls which point of the preview image follows the cursor.\n- `placeholderClass`: `string`, default `'dnd-placeholder'`. CSS class applied to the generated placeholder element.\n- `disabled`: `boolean`. Blocks drag interactions. If a list becomes disabled mid-drag, Dnd cancels the drag and restores the original order.\n- `onDragStart`: `(id: string, event: DragEvent) => void`. Called when a drag starts.\n- `onDragEnd`: `(id: string, event: DragEvent) => void`. Called when a drag ends, whether completed or cancelled.\n- `onBeforeReorder`: `(from: string[], to: string[]) => void`. Called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. Items are still in their pre-commit positions at the time of the call, making it ideal for [`captureLayout()`](/necromancer/api.md#capturelayout) setup.\n- `onReorder`: `(event: ReorderEvent) => void`. Called after a successful reorder (drag or keyboard), only when the order changed. Use `event.setRevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**Returns:** `Sortable`\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => saveMove(itemId, sourceIds, targetIds),\n touch: true,\n});\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.id!,\n handle: '.drag-handle',\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n saveOrder(ids);\n setRevert(() => saveOrder(prev));\n },\n scope: boardScope,\n});\n```\n\n### `createSortableScope()`\n\n```ts\ndeclare function createSortableScope(options?: SortableScopeOptions): SortableScope;\n```\n\nUse one scope per connected set of containers. `onMove` fires once for cross-list moves with both final orders; local reorders continue to call the sortable's `onReorder`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `SortableScopeOptions` | Optional cross-list move callback and scope-owned touch configuration |\n\n**Returns:** `SortableScope`.\n\n```ts\nimport { createSortableScope } from '@vielzeug/dnd';\n\nconst scope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n```\n\n## `Sortable` Interface\n\n### `sortable.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\nCalls the revert function registered via `setRevert` in the last `onReorder` invocation (if any) and clears it. A no-op when no revert function was registered or it has already been consumed. Works for both drag-based and keyboard-based reorders.\n\nOnly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids);\n setRevert(() => setOrder(prev)); // ← enable revert\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nRe-applies `draggable`, `role`, and handle attributes after DOM mutations. Call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalSignal`\n\n`readonly disposalSignal: AbortSignal`\n\nAn `AbortSignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nRemoves all event listeners from the container, strips sortable attributes from items and handles, and cancels any in-progress drag by restoring the original order. Idempotent — safe to call multiple times.\n\n### `sortable[Symbol.dispose]()`\n\n`[Symbol.dispose](): void`\n\nAlias for `dispose()`.\n\n## `SortableScope` Interface\n\n### `scope.isDragging`\n\n`readonly isDragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\nCalls and clears the rollback registered with `SortableMoveEvent.setRevert()` for the latest cross-list move. It is a no-op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\nDisposes scope-owned touch input and prevents registered lists from participating in future connected moves.\n\n## DOM Attributes\n\nDnd reads and writes the following DOM attributes:\n\n- `data-dnd-item`: internal marker applied by `createSortable` to children that return a truthy key from `getKey`. Restored on `dispose()`.\n- `draggable`, roles, tabindex, and `touchAction`: managed only as needed and restored to their exact prior values on `dispose()`.\n- `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.\n- `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.\n- `aria-hidden=\"true\"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.\n- `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## CSS Classes\n\n| Class | Applied to | When |\n| ----------------- | ---------------------------- | ------------------------------------------------------------- |\n| `dnd-placeholder` | `<div>` inserted by sortable | While an item is being dragged, in the placeholder's position |\n\n## `matchesAccept()`\n\n```ts\ndeclare function matchesAccept(file: File, accept: string[]): boolean;\n```\n\nTests whether a `File` matches an accept pattern list. Each pattern can be:\n\n- A MIME type: `'image/png'`\n- A MIME wildcard: `'image/*'`\n- A file extension: `'.pdf'`\n\nAn empty list accepts everything. Extension matching is case-insensitive.\n\n**Returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesAccept } from '@vielzeug/dnd';\n\nmatchesAccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyReorder()`\n\n```ts\ndeclare function applyReorder<T>(items: T[], ids: string[], getKey: (item: T) => string): T[];\n```\n\nApplies a DOM reorder result (`orderedIds`) to your backing array.\n\n- IDs missing from `items` are ignored.\n- Items not listed in `ids` are appended in original order.\n- Duplicate IDs in `ids` — first occurrence wins, later occurrences are ignored.\n\n**Returns:** A new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyReorder(items, orderedIds, (item) => item.id);\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `DndError` | Base class for package errors | Use `instanceof DndError` to narrow |\n| `DndScopeError` | A sortable receives a scope not created by `createSortableScope()` | — |\n",
6
+ "usage": "---\ntitle: Dnd — Usage Guide\ndescription: Drop zones, sortable lists, explicit connected scopes, keyboard sorting, and cleanup patterns with Dnd.\n---\n\n[[toc]]\n\n## Basic Usage\n\n`createDropZone` attaches drag-and-drop behavior to any DOM element and keeps hover state stable with a counter.\n\n```ts\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst dropzone = document.getElementById('dropzone')!;\n\nconst zone = createDropZone({\n element: dropzone,\n onDrop: (files) => {\n console.log('Accepted files:', files);\n },\n});\n```\n\n### Accept filtering\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*', '.pdf', 'application/json'],\n onDrop: (files) => {\n // accepted files only\n },\n onDropRejected: (files) => {\n showToast(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nThe `accept` list is read at drop-time, so mutating the array dynamically adjusts what is accepted for the next drop.\n\n### Hover state\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onHoverChange: (hovered) => {\n dropEl.classList.toggle('drag-over', hovered);\n },\n});\n```\n\nRead zone state imperatively:\n\n```ts\nconsole.log(zone.hovered);\nconsole.log(zone.validating);\n```\n\n### Drop effect\n\n```ts\ncreateDropZone({\n element: dropEl,\n dropEffect: 'move',\n onDrop: (files) => {\n // ...\n },\n});\n```\n\n### Disabled state\n\n```ts\nconst options = { disabled: false, element: dropEl, onDrop: handleFiles };\nconst zone = createDropZone(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isReadOnly;\n```\n\n### File limit\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n maxFiles: 5,\n onDrop: (files) => {\n // 1-5 accepted files\n },\n onDropRejected: (files) => {\n showToast(`Only 5 files at a time. ${files.length} were ignored.`);\n },\n});\n```\n\n### Cleanup\n\n```ts\nzone.dispose();\n// or:\nusing zone = createDropZone({ element: dropEl, onDrop: handleFiles });\n```\n\n### Async validation\n\nGate drops behind an async check with `onValidate`. The zone remains `validating: true` until every pending validation settles, and disposal aborts each validation signal.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n accept: ['image/*'],\n onValidate: async (files, { signal }) => {\n const ok = await checkServerQuota(files, { signal });\n return ok; // false → all files forwarded to onDropRejected\n },\n onDrop: (files) => uploadFiles(files),\n onDropRejected: (files) => showError('Quota exceeded'),\n});\n\n// show a spinner while checking\nconsole.log(zone.validating); // true during pending check\n```\n\nA synchronous boolean return skips the microtask queue entirely:\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n onValidate: (files) => files.every((f) => f.size < 5_000_000), // sync\n onDrop: handleFiles,\n});\n```\n\n### Clipboard paste\n\nSet `paste: true` to accept files pasted from the clipboard. The same `accept`, `maxFiles`, and `onValidate` pipeline applies.\n\n```ts\nconst zone = createDropZone({\n element: dropEl,\n paste: true,\n accept: ['image/*'],\n onPaste: (files) => {\n uploadFiles(files);\n },\n onDropRejected: (files) => {\n showError(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nWhen `onPaste` is omitted, accepted pasted files fall through to `onDrop`.\n\n## Sortable\n\n`createSortable` makes direct children of a container reorderable via drag.\n\n### Setup\n\n```html\n<ul id=\"task-list\">\n <li data-sort-id=\"task-1\">Design</li>\n <li data-sort-id=\"task-2\">Develop</li>\n <li data-sort-id=\"task-3\">Review</li>\n</ul>\n```\n\n```ts\nconst sortable = createSortable({\n element: document.getElementById('task-list')!,\n getKey: (el) => el.dataset.sortId!,\n axis: 'vertical',\n onReorder: ({ ids }) => {\n saveTaskOrder(ids);\n },\n});\n```\n\nDnd automatically sets:\n\n- `draggable=\"true\"` on sortable nodes (or handles)\n- `role=\"listitem\"` on each item\n- `role=\"list\"` on the container\n- `tabindex=\"0\"` on each item for keyboard reordering\n\n### Drag handles\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n handle: '.drag-handle',\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Keyboard reordering\n\nFocus an item and use arrow keys to move it. `Home` and `End` move to the boundary positions.\n\nWhen an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). Only keys that actually move an item call `preventDefault`.\n\n### Connected lists\n\nCreate a shared scope when items should move between containers:\n\n```ts\nconst boardScope = createSortableScope({\n onMove: ({ itemId, sourceIds, targetIds }) => {\n persistMove(itemId, sourceIds, targetIds);\n },\n touch: true,\n});\n\ncreateSortable({\n element: todoEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\ncreateSortable({\n element: doneEl,\n getKey: (el) => el.dataset.sortId!,\n scope: boardScope,\n});\n```\n\n### Auto-scroll and drag preview\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n autoScroll: { edgeThreshold: 40, speed: 24, viewport: true },\n dragImage: (id, item) => item,\n dragImageOffset: [8, 8],\n});\n```\n\nViewport scrolling is opt-in. Container scrolling stays enabled by default.\n\n### Lifecycle hooks\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onDragStart: (id) => {\n listEl.classList.add('sorting');\n },\n onDragEnd: (id) => {\n listEl.classList.remove('sorting');\n },\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Custom identity function\n\n```ts\ncreateSortable({\n element: listEl,\n getKey: (el) => el.getAttribute('data-id')!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### Dynamic lists\n\nCall `sortable.sync()` after adding, removing, or replacing sortable items.\n\n```ts\nconst item = document.createElement('li');\nitem.dataset.sortId = 'task-4';\nitem.textContent = 'Deploy';\nlistEl.appendChild(item);\nsortable.sync();\n```\n\n### Disabled state\n\n```ts\nimport { createSortable, type SortableOptions } from '@vielzeug/dnd';\n\nconst options: SortableOptions = {\n disabled: false,\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n};\nconst sortable = createSortable(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isLocked;\n```\n\n### Placeholder styling\n\n```css\n.dnd-placeholder {\n background: var(--color-primary-50);\n border: 2px dashed var(--color-primary-300);\n border-radius: 4px;\n box-sizing: border-box;\n}\n\n[data-dragging] {\n opacity: 0.35;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n```\n\n### Mapping DOM order back to data\n\n```ts\nimport { applyReorder, createSortable } from '@vielzeug/dnd';\n\nlet items = [\n { id: 'task-1', title: 'Design' },\n { id: 'task-2', title: 'Develop' },\n { id: 'task-3', title: 'Review' },\n];\n\ncreateSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items = applyReorder(items, ids, (item) => item.id);\n },\n});\n```\n\n### Cleanup\n\n```ts\nsortable.dispose();\n// or:\nusing sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => saveOrder(ids),\n});\n```\n\n### FLIP animation hook\n\n`onBeforeReorder` fires just before the DOM reorder commits, for both drag and keyboard moves. Pair it with [`captureLayout()`](/necromancer/api.md#capturelayout) to animate the resulting layout without managing rectangles, transforms, or animation frames yourself.\n\n```ts\nimport { captureLayout, type LayoutTransition } from '@vielzeug/necromancer';\n\nlet layout: LayoutTransition | undefined;\n\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onBeforeReorder: () => {\n layout = captureLayout(listEl.querySelectorAll('[data-sort-id]'), {\n getKey: (el) => el.dataset.sortId!,\n });\n },\n onReorder: ({ ids }) => {\n saveOrder(ids); // Commit a framework render here when needed.\n layout?.animate({\n duration: 200,\n easing: 'ease-out',\n elements: listEl.querySelectorAll('[data-sort-id]'),\n });\n layout = undefined;\n },\n});\n```\n\nIf `saveOrder()` triggers a render that replaces list items, call `layout?.animate({ elements: committedItems })` after that render commits. When DnD's own reordered elements remain in the DOM, call `layout?.animate()` directly. DnD stays dependency-free: the application chooses to install and import Necromancer when it wants this integration.\n\n### Optimistic updates and revert\n\nCall `sortable.revert()` to roll back the most recent reorder. Register a revert function via `setRevert` inside `onReorder`.\n\n```ts\nconst sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids, setRevert }) => {\n const prev = currentOrder;\n setOrder(ids); // optimistic update\n setRevert(() => setOrder(prev)); // registered for sortable.revert()\n },\n});\n\n// On server error:\ntry {\n await api.saveOrder(currentOrder);\n} catch {\n sortable.revert();\n}\n```\n\n## Touch Support\n\nHTML5 drag-and-drop has no native touch story. Enable touch on a sortable scope; it only recognizes items registered to that scope, never unrelated `draggable` elements.\n\n```ts\nimport { createSortable, createSortableScope } from '@vielzeug/dnd';\n\nusing scope = createSortableScope({ touch: true });\nusing sortable = createSortable({ element: listEl, getKey: (el) => el.dataset.id!, scope });\n```\n\nThe scope tracks the touch that initiated the drag by its identifier. Additional fingers cannot move, finish, or replace the active drag. If the initiating touch is cancelled, Dnd restores the original item order and removes the transient preview.\n\n### Touch preview\n\nTouch uses an inert outline by default, avoiding cloned application DOM. Provide a preview factory or opt out when your item styling supplies its own feedback.\n\n```ts\nconst scope = createSortableScope({\n touch: {\n // The returned element is cloned before Dnd mounts it as a transient preview.\n preview: (item) => item.querySelector<HTMLElement>('.drag-preview'),\n },\n});\n```\n\n### Why draggable items get `touch-action: none`\n\n`createSortable` sets `touch-action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set). This prevents a mobile browser from treating the initial movement as page scrolling before the scope controller can start the drag.\n\nThis has no effect on mouse/pointer input.\n\n## Testing\n\nTest observable callbacks and controller state with your DOM test runner. Construct the zone in each test, dispatch a real `drop` event, then dispose it during teardown.\n\n```ts\nimport { afterEach, expect, it, vi } from 'vitest';\nimport { createDropZone } from '@vielzeug/dnd';\n\nconst zones: Array<{ dispose(): void }> = [];\n\nafterEach(() => zones.splice(0).forEach((zone) => zone.dispose()));\n\nit('forwards accepted files', async () => {\n const element = document.createElement('div');\n const onDrop = vi.fn();\n const zone = createDropZone({ element, onDrop });\n zones.push(zone);\n const file = new File(['content'], 'readme.txt', { type: 'text/plain' });\n const event = new Event('drop') as DragEvent;\n\n Object.defineProperty(event, 'dataTransfer', { value: { files: [file] } });\n element.dispatchEvent(event);\n\n await Promise.resolve();\n\n expect(onDrop).toHaveBeenCalledWith([file]);\n expect(zone.disposed).toBe(false);\n});\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createSortable, applyReorder } from '@vielzeug/dnd';\n\nfunction SortableList({ initialItems }: { initialItems: { id: string; text: string }[] }) {\n const listRef = useRef<HTMLUListElement>(null);\n const items = useRef(initialItems);\n\n useEffect(() => {\n const sortable = createSortable({\n element: listRef.current!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n items.current = applyReorder(items.current, ids, (i) => i.id);\n },\n });\n return () => sortable.dispose();\n }, []);\n\n return (\n <ul ref={listRef}>\n {initialItems.map((item) => (\n <li key={item.id} data-sort-id={item.id}>\n {item.text}\n </li>\n ))}\n </ul>\n );\n}\n```\n\n```ts [Vue 3]\nimport { ref, onMounted, onUnmounted } from 'vue';\nimport { createSortable, applyReorder, type Sortable } from '@vielzeug/dnd';\n\nfunction useSortable(items: { id: string; text: string }[]) {\n const listRef = ref<HTMLElement | null>(null);\n const orderedItems = ref(items);\n let sortable: Sortable | null = null;\n\n onMounted(() => {\n sortable = createSortable({\n element: listRef.value!,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => {\n orderedItems.value = applyReorder(orderedItems.value, ids, (i) => i.id);\n },\n });\n });\n\n onUnmounted(() => sortable?.dispose());\n return { listRef, orderedItems };\n}\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createSortable, applyReorder } from '@vielzeug/dnd';\n\n export let initialItems: { id: string; text: string }[] = [];\n let items = initialItems;\n let listEl: HTMLUListElement;\n\n onMount(() => {\n const sortable = createSortable({\n element: listEl,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => { items = applyReorder(items, ids, (i) => i.id); },\n });\n return () => sortable.dispose();\n });\n</script>\n\n<ul bind:this={listEl}>\n {#each items as item (item.id)}\n <li data-sort-id={item.id}>{item.text}</li>\n {/each}\n</ul>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### With Ore\n\nUse Dnd in custom web components by attaching behavior in component lifecycle hooks.\n\n```ts\nimport { createSortable } from '@vielzeug/dnd';\nimport { define, getHost, html, onMounted } from '@vielzeug/ore';\n\ndefine('task-list', {\n setup(_props) {\n const el = getHost();\n\n onMounted(() => {\n const sortable = createSortable({\n element: el,\n getKey: (el) => el.dataset.sortId!,\n onReorder: ({ ids }) => save(ids),\n });\n return () => sortable.dispose();\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## Best Practices\n\n- Attach `createDropZone` and `createSortable` after the container element is in the DOM — use `onMounted` in component frameworks.\n- Call `.dispose()` in the cleanup phase of your framework (useEffect return, onUnmounted, onDestroy) to prevent memory leaks.\n- Use `data-sort-id` attributes that match your data's identity field — do not use DOM index as an identifier.\n- Prefer `applyReorder()` over manual array splicing to keep your data array in sync with DOM order.\n- Use `createSortableScope()` only when items should genuinely move between containers.\n- Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.\n- Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.\n- Enable `touch: true` only on scopes that own touch-sortable lists.\n",
7
7
  "examples": "---\ntitle: Dnd — Examples\ndescription: Practical examples and recipes for dnd.\n---\n\n## Examples\n\n- [Sortable List](./examples/sortable-list.md)\n- [Touch-Enabled Sortable List](./examples/touch-enabled-sortable-list.md)\n- [File Upload Drop Zone](./examples/file-upload-drop-zone.md)\n- [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md)\n- [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md)\n- [Connected Kanban Keyboard Sorting](./examples/connected-kanban-keyboard-sorting.md)\n- [Web Component With Ore](./examples/web-component-with-craft.md)\n- [Using `using` for scoped cleanup](./examples/using-using-for-scoped-cleanup.md)\n"
8
8
  },
9
9
  "examples": [
@@ -2,7 +2,7 @@
2
2
  "apiSource": "export * from './worker';\n",
3
3
  "docs": {
4
4
  "index": "---\ntitle: Familiar — Typed module-worker pools\ndescription: Typed ES module Worker pools with cancellation, priority scheduling, streaming, and test utilities.\npackage: familiar\ncategory: workers\nkeywords: [web-workers, module-workers, pool, concurrency, timeout, cancellation, streaming]\nrelated: [arsenal, ripple, herald]\nexports: [createWorker, createStreamWorker, batch, createTaskGroup, FamiliarError, FamiliarTimeoutError, FamiliarTaskError, FamiliarQueueFullError, FamiliarTerminatedError, FamiliarRuntimeError]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"familiar\" />\n\n## Why Familiar?\n\nRaw workers force every application to maintain its own message contract, lifecycle, cancellation, and pool scheduler. Familiar provides those boundaries while keeping worker code in normal typed ES modules.\n\n```ts\n// Before\nconst worker = new Worker(new URL('./sum.worker.ts', import.meta.url), { type: 'module' });\nworker.postMessage([1, 2, 3]);\n\n// After\nconst pool = createWorker<number[], number>(new URL('./sum.worker.ts', import.meta.url));\nawait pool.run([1, 2, 3]);\n```\n\n| Feature | Familiar | Raw Worker | Comlink |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"familiar\" type=\"size\" /> | built-in | ~2 kB |\n| Module-worker contract | <ore-icon name=\"check\" size=\"16\"></ore-icon> | manual | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Pool scheduling | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| AbortSignal cancellation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | manual | manual |\n| Versioned protocol | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | implementation-specific |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Familiar when** worker jobs need bounded concurrency, typed errors, cancellation, or queue policy.\n\n**Consider raw Worker when** one isolated worker and custom messaging are enough.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/familiar\n```\n\n```sh [npm]\nnpm install @vielzeug/familiar\n```\n\n```sh [yarn]\nyarn add @vielzeug/familiar\n```\n\n:::\n\n## Quick Start\n\nRegister task logic inside a worker module.\n\n```ts\n// double.worker.ts\nimport { exposeTask } from '@vielzeug/familiar/protocol';\n\nexposeTask((value: number) => value * 2);\n```\n\nCreate pool from module URL and dispose it after use.\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst worker = createWorker<number, number>(new URL('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await worker.run(21));\n} finally {\n worker.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createWorker()` — versioned task protocol over ES module workers\n- `createStreamWorker()` — stream-only worker capability\n- `run()` — priority scheduling, transferables, timeout, and cancellation\n- `batch()` — ordered task composition\n- `createTaskGroup()` — shared cancellation and settlement tracking\n- `stats` — active, queued, completed, and failed counters\n- `createTestWorker()` — faithful in-process task-pool testing\n- `dispose()` and `drain()` — immediate or draining teardown, with `using` support\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Arsenal](/arsenal/) — async helpers for application coordination.\n- [Ripple](/ripple/) — expose worker results through reactive state.\n- [Herald](/herald/) — publish application events after worker jobs settle.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Familiar — API Reference\ndescription: API reference for module-worker pools and worker-side protocol registration.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createWorker()` | Create single-result module-worker pool | Sync | Worker must call `exposeTask()` |\n| `createStreamWorker()` | Create stream-only module-worker pool | Sync | Worker must call `exposeStream()` |\n| `batch()` | Yield ordered task-pool results | Async iterator | Stops remaining work on first failure |\n| `createTaskGroup()` | Coordinate related task-pool jobs | Sync | Call `abort()` to stop group work |\n| `createTestWorker()` | Create an in-process task-pool test double | Sync | Task modules are not executed |\n| `exposeTask()` | Register worker task handler | Sync | Worker-only import |\n| `exposeStream()` | Register worker stream handler | Sync | Worker-only import |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/familiar` | Pool factories, helpers, types, errors |\n| `@vielzeug/familiar/protocol` | Versioned worker protocol and registration helpers |\n| `@vielzeug/familiar/testing` | Task-pool testing adapter |\n\n## Pool Factories\n\n### `createWorker()`\n\n```ts\nfunction createWorker<TInput, TOutput>(url: URL | string, options?: WorkerOptions): WorkerPool<TInput, TOutput>;\n```\n\nCreates a task pool for a worker module registered with `exposeTask()`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `url` | `URL \\| string` | Module-worker URL, usually `new URL('./task.worker.ts', import.meta.url)` |\n| `options` | `WorkerOptions` | Pool concurrency, queue, timeout, and worker-error policy |\n\n**Returns:** `WorkerPool<TInput, TOutput>`.\n\n**Example:**\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker<number, number>(new URL('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await pool.run(21));\n} finally {\n pool.dispose();\n}\n```\n\n### `createStreamWorker()`\n\n```ts\nfunction createStreamWorker<TInput, TChunk>(url: URL | string, options?: WorkerOptions): StreamWorkerPool<TInput, TChunk>;\n```\n\nCreates a stream-only pool for a worker module registered with `exposeStream()`.\n\n**Returns:** `StreamWorkerPool<TInput, TChunk>`.\n\n---\n\n### `batch()`\n\n```ts\nfunction batch<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n inputs: readonly TInput[],\n options?: BatchOptions,\n): AsyncIterable<TOutput>;\n```\n\nYields results in submission order. A failure or cancellation aborts remaining batch work.\n\n**Returns:** `AsyncIterable<TOutput>`.\n\n---\n\n### `createTaskGroup()`\n\n```ts\nfunction createTaskGroup<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n name?: string,\n options?: TaskGroupOptions,\n): TaskGroup<TInput, TOutput>;\n```\n\nCreates group-scoped cancellation and settlement tracking for one task pool.\n\n**Returns:** `TaskGroup<TInput, TOutput>`.\n\n## Testing\n\n### `createTestWorker()`\n\n```ts\nfunction createTestWorker<TInput, TOutput>(\n handler: (input: TInput) => TOutput | Promise<TOutput>,\n options?: TestWorkerOptions,\n): TestWorkerHandle<TInput, TOutput>;\n```\n\nCreates an in-process task-pool double. It structured-clones values, records settlement, and matches task-pool timeout and cancellation behavior without loading a worker module.\n\n**Returns:** `TestWorkerHandle<TInput, TOutput>`.\n\n## Worker Protocol\n\n### `exposeTask()`\n\n```ts\nfunction exposeTask<TInput, TOutput>(handler: TaskHandler<TInput, TOutput>): void;\n```\n\nRegisters one single-result handler in a module worker.\n\n### `exposeStream()`\n\n```ts\nfunction exposeStream<TInput, TChunk>(handler: StreamHandler<TInput, TChunk>): void;\n```\n\nRegisters one chunk-producing handler in a module worker.\n\n### `PROTOCOL_VERSION`\n\n```ts\nconst PROTOCOL_VERSION: 1;\n```\n\nVersion included in every host request and worker response.\n\n## Types\n\n### `WorkerOptions`\n\n```ts\ntype WorkerOptions = {\n concurrency?: number | 'auto';\n maxQueue?: number;\n onFull?: 'reject' | 'wait';\n timeout?: number;\n onSlotError?: (error: FamiliarRuntimeError) => void;\n};\n```\n\n### `RunOptions`\n\n```ts\ntype RunOptions = {\n priority?: number;\n signal?: AbortSignal;\n timeout?: number;\n transferables?: Transferable[];\n};\n```\n\n`signal` cancels capacity waits, queued work, and executing work. Executing cancellation terminates and replaces its worker slot.\n\n### `WorkerPool`\n\n```ts\ninterface WorkerPool<TInput, TOutput> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n run(input: TInput, options?: RunOptions): Promise<TOutput>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n```\n\n### `StreamWorkerPool`\n\n```ts\ninterface StreamWorkerPool<TInput, TChunk> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n runStream(input: TInput, options?: RunOptions): AsyncIterable<TChunk>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n}\n```\n\n### `WorkerStats`\n\n```ts\ntype WorkerStats = {\n readonly active: number;\n readonly completed: number;\n readonly failed: number;\n readonly queued: number;\n};\n```\n\n### `RunningStream`\n\n```ts\ntype RunningStream<TChunk> = {\n done: Promise<void>;\n iterable: AsyncIterable<TChunk>;\n};\n```\n\n### `WorkerStatus`\n\n```ts\ntype WorkerStatus = 'idle' | 'running' | 'terminated';\n```\n\n### `BatchOptions`\n\n```ts\ntype BatchOptions = RunOptions;\n```\n\n### `DrainOptions`\n\n```ts\ntype DrainOptions = {\n timeout?: number;\n};\n```\n\n### `TaskGroup`\n\n```ts\ntype TaskGroup<TInput, TOutput> = {\n abort(reason?: unknown): void;\n drain(): Promise<PromiseSettledResult<TOutput>[]>;\n readonly name: string | undefined;\n readonly pending: number;\n run(input: TInput, options?: Omit<RunOptions, 'signal'>): Promise<TOutput>;\n readonly size: number;\n};\n```\n\n### `TaskGroupOptions`\n\n```ts\ntype TaskGroupOptions = {\n signal?: AbortSignal;\n};\n```\n\n### `TestWorkerOptions`\n\n```ts\ntype TestWorkerOptions = Omit<WorkerOptions, 'concurrency' | 'onSlotError'> & {\n concurrency?: number;\n};\n```\n\n### `TestWorkerCall`\n\n```ts\ntype TestWorkerCall<TInput, TOutput> =\n | { input: TInput; status: 'fulfilled'; value: TOutput }\n | { input: TInput; reason: unknown; status: 'rejected' };\n```\n\n### `TestWorkerHandle`\n\n```ts\ntype TestWorkerHandle<TInput, TOutput> = WorkerPool<TInput, TOutput> & {\n readonly calls: ReadonlyArray<TestWorkerCall<TInput, TOutput>>;\n};\n```\n\n### `SerializedError`\n\n```ts\ntype SerializedError = {\n message: string;\n name: string;\n stack?: string;\n};\n```\n\n### `WorkerRequest`\n\n```ts\ntype WorkerRequest<TInput> =\n | { id: number; input: TInput; kind: 'run'; version: 1 }\n | { id: number; input: TInput; kind: 'stream'; version: 1 };\n```\n\n### `WorkerResponse`\n\n```ts\ntype WorkerResponse<TOutput> =\n | { id: number; kind: 'chunk'; value: TOutput; version: 1 }\n | { error: SerializedError; id: number; kind: 'error'; version: 1 }\n | { id: number; kind: 'result'; value: TOutput; version: 1 };\n```\n\n### `TaskHandler` and `StreamHandler`\n\n```ts\ntype TaskHandler<TInput, TOutput> = (input: TInput) => TOutput | Promise<TOutput>;\ntype StreamHandler<TInput, TChunk> = (input: TInput) => AsyncIterable<TChunk> | Promise<AsyncIterable<TChunk>>;\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `FamiliarError` | Base class for all Familiar errors | `FamiliarError.is(error)` |\n| `FamiliarInvalidOptionsError` | Invalid factory or test options | — |\n| `FamiliarQueueFullError` | Queue limit reached with `onFull: 'reject'` | `maxQueue` |\n| `FamiliarTaskError` | Worker handler throws or payload cannot clone | `cause` |\n| `FamiliarTimeoutError` | Task or drain deadline expires | `timeoutMs` |\n| `FamiliarTerminatedError` | Pool is disposed or draining | — |\n| `FamiliarRuntimeError` | Worker API or worker process fails | `cause` |\n",
5
+ "api": "---\ntitle: Familiar — API Reference\ndescription: API reference for module-worker pools and worker-side protocol registration.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createWorker()` | Create single-result module-worker pool | Sync | Worker must call `exposeTask()` |\n| `createStreamWorker()` | Create stream-only module-worker pool | Sync | Worker must call `exposeStream()` |\n| `batch()` | Yield ordered task-pool results | Async iterator | Stops remaining work on first failure |\n| `createTaskGroup()` | Coordinate related task-pool jobs | Sync | Call `abort()` to stop group work |\n| `createTestWorker()` | Create an in-process task-pool test double | Sync | Task modules are not executed |\n| `exposeTask()` | Register worker task handler | Sync | Worker-only import |\n| `exposeStream()` | Register worker stream handler | Sync | Worker-only import |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/familiar` | Pool factories, helpers, types, errors |\n| `@vielzeug/familiar/protocol` | Versioned worker protocol and registration helpers |\n| `@vielzeug/familiar/testing` | Task-pool testing adapter |\n\n## Pool Factories\n\n### `createWorker()`\n\n```ts\nfunction createWorker<TInput, TOutput>(url: URL | string, options?: WorkerOptions): WorkerPool<TInput, TOutput>;\n```\n\nCreates a task pool for a worker module registered with `exposeTask()`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `url` | `URL \\| string` | Module-worker URL, usually `new URL('./task.worker.ts', import.meta.url)` |\n| `options` | `WorkerOptions` | Pool concurrency, queue, timeout, and worker-error policy |\n\n**Returns:** `WorkerPool<TInput, TOutput>`.\n\n**Example:**\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker<number, number>(new URL('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await pool.run(21));\n} finally {\n pool.dispose();\n}\n```\n\n### `createStreamWorker()`\n\n```ts\nfunction createStreamWorker<TInput, TChunk>(url: URL | string, options?: WorkerOptions): StreamWorkerPool<TInput, TChunk>;\n```\n\nCreates a stream-only pool for a worker module registered with `exposeStream()`.\n\n**Returns:** `StreamWorkerPool<TInput, TChunk>`.\n\n---\n\n### `batch()`\n\n```ts\nfunction batch<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n inputs: readonly TInput[],\n options?: BatchOptions,\n): AsyncIterable<TOutput>;\n```\n\nYields results in submission order. A failure or cancellation aborts remaining batch work.\n\n**Returns:** `AsyncIterable<TOutput>`.\n\n---\n\n### `createTaskGroup()`\n\n```ts\nfunction createTaskGroup<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n name?: string,\n options?: TaskGroupOptions,\n): TaskGroup<TInput, TOutput>;\n```\n\nCreates group-scoped cancellation and settlement tracking for one task pool.\n\n**Returns:** `TaskGroup<TInput, TOutput>`.\n\n## Testing\n\n### `createTestWorker()`\n\n```ts\nfunction createTestWorker<TInput, TOutput>(\n handler: (input: TInput) => TOutput | Promise<TOutput>,\n options?: TestWorkerOptions,\n): TestWorkerHandle<TInput, TOutput>;\n```\n\nCreates an in-process task-pool double. It structured-clones values, records settlement, and matches task-pool timeout and cancellation behavior without loading a worker module.\n\n**Returns:** `TestWorkerHandle<TInput, TOutput>`.\n\n## Worker Protocol\n\n### `exposeTask()`\n\n```ts\nfunction exposeTask<TInput, TOutput>(handler: TaskHandler<TInput, TOutput>): void;\n```\n\nRegisters one single-result handler in a module worker.\n\n### `exposeStream()`\n\n```ts\nfunction exposeStream<TInput, TChunk>(handler: StreamHandler<TInput, TChunk>): void;\n```\n\nRegisters one chunk-producing handler in a module worker.\n\n### `PROTOCOL_VERSION`\n\n```ts\nconst PROTOCOL_VERSION: 1;\n```\n\nVersion included in every host request and worker response.\n\n## Types\n\n### `WorkerOptions`\n\n```ts\ntype WorkerOptions = {\n concurrency?: number | 'auto';\n maxQueue?: number;\n onFull?: 'reject' | 'wait';\n timeout?: number;\n onSlotError?: (error: FamiliarRuntimeError) => void;\n};\n```\n\n### `RunOptions`\n\n```ts\ntype RunOptions = {\n priority?: number;\n signal?: AbortSignal;\n timeout?: number;\n transferables?: Transferable[];\n};\n```\n\n`signal` cancels capacity waits, queued work, and executing work. Executing cancellation terminates and replaces its worker slot.\n\n### `WorkerPool`\n\n```ts\ninterface WorkerPool<TInput, TOutput> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n run(input: TInput, options?: RunOptions): Promise<TOutput>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n```\n\n### `StreamWorkerPool`\n\n```ts\ninterface StreamWorkerPool<TInput, TChunk> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n runStream(input: TInput, options?: RunOptions): AsyncIterable<TChunk>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n}\n```\n\n### `WorkerStats`\n\n```ts\ntype WorkerStats = {\n readonly active: number;\n readonly completed: number;\n readonly failed: number;\n readonly queued: number;\n};\n```\n\n### `RunningStream`\n\n```ts\ntype RunningStream<TChunk> = {\n done: Promise<void>;\n iterable: AsyncIterable<TChunk>;\n};\n```\n\n### `WorkerStatus`\n\n```ts\ntype WorkerStatus = 'idle' | 'running' | 'terminated';\n```\n\n### `BatchOptions`\n\n```ts\ntype BatchOptions = RunOptions;\n```\n\n### `DrainOptions`\n\n```ts\ntype DrainOptions = {\n timeout?: number;\n};\n```\n\n### `TaskGroup`\n\n```ts\ntype TaskGroup<TInput, TOutput> = {\n abort(reason?: unknown): void;\n drain(): Promise<PromiseSettledResult<TOutput>[]>;\n readonly name: string | undefined;\n readonly pending: number;\n run(input: TInput, options?: Omit<RunOptions, 'signal'>): Promise<TOutput>;\n readonly size: number;\n};\n```\n\n### `TaskGroupOptions`\n\n```ts\ntype TaskGroupOptions = {\n signal?: AbortSignal;\n};\n```\n\n### `TestWorkerOptions`\n\n```ts\ntype TestWorkerOptions = Omit<WorkerOptions, 'concurrency' | 'onSlotError'> & {\n concurrency?: number;\n};\n```\n\n### `TestWorkerCall`\n\n```ts\ntype TestWorkerCall<TInput, TOutput> =\n | { input: TInput; status: 'fulfilled'; value: TOutput }\n | { input: TInput; reason: unknown; status: 'rejected' };\n```\n\n### `TestWorkerHandle`\n\n```ts\ntype TestWorkerHandle<TInput, TOutput> = WorkerPool<TInput, TOutput> & {\n readonly calls: ReadonlyArray<TestWorkerCall<TInput, TOutput>>;\n};\n```\n\n### `SerializedError`\n\n```ts\ntype SerializedError = {\n message: string;\n name: string;\n stack?: string;\n};\n```\n\n### `WorkerRequest`\n\n```ts\ntype WorkerRequest<TInput> =\n | { id: number; input: TInput; kind: 'run'; version: 1 }\n | { id: number; input: TInput; kind: 'stream'; version: 1 };\n```\n\n### `WorkerResponse`\n\n```ts\ntype WorkerResponse<TOutput> =\n | { id: number; kind: 'chunk'; value: TOutput; version: 1 }\n | { error: SerializedError; id: number; kind: 'error'; version: 1 }\n | { id: number; kind: 'result'; value: TOutput; version: 1 };\n```\n\n### `TaskHandler` and `StreamHandler`\n\n```ts\ntype TaskHandler<TInput, TOutput> = (input: TInput) => TOutput | Promise<TOutput>;\ntype StreamHandler<TInput, TChunk> = (input: TInput) => AsyncIterable<TChunk> | Promise<AsyncIterable<TChunk>>;\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `FamiliarError` | Base class for all Familiar errors | Use `instanceof FamiliarError` to narrow |\n| `FamiliarInvalidOptionsError` | Invalid factory or test options | — |\n| `FamiliarQueueFullError` | Queue limit reached with `onFull: 'reject'` | `maxQueue` |\n| `FamiliarTaskError` | Worker handler throws or payload cannot clone | `cause` |\n| `FamiliarTimeoutError` | Task or drain deadline expires | `timeoutMs` |\n| `FamiliarTerminatedError` | Pool is disposed or draining | — |\n| `FamiliarRuntimeError` | Worker API or worker process fails | `cause` |\n",
6
6
  "usage": "---\ntitle: Familiar — Usage Guide\ndescription: Run task and stream module workers with bounded concurrency, cancellation, and test parity.\n---\n\n[[toc]]\n\n## Basic Usage\n\nPut task logic in a worker module. Imports and helpers stay normal module code.\n\n```ts\n// normalize.worker.ts\nimport { exposeTask } from '@vielzeug/familiar/protocol';\n\nimport { normalize } from './normalize';\n\nexposeTask((text: string) => normalize(text));\n```\n\nCreate one long-lived pool at its owner boundary.\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker<string, string>(new URL('./normalize.worker.ts', import.meta.url), {\n concurrency: 2,\n timeout: 2_000,\n});\n\ntry {\n const normalized = await pool.run(' Familiar ');\n console.log(normalized);\n} finally {\n pool.dispose();\n}\n```\n\n## Cancellation and Timeouts\n\nPass one signal to stop capacity waits, queued work, or active work. Cancelling active work terminates and lazily replaces its slot.\n\n```ts\nconst controller = new AbortController();\nconst result = pool.run('input', { signal: controller.signal, timeout: 500 });\n\ncontroller.abort();\nawait result.catch((error) => console.log(error.name)); // AbortError\n```\n\n## Queue Policy and Priority\n\nUse `maxQueue` to bound waiting work. Higher priorities dispatch first once a slot opens.\n\n```ts\nconst pool = createWorker<Job, Result>(new URL('./job.worker.ts', import.meta.url), {\n concurrency: 2,\n maxQueue: 100,\n onFull: 'wait',\n});\n\nawait pool.run(criticalJob, { priority: 10 });\n```\n\n## Batch and Groups\n\nCompose task pools with free helpers instead of carrying unrelated methods on every pool.\n\n```ts\nimport { batch, createTaskGroup } from '@vielzeug/familiar';\n\nfor await (const value of batch(pool, inputs)) {\n console.log(value);\n}\n\nconst group = createTaskGroup(pool, 'import');\nconst tasks = rows.map((row) => group.run(row));\nawait group.drain();\nawait Promise.all(tasks);\n```\n\n## Streaming\n\nStream workers have their own capability and registration helper.\n\n```ts\n// tokenize.worker.ts\nimport { exposeStream } from '@vielzeug/familiar/protocol';\n\nexposeStream(async function* (text: string) {\n for (const token of text.split(/\\s+/)) yield token;\n});\n```\n\n```ts\nimport { createStreamWorker } from '@vielzeug/familiar';\n\nconst pool = createStreamWorker<string, string>(new URL('./tokenize.worker.ts', import.meta.url));\nfor await (const token of pool.runStream('typed module workers')) {\n console.log(token);\n}\npool.dispose();\n```\n\n## Testing\n\nUse `createTestWorker()` when testing consumer code that depends on a task pool. It clones input/output, wraps task failures, and honors cancellation and timeout behavior.\n\n```ts\nimport { createTestWorker } from '@vielzeug/familiar/testing';\n\nconst pool = createTestWorker((value: number) => value * 2);\nawait expect(pool.run(21)).resolves.toBe(42);\nexpect(pool.calls).toEqual([{ input: 21, status: 'fulfilled', value: 42 }]);\npool.dispose();\n```\n\nTest worker-module business logic directly when possible. `createTestWorker()` does not run module files or support stream pools.\n\n## Framework Integration\n\nCreate a pool once per component lifetime. Abort obsolete requests during effect cleanup and dispose the pool on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useMemo } from 'react';\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = useMemo(() => createWorker(new URL('./sort.worker.ts', import.meta.url)), []);\n\nuseEffect(() => () => pool.dispose(), [pool]);\n```\n\n```ts [Vue]\nimport { onUnmounted } from 'vue';\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker(new URL('./sort.worker.ts', import.meta.url));\n\nonUnmounted(() => pool.dispose());\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker(new URL('./sort.worker.ts', import.meta.url));\n\nonDestroy(() => pool.dispose());\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse `@vielzeug/arsenal` async helpers in application orchestration. Keep worker module protocol registration in `@vielzeug/familiar/protocol`.\n\n## Best Practices\n\n- Put every task handler in its own module-worker boundary.\n- Reuse pools for repeated work; dispose owner-scoped pools.\n- Abort work made obsolete by navigation or newer input.\n- Transfer large binary buffers instead of cloning them.\n- Set explicit timeouts for work with a bounded latency budget.\n- Keep worker handlers deterministic and data-only.\n- Test module logic directly; test pool consumers with `createTestWorker()`.\n",
7
7
  "examples": "---\ntitle: Familiar — Examples\ndescription: Module-worker recipes for familiar.\n---\n\n## Examples\n\n- [Fibonacci With Pool And Timeout](./examples/fibonacci-with-pool-and-timeout.md)\n- [Data Transformation Pipeline](./examples/data-transformation-pipeline.md)\n- [Image Processing](./examples/image-processing.md)\n- [Using Transferables](./examples/using-transferables.md)\n- [Cancellable Batch](./examples/cancellable-batch.md)\n- [Priority Queue](./examples/priority-queue.md)\n- [Streaming With Stream Worker](./examples/streaming-with-runstream.md)\n- [Module Worker](./examples/module-worker.md)\n- [Typed Error Handling](./examples/typed-error-handling.md)\n- [React Integration](./examples/react-integration.md)\n- [Testing With createTestWorker](./examples/testing-with-createtestworker.md)\n"
8
8
  },
@@ -0,0 +1,37 @@
1
+ {
2
+ "apiSource": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';\nexport { createListNavigation } from './list-navigation';\nexport type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus';\nexport { captureFocus, restoreFocus } from './restore-focus';\n",
3
+ "docs": {
4
+ "index": "---\ntitle: Focus — Navigation and restoration\ndescription: Framework-neutral list navigation and focus restoration primitives.\npackage: focus\ncategory: input\nkeywords: [focus, roving, keyboard, accessibility, list navigation]\nexports: [createListNavigation, captureFocus, restoreFocus]\nrelated: [refine, keymap, ore]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"focus\" />\n\n## Why Focus?\n\nComposite widgets need consistent keyboard navigation and predictable return focus behavior. Focus centralizes those primitives without coupling to component rendering or framework state.\n\n```ts\n// Before\nlist.addEventListener('keydown', (event) => {\n // arrow/home/end bookkeeping, disabled filtering, wrapping\n});\n\n// After\nconst nav = createListNavigation({ getItems, onNavigate: ({ item }) => item.focus() });\nlist.addEventListener('keydown', nav.handleKeydown);\n```\n\n| Feature | Per-component navigation | Focus |\n| --- | --- | --- |\n| Bundle size | n/a | <PackageInfo package=\"focus\" type=\"size\" /> |\n| Zero dependencies | n/a | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| RTL mirroring | Manual | Built in |\n| Typeahead | Manual | Optional via `typeahead` |\n| Focus restoration | Manual capture | `captureFocus()` / `restoreFocus()` |\n\n<div class=\"decision-callout\">\n\n**Use Focus when** a widget needs arrow-key navigation, Home/End, and controlled focus restoration.\n\n**Consider direct focus calls when** interaction is a single isolated element with no composite navigation.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/focus\n```\n\n```sh [npm]\nnpm install @vielzeug/focus\n```\n\n```sh [yarn]\nyarn add @vielzeug/focus\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { captureFocus, createListNavigation } from '@vielzeug/focus';\n\nconst restore = captureFocus();\nconst nav = createListNavigation({\n getItems: () => items,\n loop: true,\n onNavigate: ({ item }) => item.focus(),\n});\n\ncontainer.addEventListener('keydown', nav.handleKeydown);\n\nrestore();\nnav.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createListNavigation()` — reusable composite-widget keyboard navigation\n- Orientation and direction support — vertical/horizontal/both with LTR/RTL defaults\n- Dynamic item queries — disabled filtering and loop control\n- Optional typeahead — label-based navigation in key-driven lists\n- `captureFocus()` and `restoreFocus()` — explicit return-focus helpers\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Refine](/refine/) — component primitives integrating list navigation.\n- [Keymap](/keymap/) — global and scoped keyboard shortcuts.\n- [Ore](/ore/) — lifecycle ownership used by consumer components.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: Focus — API Reference\ndescription: API reference for @vielzeug/focus navigation and restoration primitives.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createListNavigation()` | Build keyboard navigation for composite widgets | Sync | Disabled items require an explicit predicate |\n| `restoreFocus()` | Restore focus to a target or fallback | Sync | Returns `false` when neither target can receive focus |\n| `captureFocus()` | Capture active focus for one later restoration | Sync | The returned function is one-shot |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/focus` | List navigation and focus restoration primitives. |\n\n## Core Functions\n\n### `createListNavigation()`\n\n```ts\nfunction createListNavigation<T>(options: ListNavigationOptions<T>): ListNavigation<T>;\n```\n\nCreates a keyboard navigation controller with an internal active index.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `ListNavigationOptions<T>` | Item lookup, key mapping, navigation, typeahead, and lifecycle options. |\n\n**Returns:** `ListNavigation<T>`.\n\n**Example**\n\n```ts\nimport { createListNavigation } from '@vielzeug/focus';\n\nconst nav = createListNavigation({\n getItems: () => rows,\n isItemDisabled: (item) => item.matches('[aria-disabled=\"true\"]'),\n onNavigate: ({ item }) => item.focus(),\n});\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `handleKeydown(event)` | `boolean` | Handles configured navigation keys and optional typeahead. |\n| `navigate(action)` | `number` | Moves programmatically and returns the active index, or `-1`. |\n| `set(index)` | `number` | Sets the active index when usable, or resets it to `-1`. |\n| `reset()` | `void` | Clears the active index and typeahead sequence. |\n| `getIndex()` | `number` | Returns the current usable index, or `-1`. |\n| `getActiveItem()` | `T \\| undefined` | Returns the item at the current usable index. |\n| `dispose()` | `void` | Permanently disables the controller and aborts `disposalSignal`. |\n| `disposed` | `boolean` | Indicates whether the controller is permanently disabled. |\n| `disposalSignal` | `AbortSignal` | Aborts when the controller is disposed. |\n| `[Symbol.dispose]()` | `void` | Calls `dispose()`. |\n\n---\n\n### `restoreFocus()`\n\n```ts\nfunction restoreFocus(target: FocusTarget, options?: RestoreFocusOptions): boolean;\n```\n\nAttempts to focus a connected target that is neither disabled nor inert.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `target` | `FocusTarget` | Element or getter resolved when `restoreFocus()` is called. |\n| `options` | `RestoreFocusOptions` | Optional lazy fallback and `preventScroll` flag. |\n\n**Returns:** `boolean` — `true` when focus moved to the target or fallback.\n\n**Example**\n\n```ts\nimport { restoreFocus } from '@vielzeug/focus';\n\nrestoreFocus(() => triggerElement, {\n fallback: () => document.body,\n preventScroll: true,\n});\n```\n\n---\n\n### `captureFocus()`\n\n```ts\nfunction captureFocus(options?: CaptureFocusOptions): FocusRestorer;\n```\n\nCaptures the deepest active element immediately and returns a one-shot restoration function.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `CaptureFocusOptions` | Optional lazy fallback, `preventScroll`, and cancellation signal. |\n\n**Returns:** `FocusRestorer`. Its first call attempts restoration; later calls return `false`.\n\n**Example**\n\n```ts\nimport { captureFocus } from '@vielzeug/focus';\n\nconst restore = captureFocus({ fallback: () => document.body });\n\ndialog.showModal();\ndialog.addEventListener('close', restore, { once: true });\n```\n\n## Types\n\n```ts\ntype MaybeGetter<T> = T | (() => T);\n\ntype ListNavigationAction = 'first' | 'last' | 'next' | 'prev';\ntype ListKeyAction = ListNavigationAction | 'typeahead';\n\ntype ListNavigationChange<T> = {\n action: ListKeyAction;\n event?: KeyboardEvent;\n index: number;\n item: T;\n};\n\ntype ListNavigationTypeaheadOptions<T> = {\n delayMs?: number;\n getLabel: (item: T, index: number) => string;\n};\n\ntype ListNavigationOptions<T> = {\n direction?: MaybeGetter<'ltr' | 'rtl'>;\n disabled?: MaybeGetter<boolean | undefined>;\n getItems: () => readonly T[];\n isItemDisabled?: (item: T, index: number) => boolean;\n keys?: Partial<Record<ListNavigationAction, readonly string[]>>;\n loop?: boolean;\n onNavigate?: (change: ListNavigationChange<T>) => void;\n orientation?: MaybeGetter<'both' | 'horizontal' | 'vertical'>;\n signal?: AbortSignal;\n typeahead?: ListNavigationTypeaheadOptions<T>;\n};\n\ntype ListNavigation<T> = {\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n dispose(): void;\n getActiveItem(): T | undefined;\n getIndex(): number;\n handleKeydown(event: KeyboardEvent): boolean;\n navigate(action: ListNavigationAction): number;\n reset(): void;\n set(index: number): number;\n [Symbol.dispose](): void;\n};\n\ntype FocusTarget = HTMLElement | SVGElement | null | undefined | (() => HTMLElement | SVGElement | null | undefined);\n\ntype RestoreFocusOptions = {\n fallback?: FocusTarget;\n preventScroll?: boolean;\n};\n\ntype CaptureFocusOptions = RestoreFocusOptions & {\n signal?: AbortSignal;\n};\n\ntype FocusRestorer = () => boolean;\n```\n\n`typeahead.delayMs` defaults to `500`. Non-finite or non-positive values use the default.\n\n## Errors\n\n`@vielzeug/focus` does not export custom error classes.\n",
6
+ "usage": "---\ntitle: Focus — Usage Guide\ndescription: Build keyboard-focus navigation and restoration into composite widgets.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one navigation handle for a composite widget and forward `keydown` events to it.\n\n```ts\nimport { createListNavigation } from '@vielzeug/focus';\n\nconst nav = createListNavigation({\n getItems: () => items,\n loop: true,\n onNavigate: ({ item }) => item.focus(),\n});\n\nlist.addEventListener('keydown', nav.handleKeydown);\n```\n\n## Orientation and Direction\n\nUse orientation and direction to derive default key bindings.\n\n```ts\nconst nav = createListNavigation({\n direction: () => (document.dir === 'rtl' ? 'rtl' : 'ltr'),\n getItems: () => tabs,\n orientation: 'horizontal',\n});\n```\n\n## Disabled and Dynamic Items\n\nProvide `isItemDisabled` when disabled state is data-driven.\n\n```ts\nconst nav = createListNavigation({\n getItems: () => rows,\n isItemDisabled: (item) => item.hasAttribute('aria-disabled'),\n});\n```\n\n## Typeahead\n\nEnable character-based navigation with the `typeahead` option.\n\n```ts\nconst nav = createListNavigation({\n getItems: () => menuItems,\n typeahead: {\n delayMs: 300,\n getLabel: (item) => item.textContent ?? '',\n },\n});\n```\n\n`typeahead.delayMs` defaults to `500`. Repeated characters cycle matching items without waiting for the timeout.\n\n## Focus Restoration\n\nCapture focus before opening a floating surface and restore it after closing.\n\n```ts\nimport { captureFocus } from '@vielzeug/focus';\n\nconst restore = captureFocus();\n\nopenDialog();\ncloseDialog();\nrestore();\n```\n\n## Framework Integration\n\nCreate the navigation handle once per component instance and dispose it on unmount. The handle is framework-neutral — wire `keydown` from whatever element owns the composite widget's keyboard surface.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createListNavigation } from '@vielzeug/focus';\n\nfunction Tabs({ tabs }: { tabs: Array<{ id: string; label: string }> }) {\n const listRef = useRef<HTMLDivElement>(null);\n const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);\n\n useEffect(() => {\n const list = listRef.current;\n if (!list) return;\n\n const nav = createListNavigation({\n getItems: () => tabRefs.current.filter((el): el is HTMLButtonElement => el !== null),\n loop: true,\n onNavigate: ({ item }) => item.focus(),\n orientation: 'horizontal',\n });\n\n list.addEventListener('keydown', nav.handleKeydown);\n return () => {\n list.removeEventListener('keydown', nav.handleKeydown);\n nav.dispose();\n };\n }, []);\n\n return (\n <div ref={listRef} role=\"tablist\">\n {tabs.map((tab, i) => (\n <button\n key={tab.id}\n ref={(el) => { tabRefs.current[i] = el; }}\n role=\"tab\"\n >\n {tab.label}\n </button>\n ))}\n </div>\n );\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, ref } from 'vue';\nimport { createListNavigation } from '@vielzeug/focus';\n\nconst props = defineProps<{ tabs: Array<{ id: string; label: string }> }>();\n\nconst listEl = ref<HTMLDivElement | null>(null);\nconst tabEls = ref<Array<HTMLButtonElement | null>>([]);\n\nlet nav: ReturnType<typeof createListNavigation> | undefined;\n\nonMounted(() => {\n if (!listEl.value) return;\n\n nav = createListNavigation({\n getItems: () => tabEls.value.filter((el): el is HTMLButtonElement => el !== null),\n loop: true,\n onNavigate: ({ item }) => item.focus(),\n orientation: 'horizontal',\n });\n\n listEl.value.addEventListener('keydown', nav.handleKeydown);\n});\n\nonUnmounted(() => {\n if (nav) listEl.value?.removeEventListener('keydown', nav.handleKeydown);\n nav?.dispose();\n});\n</script>\n\n<template>\n <div ref=\"listEl\" role=\"tablist\">\n <button\n v-for=\"(tab, i) in tabs\"\n :key=\"tab.id\"\n :ref=\"(el) => { tabEls[i] = el as HTMLButtonElement | null; }\"\n role=\"tab\"\n >\n {{ tab.label }}\n </button>\n </div>\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createListNavigation } from '@vielzeug/focus';\n\n let { tabs }: { tabs: Array<{ id: string; label: string }> } = $props();\n\n let listEl: HTMLDivElement;\n let tabEls: HTMLButtonElement[] = [];\n\n onMount(() => {\n const nav = createListNavigation({\n getItems: () => tabEls,\n loop: true,\n onNavigate: ({ item }) => item.focus(),\n orientation: 'horizontal',\n });\n\n listEl.addEventListener('keydown', nav.handleKeydown);\n return () => {\n listEl.removeEventListener('keydown', nav.handleKeydown);\n nav.dispose();\n };\n });\n</script>\n\n<div bind:this={listEl} role=\"tablist\">\n {#each tabs as tab, i}\n <button bind:this={tabEls[i]} role=\"tab\">{tab.label}</button>\n {/each}\n</div>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Focus + Refine\n\nRefine's `ore-menu`, `ore-dialog`, and `ore-list` use Focus internally for keyboard navigation and focus restoration. When building custom composite widgets on top of Refine components, use `createListNavigation` for the keyboard layer and let Refine handle rendering.\n\n```ts\nimport { createListNavigation } from '@vielzeug/focus';\n\n// Custom tab bar built alongside ore-tab panels\nconst tabNav = createListNavigation({\n getItems: () => Array.from(host.querySelectorAll('[role=\"tab\"]')),\n loop: true,\n onNavigate: ({ item }) => item.focus(),\n orientation: 'horizontal',\n});\n\nhost.addEventListener('keydown', tabNav.handleKeydown);\n```\n\n### Focus + Keymap\n\nUse Keymap for global shortcuts and Focus for composite-widget navigation. They operate on different event layers without conflict.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\nimport { createListNavigation } from '@vielzeug/focus';\n\nconst nav = createListNavigation({ getItems: () => items, onNavigate: ({ item }) => item.focus() });\n\nconst map = createKeymap({\n 'mod+k': () => openPalette(),\n escape: () => nav.reset(),\n});\n\nlist.addEventListener('keydown', nav.handleKeydown);\nmap.mount(document);\n```\n\n## Best Practices\n\n- **Keep** item discovery in one function.\n- **Drive** focus side effects from `onNavigate`.\n- **Reset** navigation on overlay close when focus context changes.\n- **Use** typeahead only when labels are stable and meaningful.\n- **Capture** return focus before opening transient surfaces.\n- **Dispose** handles when owners unmount.\n",
7
+ "examples": "---\ntitle: Focus — Examples\ndescription: Worked examples for @vielzeug/focus.\n---\n\n## Examples\n\n- [Roving Tabs Keyboard Navigation](./examples/roving-tabs-keyboard-navigation.md)\n- [Dialog Return Focus Restoration](./examples/dialog-return-focus-restoration.md)\n"
8
+ },
9
+ "examples": [
10
+ {
11
+ "id": "list-navigation",
12
+ "code": "import { createListNavigation } from '@vielzeug/focus'\n\nconst labels = ['Apple', 'Banana', 'Cherry']\nconst list = document.createElement('div')\nlist.setAttribute('role', 'listbox')\n\nconst items = labels.map((label, index) => {\n const item = document.createElement('button')\n item.textContent = label\n item.disabled = index === 1\n item.tabIndex = index === 0 ? 0 : -1\n list.appendChild(item)\n return item\n})\n\ndocument.body.appendChild(list)\n\nconst navigation = createListNavigation({\n getItems: () => items,\n isItemDisabled: (item) => item.disabled,\n loop: true,\n onNavigate: ({ item }) => {\n items.forEach((candidate) => {\n candidate.tabIndex = candidate === item ? 0 : -1\n })\n item.focus()\n },\n})\n\nnavigation.set(0)\nlist.addEventListener('keydown', navigation.handleKeydown)\nitems[0].focus()\nitems[0].dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowDown' }))\n\nconsole.log(document.activeElement?.textContent) // 'Cherry'",
13
+ "name": "List Navigation"
14
+ },
15
+ {
16
+ "id": "restore-focus",
17
+ "code": "import { captureFocus } from '@vielzeug/focus'\n\nconst trigger = document.createElement('button')\ntrigger.textContent = 'Open dialog'\n\nconst dialogButton = document.createElement('button')\ndialogButton.textContent = 'Close dialog'\n\ndocument.body.append(trigger, dialogButton)\ntrigger.focus()\n\nconst restore = captureFocus()\ndialogButton.focus()\n\nconsole.log(restore()) // true\nconsole.log(document.activeElement === trigger) // true\nconsole.log(restore()) // false: restorers are one-shot",
18
+ "name": "Restore Captured Focus"
19
+ }
20
+ ],
21
+ "typeSignatures": {
22
+ "ListKeyAction": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';",
23
+ "ListNavigation": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';",
24
+ "ListNavigationAction": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';",
25
+ "ListNavigationChange": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';",
26
+ "ListNavigationOptions": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';",
27
+ "ListNavigationTypeaheadOptions": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';",
28
+ "MaybeGetter": "export type {\n ListKeyAction,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation';",
29
+ "createListNavigation": "export { createListNavigation } from './list-navigation';",
30
+ "CaptureFocusOptions": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus';",
31
+ "FocusRestorer": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus';",
32
+ "FocusTarget": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus';",
33
+ "RestoreFocusOptions": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus';",
34
+ "captureFocus": "export { captureFocus, restoreFocus } from './restore-focus';",
35
+ "restoreFocus": "export { captureFocus, restoreFocus } from './restore-focus';"
36
+ }
37
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
- "apiSource": "export { toFormData } from './adapters/form-data';\nexport { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors';\nexport { createForm } from './form';\nexport * from './types';\n",
2
+ "apiSource": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors';\nexport { createForm } from './form';\nexport * from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Forge — Immutable form state for TypeScript\ndescription: Framework-agnostic immutable form state with focused object fields and explicit validation results.\npackage: forge\ncategory: forms\nkeywords: [form-state, validation, immutable, input, submission]\nrelated: [spell, vault, courier]\nexports: [createForm, toFormData, bindField, customValidator, saveForm, loadForm]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"forge\" />\n\n## Why Forge?\n\nNative form state becomes difficult to inspect once values, validation, draft restoration, and UI bindings share mutable objects. Forge owns one immutable value tree and gives you typed handles for object branches without string paths, scoped controllers, or framework state.\n\n```ts\n// Before\nconst values = { email: '', password: '' };\nconst errors: Record<string, string> = {};\n\nfunction submit() {\n errors.email = values.email.includes('@') ? '' : 'Invalid email';\n errors.password = values.password.length >= 8 ? '' : 'Use at least eight characters';\n}\n\n// After\nconst form = createForm({\n initialValues: { email: '', password: '' },\n validate: (value) => ({\n fields: {\n email: value.email.includes('@') ? undefined : 'Invalid email',\n password: value.password.length >= 8 ? undefined : 'Use at least eight characters',\n },\n }),\n});\n```\n\n| Feature | Forge | Native form state | Framework-owned form state |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"forge\" type=\"size\" /> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Varies |\n| Zero external dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Immutable nested values | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Varies |\n| Typed object field handles | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Varies |\n| Framework-independent state | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Forge when** form state needs framework-independent immutable values, typed object fields, and one explicit validation boundary.\n\n**Consider framework-owned form state when** application only needs a single UI framework's native input bindings.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/forge\n```\n\n```sh [npm]\nnpm install @vielzeug/forge\n```\n\n```sh [yarn]\nyarn add @vielzeug/forge\n```\n\n:::\n\nInstall `@vielzeug/spell` or `@vielzeug/vault` only when importing Forge's matching optional adapter.\n\n## Quick Start\n\nCreate a form, update a focused field, and submit only after validation passes.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: { profile: { email: '', name: '' } },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'Invalid email' } },\n }),\n});\n\nform.field('profile').field('email').set('ada@example.com');\n\nconst result = await form.submit(async (value) => {\n const response = await fetch('/api/profile', {\n body: JSON.stringify(value),\n headers: { 'Content-Type': 'application/json' },\n method: 'POST',\n });\n\n return response.ok;\n});\n\nif (!result.ok && result.type === 'validation') console.log(result.errors);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `form.value` exposes one immutable nested value tree.\n- `form.field(key)` selects typed object branches without string paths.\n- `field.set(updater)` replaces array values without index handles.\n- `form.validate()` returns valid, invalid, or aborted results.\n- `form.submit(handler)` touches, validates, and invokes the handler when valid.\n- `bindField()` connects one DOM element without owning validation timing.\n- `customValidator()` maps Spell schema errors into Forge fields.\n- `saveForm()` and `loadForm()` persist explicit Vault draft records.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Spell](/spell/) — adapt a Spell schema through `customValidator()`.\n- [Vault](/vault/) — save and restore explicit Forge draft records.\n- [Courier](/courier/) — send a validated form value through a mutation.\n\n</div>\n\n<!-- markdownlint-enable -->\n",
5
- "api": "---\ntitle: Forge — API Reference\ndescription: Complete reference for immutable forms, fields, validation, serialization, and optional adapters.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createForm()` | Create immutable form state | Sync | `initialValues` cannot contain mutable class instances |\n| `form.field()` | Select a top-level or object child field | Sync | Arrays have no index field handles |\n| `form.validate()` | Validate complete value | Async | Handle `aborted` separately |\n| `form.submit()` | Touch, validate, then invoke handler | Async | Concurrent calls reject |\n| `form.reset()` | Restore or replace baseline | Sync | `reset(next)` makes `next` clean |\n| `form.subscribe()` | Observe form metadata | Sync | Throws after disposal |\n| `toFormData()` | Serialize values for multipart transport | Sync | `FileList` is transport-only |\n| `bindField()` | Bind one DOM element | Sync | Does not schedule validation |\n| `customValidator()` | Adapt a Spell schema | Async | Does not transform `form.value` |\n| `saveForm()` / `loadForm()` | Persist explicit Vault records | Async | FormDraftCodec owns record shape |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/forge` | Core form factory, serialization helper, types, and errors |\n| `@vielzeug/forge/dom` | `bindField()` and DOM binding types |\n| `@vielzeug/forge/spell` | `customValidator()` |\n| `@vielzeug/forge/vault` | `saveForm()`, `loadForm()`, and `FormDraftCodec` |\n\n## Core Functions\n\n### `createForm(options)`\n\n```ts\nfunction createForm<TValues extends Record<string, unknown>>(options: FormOptions<TValues>): Form<TValues>;\n```\n\nCreates a form with immutable initial values and an optional full-form validator.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.initialValues` | `TValues` | Initial value and reset baseline. Supports primitives, plain objects, arrays, `File`, and `Blob`. |\n| `options.validate` | `FormValidator<TValues>` | Optional validator for the entire current value. |\n| `options.onSubscriberError` | `(error: unknown) => void` | Optional subscriber failure reporter. |\n\n**Returns:** `Form<TValues>`.\n\n**Example:**\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\n```\n\n---\n\n### `toFormData(values)`\n\n```ts\nfunction toFormData(values: Record<string, unknown>): FormData;\n```\n\nConverts nested values into `FormData` with dot-separated object keys and repeated array keys.\n\n**Returns:** a populated `FormData` instance.\n\n**Example:**\n\n```ts\nimport { toFormData } from '@vielzeug/forge';\n\nconst body = toFormData({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });\n```\n\n## Form Handles\n\n### `Form<TValues>`\n\n`createForm()` returns this handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<TValues>` | Current immutable value. |\n| `state` | `FormState<TValues>` | Submission, validation, touch, and error metadata. |\n| `field(key)` | `Field<TValues[K]>` | Select a top-level field. |\n| `set(next)` | `void` | Replace the complete value or derive a replacement. |\n| `reset(next?)` | `void` | Restore baseline or make `next` the baseline. |\n| `validate(signal?)` | `Promise<ValidationResult<TValues>>` | Run full-form validation. |\n| `submit(handler)` | `Promise<SubmitResult<TResult, TValues>>` | Touch, validate, and invoke handler when valid. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe form state; throws after disposal. |\n| `dispose()` | `void` | Abort validation and clear subscribers. |\n| `disposed` | `boolean` | Whether the form has been disposed. |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal. |\n\n### `Field<V>`\n\n`form.field(key)` and object-field `.field(key)` return this handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<V>` | Current immutable branch value. |\n| `error` | `string \\| undefined` | Current field error. |\n| `dirty` | `boolean` | Whether branch differs from baseline. |\n| `touched` | `boolean` | Whether field was touched. |\n| `field(key)` | `Field<V[K]>` | Select child object field only. |\n| `set(next)` | `void` | Replace branch or derive a replacement. |\n| `reset()` | `void` | Restore exact baseline branch. |\n| `touch()` | `void` | Mark field touched. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe field transitions; throws after disposal. |\n\n## Validation Results\n\n### `form.validate(signal?)`\n\n```ts\nfunction validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n```\n\nRuns the configured validator against the complete value. A newer validation aborts the older run.\n\n**Returns:** `ValidationResult<TValues>`.\n\n```ts\nconst result = await form.validate();\n\nif (result.status === 'invalid') console.log(result.errors, result.formError);\n```\n\n### `form.submit(handler)`\n\n```ts\nfunction submit<TResult = void>(handler: (values: ReadonlyDeep<TValues>) => MaybePromise<TResult>): Promise<SubmitResult<TResult, TValues>>;\n```\n\nTouches all fields, validates once, and invokes `handler` when validation is valid.\n\n**Returns:** `SubmitResult<TResult, TValues>`. Handler failures reject normally.\n\n```ts\nconst result = await form.submit((value) => Promise.resolve(value));\n```\n\n## Adapters\n\n### `bindField(element, field, options)`\n\n```ts\nfunction bindField<Element extends HTMLElement, V>(\n element: Element,\n field: Field<V>,\n options: FieldBindingOptions<Element, V>,\n): () => void;\n```\n\nBinds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.\n\n**Example:**\n\n```ts\nimport { bindField } from '@vielzeug/forge/dom';\n\nconst stop = bindField(input, form.field('email'), {\n read: (element) => element.value,\n write: (element, value) => {\n element.value = value;\n },\n});\n```\n\n---\n\n### `customValidator(schema)`\n\n```ts\nfunction customValidator<TValues extends Record<string, unknown>>(\n schema: Schema<unknown, TValues, SchemaMode>,\n): FormValidator<TValues>;\n```\n\nAdapts a Spell schema. Every failing union maps its closest branch while preserving unrelated errors. Array item issues map to the parent array field; duplicate paths retain the first message.\n\n**Example:**\n\n```ts\nimport { customValidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst Profile = s.object({ email: s.string().email() });\nconst form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });\n```\n\n---\n\n### `saveForm()` and `loadForm()`\n\n```ts\nfunction saveForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, codec: FormDraftCodec<TValues, S, K>,\n): Promise<void>;\n\nfunction loadForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, key: KeyOf<S, K>, codec: FormDraftCodec<TValues, S, K>,\n): Promise<boolean>;\n```\n\nPersists or restores a codec-defined Vault record. `loadForm()` calls `form.reset()` when the codec decodes a record.\n\n**Returns:** `loadForm()` returns `false` for a missing or rejected record.\n\n## Types\n\n```ts\ntype Unsubscribe = () => void;\ntype MaybePromise<T> = T | PromiseLike<T>;\ntype ReadonlyDeep<T> = T extends (...args: never[]) => unknown\n ? T\n : T extends readonly (infer Item)[]\n ? readonly ReadonlyDeep<Item>[]\n : T extends Record<string, unknown>\n ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }\n : T;\n\ntype FormErrors<T> = T extends readonly unknown[]\n ? string\n : T extends Record<string, unknown>\n ? string | { readonly [K in keyof T]?: FormErrors<T[K]> }\n : string;\n\ntype ValidationErrors<TValues extends Record<string, unknown>> = Readonly<{\n fields?: FormErrors<TValues>;\n formError?: string;\n}>;\n\ntype FormValidator<TValues extends Record<string, unknown>> = (\n values: ReadonlyDeep<TValues>, signal: AbortSignal,\n) => MaybePromise<ValidationErrors<TValues> | undefined>;\n\ntype FormOptions<TValues extends Record<string, unknown>> = Readonly<{\n initialValues: TValues;\n onSubscriberError?: (error: unknown) => void;\n validate?: FormValidator<NoInfer<TValues>>;\n}>;\n\ntype SubscribeOptions = Readonly<{ immediate?: boolean }>;\n\ntype FieldState<V> = Readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: ReadonlyDeep<V>;\n}>;\n\ntype FormState<TValues extends Record<string, unknown>> = Readonly<{\n error: string | undefined;\n errors: FormErrors<TValues> | undefined;\n submitCount: number;\n submitting: boolean;\n touched: boolean;\n valid: boolean;\n validating: boolean;\n}>;\n\ntype ValidationResult<TValues extends Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ status: 'valid' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>;\n\ntype SubmitResult<TResult = void, TValues extends Record<string, unknown> = Record<string, unknown>> =\n | Readonly<{ ok: true; value: TResult }>\n | Readonly<{ ok: false; type: 'aborted' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; ok: false; type: 'validation' }>;\n```\n\n```ts\ntype Field<V> = {\n readonly dirty: boolean;\n readonly error: string | undefined;\n readonly touched: boolean;\n readonly value: ReadonlyDeep<V>;\n field<K extends keyof NonNullable<V> & string>(key: K): Field<NonNullable<V>[K]>;\n reset(): void;\n set(next: V | ((previous: ReadonlyDeep<V>) => V)): void;\n subscribe(listener: (state: FieldState<V>) => void, options?: SubscribeOptions): Unsubscribe;\n touch(): void;\n};\n\ntype Form<TValues extends Record<string, unknown>> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n readonly state: FormState<TValues>;\n readonly value: ReadonlyDeep<TValues>;\n dispose(): void;\n field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;\n reset(next?: TValues): void;\n set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;\n submit<TResult = void>(handler: (values: ReadonlyDeep<TValues>) => MaybePromise<TResult>): Promise<SubmitResult<TResult, TValues>>;\n subscribe(listener: (state: FormState<TValues>) => void, options?: SubscribeOptions): Unsubscribe;\n validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n};\n\ntype FieldBindingOptions<Element extends HTMLElement, V> = Readonly<{\n event?: keyof HTMLElementEventMap;\n read(element: Element): V;\n write?: (element: Element, value: ReadonlyDeep<V>) => void;\n}>;\n\ntype FormDraftCodec<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string> = Readonly<{\n fromRecord(record: RecordOf<S, K>): TValues | undefined;\n toRecord(values: ReadonlyDeep<TValues>): RecordOf<S, K>;\n}>;\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `ForgeError` | Base Forge error | `ForgeError.is(error)` narrows unknown values. |\n| `ForgeConfigError` | Unsafe key or unsupported form value | Extends `ForgeError`. |\n| `ForgeDisposedError` | Operation or subscription after disposal | Message names the attempted operation. |\n| `ForgeSubmitError` | Concurrent `submit()` call | Extends `ForgeError`. |\n| `ForgeValidationError` | Validator throws unexpectedly | Preserves original error as `cause`. |\n",
6
- "usage": "---\ntitle: Forge — Usage Guide\ndescription: Build immutable forms, validate whole values, and use optional adapters.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one form value and update object branches through stable typed operations. Form values support primitives, plain objects, arrays, `File`, and `Blob`; mutable class instances such as `Date`, `Map`, and `Set` are rejected.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: { profile: { email: '', name: '' }, tags: [] as string[] },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'Invalid email' } },\n }),\n});\n\nconst email = form.field('profile').field('email');\nemail.set('ada@example.com');\nform.field('tags').set((tags) => [...tags, 'typescript']);\n\nconsole.log(form.value.profile.email);\n```\n\n## Reset Values and Branches\n\nReset a field when one branch should return to its exact baseline. Reset the form with a value when newly loaded data should become the clean baseline.\n\n```ts\nconst name = form.field('profile').field('name');\n\nname.set('Ada');\nname.touch();\nname.reset();\n\nform.reset({ profile: { email: 'ada@example.com', name: 'Ada' }, tags: [] });\n```\n\nAn absent optional parent remains absent after a child reset. Arrays are complete values; replace them with an updater instead of retaining index handles.\n\n## Validate and Submit\n\nReturn `fields` and an optional `formError` from one validator. `validate()` replaces the complete validation snapshot and returns an explicit status.\n\n```ts\nconst passwordForm = createForm({\n initialValues: { password: '', passwordConfirmation: '' },\n validate: (value) => ({\n fields: {\n password: value.password.length >= 8 ? undefined : 'Use at least eight characters',\n passwordConfirmation: value.password === value.passwordConfirmation ? undefined : 'Passwords must match',\n },\n }),\n});\n\nconst validation = await passwordForm.validate();\n\nif (validation.status === 'invalid') console.log(validation.errors);\nif (validation.status === 'aborted') console.log('Validation cancelled');\n\nconst result = await passwordForm.submit((value) => Promise.resolve(value.password.length));\n\nif (result.ok) console.log(result.value);\n```\n\nStarting another validation aborts the previous run. Field edits preserve existing errors until the next validation replaces them. Unexpected validator failures reject as `ForgeValidationError` with the original error as `cause`.\n\n## Observe State\n\nUse form subscriptions for aggregate metadata and field subscriptions for one branch. Subscribing after disposal throws `ForgeDisposedError`.\n\n```ts\nconst errors: unknown[] = [];\nconst observedForm = createForm({\n initialValues: { email: '' },\n onSubscriberError: (error) => errors.push(error),\n});\n\nconst stopForm = observedForm.subscribe((state) => {\n console.log(state.valid, state.submitting);\n}, { immediate: true });\nconst stopField = observedForm.field('email').subscribe((state) => {\n console.log(state.value, state.error);\n}, { immediate: true });\n\nstopField();\nstopForm();\n```\n\nWithout `onSubscriberError`, Forge rethrows subscriber failures asynchronously after completing its state transition.\n\n## Testing\n\nTest the form without a DOM. Read its immutable value, invoke a method, then assert the resulting state or validation result.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createForm } from '@vielzeug/forge';\n\ntest('requires an email address', async () => {\n const form = createForm({\n initialValues: { email: '' },\n validate: (value) => ({ fields: { email: value.email.includes('@') ? undefined : 'Invalid email' } }),\n });\n\n await expect(form.validate()).resolves.toEqual({\n errors: { email: 'Invalid email' },\n formError: undefined,\n status: 'invalid',\n });\n});\n```\n\n## Framework Integration\n\nUse `form.value` and subscriptions with any renderer. Bind one DOM input through `/dom`; validation scheduling remains application policy.\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useState } from 'react';\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\n\nexport function EmailForm() {\n const [, rerender] = useState(0);\n\n useEffect(() => {\n const stop = form.subscribe(() => rerender((revision) => revision + 1));\n\n return () => stop();\n }, []);\n\n return <input value={form.field('email').value} onChange={(event) => form.field('email').set(event.target.value)} />;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, ref } from 'vue';\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\nconst revision = ref(0);\nconst stop = form.subscribe(() => revision.value++);\n\nonUnmounted(stop);\n```\n\n```ts [Svelte]\n<script lang=\"ts\">\n import { onDestroy } from 'svelte';\n import { createForm } from '@vielzeug/forge';\n\n const form = createForm({ initialValues: { email: '' } });\n let revision = 0;\n const stop = form.subscribe(() => revision++);\n\n onDestroy(stop);\n</script>\n\n<input value={form.field('email').value} on:input={(event) => form.field('email').set(event.currentTarget.value)} />\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Spell when one schema owns validation and Vault when an explicit record codec owns persistence.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\nimport { customValidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst Profile = s.object({ email: s.string().email() });\nconst form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });\n```\n\n`customValidator()` preserves unrelated Spell errors, maps each union to its closest branch, and maps array-item failures to the parent array field. Parse again at the submit boundary when a Spell transform must produce the outgoing payload.\n\n```ts\nimport { loadForm, saveForm } from '@vielzeug/forge/vault';\n\nawait saveForm(form, db, 'drafts', codec);\nconst restored = await loadForm(form, db, 'drafts', 'profile', codec);\nconsole.log(restored);\n```\n\n`loadForm()` uses `form.reset()`, so a restored value is clean. Store a selected `File`, not `FileList`, in form state; `FileList` is transport-only for `toFormData()`.\n\n## Best Practices\n\n- Keep form values to primitives, plain objects, arrays, `File`, and `Blob`.\n- Update array fields through immutable replacement functions.\n- Validate complete values instead of rebuilding field-validator graphs.\n- Handle `aborted` validation results before rendering errors.\n- Preserve errors through field edits until a deliberate validation refresh.\n- Return subscription cleanup from framework lifecycle hooks.\n- Provide `onSubscriberError` when application subscribers can throw.\n- Decode Vault records before passing them to `loadForm()`.\n",
4
+ "index": "---\ntitle: Forge — Immutable form state for TypeScript\ndescription: Framework-agnostic immutable form state with focused object fields and explicit validation results.\npackage: forge\ncategory: forms\nkeywords: [form-state, validation, immutable, input, submission]\nrelated: [spell, vault, courier]\nexports: [createForm, bindField, customValidator, saveForm, loadForm]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"forge\" />\n\n## Why Forge?\n\nNative form state becomes difficult to inspect once values, validation, draft restoration, and UI bindings share mutable objects. Forge owns one immutable value tree and gives you typed handles for object branches without string paths, scoped controllers, or framework state.\n\n```ts\n// Before\nconst values = { email: '', password: '' };\nconst errors: Record<string, string> = {};\n\nfunction submit() {\n errors.email = values.email.includes('@') ? '' : 'Invalid email';\n errors.password = values.password.length >= 8 ? '' : 'Use at least eight characters';\n}\n\n// After\nconst form = createForm({\n initialValues: { email: '', password: '' },\n validate: (value) => ({\n fields: {\n email: value.email.includes('@') ? undefined : 'Invalid email',\n password: value.password.length >= 8 ? undefined : 'Use at least eight characters',\n },\n }),\n});\n```\n\n| Feature | Forge | Native form state | Framework-owned form state |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"forge\" type=\"size\" /> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Varies |\n| Zero external dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Immutable nested values | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Varies |\n| Typed object field handles | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Varies |\n| Framework-independent state | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Forge when** form state needs framework-independent immutable values, typed object fields, and one explicit validation boundary.\n\n**Consider framework-owned form state when** application only needs a single UI framework's native input bindings.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/forge\n```\n\n```sh [npm]\nnpm install @vielzeug/forge\n```\n\n```sh [yarn]\nyarn add @vielzeug/forge\n```\n\n:::\n\nInstall `@vielzeug/spell` or `@vielzeug/vault` only when importing Forge's matching optional adapter.\n\n## Quick Start\n\nCreate a form, update a focused field, and submit only after validation passes.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: { profile: { email: '', name: '' } },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'Invalid email' } },\n }),\n});\n\nform.field('profile').field('email').set('ada@example.com');\n\nconst result = await form.submit(async (value) => {\n const response = await fetch('/api/profile', {\n body: JSON.stringify(value),\n headers: { 'Content-Type': 'application/json' },\n method: 'POST',\n });\n\n return response.ok;\n});\n\nif (result.status === 'invalid') console.log(result.errors);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `form.value` exposes one immutable nested value tree.\n- `form.field(key)` selects typed object branches without string paths.\n- `field.set(updater)` replaces array values through immutable updater functions.\n- `field.field(index)` selects typed array item fields by index.\n- `form.validate()` returns valid, invalid, or aborted results.\n- `form.submit(handler, signal?)` touches, validates, and invokes the handler when valid.\n- `bindField()` connects one DOM element without owning validation timing.\n- `customValidator()` maps Spell schema errors into Forge fields.\n- `saveForm()` and `loadForm()` persist explicit Vault draft records.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Spell](/spell/) — adapt a Spell schema through `customValidator()`.\n- [Vault](/vault/) — save and restore explicit Forge draft records.\n- [Courier](/courier/) — send a validated form value through a mutation.\n\n</div>\n\n<!-- markdownlint-enable -->\n",
5
+ "api": "---\ntitle: Forge — API Reference\ndescription: Complete reference for immutable forms, fields, validation, serialization, and optional adapters.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createForm()` | Create immutable form state | Sync | `initialValues` cannot contain mutable class instances |\n| `form.field()` | Select a top-level or object child field | Sync | Unsafe keys (`__proto__`, `constructor`, `prototype`) are rejected |\n| `form.validate()` | Validate complete value | Async | Handle `aborted` separately |\n| `form.submit(handler, signal?)` | Touch, validate, then invoke handler | Async | Concurrent calls reject |\n| `form.reset()` | Restore or replace baseline | Sync | `reset(next)` makes `next` clean |\n| `form.subscribe()` | Observe form metadata | Sync | Throws after disposal |\n| `toFormData()` | Serialize values for multipart transport | Sync | `FileList` is transport-only |\n| `bindField()` | Bind one DOM element | Sync | Does not schedule validation |\n| `customValidator()` | Adapt a Spell schema | Async | Does not transform `form.value` |\n| `saveForm()` / `loadForm()` | Persist explicit Vault records | Async | FormDraftCodec owns record shape |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/forge` | Core form factory, types, and errors |\n| `@vielzeug/forge/dom` | `bindField()` and DOM binding types |\n| `@vielzeug/forge/form-data` | `toFormData()` |\n| `@vielzeug/forge/spell` | `customValidator()` |\n| `@vielzeug/forge/vault` | `saveForm()`, `loadForm()`, and `FormDraftCodec` |\n\n## Core Functions\n\n### `createForm(options)`\n\n```ts\nfunction createForm<TValues extends Record<string, unknown>>(options: FormOptions<TValues>): Form<TValues>;\n```\n\nCreates a form with immutable initial values and an optional full-form validator.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.initialValues` | `TValues` | Initial value and reset baseline. Supports primitives, plain objects, arrays, `Date`, `File`, and `Blob`. |\n| `options.validate` | `FormValidator<TValues>` | Optional validator for the entire current value. |\n| `options.onSubscriberError` | `(error: unknown) => void` | Optional subscriber failure reporter. |\n\n**Returns:** `Form<TValues>`.\n\n**Example:**\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\n```\n\n---\n\n### `toFormData(values)`\n\n```ts\nfunction toFormData(values: Record<string, unknown>): FormData;\n```\n\nConverts nested values into `FormData` with dot-separated object keys and repeated array keys.\n\n**Returns:** a populated `FormData` instance.\n\n**Example:**\n\n```ts\nimport { toFormData } from '@vielzeug/forge/form-data';\n\nconst body = toFormData({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });\n```\n\n## Form Handles\n\n### `Form<TValues>`\n\n`createForm()` returns this handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<TValues>` | Current immutable value. |\n| `state` | `FormState<TValues>` | Submission, validation, touch, and error metadata. |\n| `field(key)` | `Field<TValues[K]>` | Select a top-level field. |\n| `set(next)` | `void` | Replace the complete value or derive a replacement. |\n| `reset(next?)` | `void` | Restore baseline or make `next` the baseline. |\n| `validate(signal?)` | `Promise<ValidationResult<TValues>>` | Run full-form validation. |\n| `submit(handler, signal?)` | `Promise<SubmitResult<TResult, TValues>>` | Touch, validate, and invoke handler when valid. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe form state; throws after disposal. |\n| `dispose()` | `void` | Abort validation and clear subscribers. |\n| `disposed` | `boolean` | Whether the form has been disposed. |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal. |\n\n### `Field<V>`\n\n`form.field(key)` and object-field `.field(key)` return this handle. Array-item `.field(index)` returns a per-item field handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<V>` | Current immutable branch value. |\n| `error` | `string \\| undefined` | Current field error. |\n| `dirty` | `boolean` | Whether branch differs from baseline. |\n| `touched` | `boolean` | Whether field was touched. |\n| `state` | `FieldState<V>` | Snapshot of `dirty`, `error`, `touched`, and `value` in one read. |\n| `field(key)` | `Field<V[K]>` | Select child object field or array item by index. |\n| `set(next)` | `void` | Replace branch or derive a replacement. |\n| `reset()` | `void` | Restore exact baseline branch. |\n| `touch()` | `void` | Mark field touched. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe field transitions; throws after disposal. |\n\n## Validation Results\n\n### `form.validate(signal?)`\n\n```ts\nfunction validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n```\n\nRuns the configured validator against the complete value. A newer validation aborts the older run.\n\n**Returns:** `ValidationResult<TValues>`.\n\n```ts\nconst result = await form.validate();\n\nif (result.status === 'invalid') console.log(result.errors, result.formError);\n```\n\n### `form.submit(handler, signal?)`\n\n```ts\nfunction submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>, signal: AbortSignal) => MaybePromise<TResult>,\n signal?: AbortSignal,\n): Promise<SubmitResult<TResult, TValues>>;\n```\n\nTouches all fields, validates once, and invokes `handler` when validation is valid. The handler receives an `AbortSignal` that is aborted when the external `signal` (or the form's disposal signal) aborts.\n\n**Returns:** `SubmitResult<TResult, TValues>`. Handler failures reject normally unless caused by signal abort, which returns `{ status: 'aborted' }`.\n\n```ts\nconst result = await form.submit((value) => Promise.resolve(value));\n```\n\n## Adapters\n\n### `bindField(element, field, options)`\n\n```ts\nfunction bindField<Element extends HTMLElement, V>(\n element: Element,\n field: Field<V>,\n options: FieldBindingOptions<Element, V>,\n): () => void;\n```\n\nBinds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.\n\n**Example:**\n\n```ts\nimport { bindField } from '@vielzeug/forge/dom';\n\nconst stop = bindField(input, form.field('email'), {\n read: (element) => element.value,\n write: (element, value) => {\n element.value = value;\n },\n});\n```\n\n---\n\n### `customValidator(schema)`\n\n```ts\nfunction customValidator<TValues extends Record<string, unknown>>(\n schema: Schema<unknown, TValues, SchemaMode>,\n): FormValidator<TValues>;\n```\n\nAdapts a Spell schema. Every failing union maps its closest branch while preserving unrelated errors. Array item issues map to per-item array fields; duplicate paths retain the first message.\n\n**Example:**\n\n```ts\nimport { customValidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst Profile = s.object({ email: s.string().email() });\nconst form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });\n```\n\n---\n\n### `saveForm()` and `loadForm()`\n\n```ts\nfunction saveForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, codec: FormDraftCodec<TValues, S, K>,\n): Promise<void>;\n\nfunction loadForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, key: KeyOf<S, K>, codec: FormDraftCodec<TValues, S, K>,\n): Promise<boolean>;\n```\n\nPersists or restores a codec-defined Vault record. `loadForm()` calls `form.reset()` when the codec decodes a record.\n\n**Returns:** `loadForm()` returns `false` for a missing or rejected record.\n\n## Types\n\n```ts\ntype Unsubscribe = () => void;\ntype MaybePromise<T> = T | PromiseLike<T>;\ntype ReadonlyDeep<T> = T extends (...args: never[]) => unknown\n ? T\n : T extends readonly (infer Item)[]\n ? readonly ReadonlyDeep<Item>[]\n : T extends Record<string, unknown>\n ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }\n : T;\n\ntype FormErrors<T> = T extends readonly (infer Item)[]\n ? string | readonly (FormErrors<Item> | undefined)[]\n : T extends Record<string, unknown>\n ? string | { readonly [K in keyof T]?: FormErrors<T[K]> }\n : string;\n\ntype ValidationErrors<TValues extends Record<string, unknown>> = Readonly<{\n fields?: FormErrors<TValues>;\n formError?: string;\n}>;\n\ntype FormValidator<TValues extends Record<string, unknown>> = (\n values: ReadonlyDeep<TValues>, signal: AbortSignal,\n) => MaybePromise<ValidationErrors<TValues> | undefined>;\n\ntype FormOptions<TValues extends Record<string, unknown>> = Readonly<{\n initialValues: TValues;\n onSubscriberError?: (error: unknown) => void;\n validate?: FormValidator<NoInfer<TValues>>;\n}>;\n\ntype SubscribeOptions = Readonly<{ immediate?: boolean }>;\n\ntype FieldState<V> = Readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: ReadonlyDeep<V>;\n}>;\n\ntype FormState<TValues extends Record<string, unknown>> = Readonly<{\n errors: FormErrors<TValues> | undefined;\n formError: string | undefined;\n hasErrors: boolean;\n submitCount: number;\n submitting: boolean;\n touched: boolean;\n validity: 'invalid' | 'unknown' | 'valid';\n validating: boolean;\n}>;\n\ntype ValidationResult<TValues extends Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ status: 'valid' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>;\n\ntype SubmitResult<TResult = void, TValues extends Record<string, unknown> = Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>\n | Readonly<{ status: 'ok'; value: TResult }>;\n```\n\n```ts\ntype ChildField<V> =\n NonNullable<V> extends readonly (infer Item)[]\n ? { field(index: number): Field<Item> }\n : NonNullable<V> extends Record<string, unknown>\n ? { field<K extends keyof NonNullable<V> & string>(key: K): Field<NonNullable<V>[K]> }\n : Record<never, never>;\n\ntype Field<V> = ChildField<V> & {\n readonly dirty: boolean;\n readonly error: string | undefined;\n readonly state: FieldState<V>;\n readonly touched: boolean;\n readonly value: ReadonlyDeep<V>;\n reset(): void;\n set(next: V | ((previous: ReadonlyDeep<V>) => V)): void;\n subscribe(listener: (state: FieldState<V>) => void, options?: SubscribeOptions): Unsubscribe;\n touch(): void;\n};\n\ntype Form<TValues extends Record<string, unknown>> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n readonly state: FormState<TValues>;\n readonly value: ReadonlyDeep<TValues>;\n dispose(): void;\n field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;\n reset(next?: TValues): void;\n set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;\n submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>, signal: AbortSignal) => MaybePromise<TResult>,\n signal?: AbortSignal,\n ): Promise<SubmitResult<TResult, TValues>>;\n subscribe(listener: (state: FormState<TValues>) => void, options?: SubscribeOptions): Unsubscribe;\n validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n};\n\ntype FieldBindingOptions<Element extends HTMLElement, V> = Readonly<{\n event?: keyof HTMLElementEventMap;\n read(element: Element): V;\n write?: (element: Element, value: ReadonlyDeep<V>) => void;\n}>;\n\ntype FormDraftCodec<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string> = Readonly<{\n fromRecord(record: RecordOf<S, K>): TValues | undefined;\n toRecord(values: ReadonlyDeep<TValues>): RecordOf<S, K>;\n}>;\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `ForgeError` | Base Forge error | Use `instanceof ForgeError` to narrow unknown values. |\n| `ForgeConfigError` | Unsafe key or unsupported form value | Extends `ForgeError`. |\n| `ForgeDisposedError` | Operation or subscription after disposal | Message names the attempted operation. |\n| `ForgeSubmitError` | Concurrent `submit()` call | Extends `ForgeError`. |\n| `ForgeValidationError` | Validator throws unexpectedly | Preserves original error as `cause`. |\n",
6
+ "usage": "---\ntitle: Forge — Usage Guide\ndescription: Build immutable forms, validate whole values, and use optional adapters.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one form value and update object branches through stable typed operations. Form values support primitives, plain objects, arrays, `Date`, `File`, and `Blob`; mutable class instances such as `Map` and `Set` are rejected.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: { profile: { email: '', name: '' }, tags: [] as string[] },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'Invalid email' } },\n }),\n});\n\nconst email = form.field('profile').field('email');\nemail.set('ada@example.com');\nform.field('tags').set((tags) => [...tags, 'typescript']);\n\nconsole.log(form.value.profile.email);\n```\n\n## Reset Values and Branches\n\nReset a field when one branch should return to its exact baseline. Reset the form with a value when newly loaded data should become the clean baseline.\n\n```ts\nconst name = form.field('profile').field('name');\n\nname.set('Ada');\nname.touch();\nname.reset();\n\nform.reset({ profile: { email: 'ada@example.com', name: 'Ada' }, tags: [] });\n```\n\nAn absent optional parent remains absent after a child reset. Array items support per-index field handles for reads, updates, and resets.\n\n## Validate and Submit\n\nReturn `fields` and an optional `formError` from one validator. `validate()` replaces the complete validation snapshot and returns an explicit status.\n\n```ts\nconst passwordForm = createForm({\n initialValues: { password: '', passwordConfirmation: '' },\n validate: (value) => ({\n fields: {\n password: value.password.length >= 8 ? undefined : 'Use at least eight characters',\n passwordConfirmation: value.password === value.passwordConfirmation ? undefined : 'Passwords must match',\n },\n }),\n});\n\nconst validation = await passwordForm.validate();\n\nif (validation.status === 'invalid') console.log(validation.errors);\nif (validation.status === 'aborted') console.log('Validation cancelled');\n\nconst result = await passwordForm.submit((value) => Promise.resolve(value.password.length));\n\nif (result.status === 'ok') console.log(result.value);\n```\n\nStarting another validation aborts the previous run. Field edits preserve existing errors until the next validation replaces them. Unexpected validator failures reject as `ForgeValidationError` with the original error as `cause`.\n\n## Observe State\n\nUse form subscriptions for aggregate metadata and field subscriptions for one branch. Subscribing after disposal throws `ForgeDisposedError`.\n\n```ts\nconst errors: unknown[] = [];\nconst observedForm = createForm({\n initialValues: { email: '' },\n onSubscriberError: (error) => errors.push(error),\n});\n\nconst stopForm = observedForm.subscribe((state) => {\n console.log(state.validity, state.submitting);\n}, { immediate: true });\nconst stopField = observedForm.field('email').subscribe((state) => {\n console.log(state.value, state.error);\n}, { immediate: true });\n\nstopField();\nstopForm();\n```\n\nWithout `onSubscriberError`, Forge rethrows subscriber failures asynchronously after completing its state transition.\n\n## Testing\n\nTest the form without a DOM. Read its immutable value, invoke a method, then assert the resulting state or validation result.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createForm } from '@vielzeug/forge';\n\ntest('requires an email address', async () => {\n const form = createForm({\n initialValues: { email: '' },\n validate: (value) => ({ fields: { email: value.email.includes('@') ? undefined : 'Invalid email' } }),\n });\n\n await expect(form.validate()).resolves.toEqual({\n errors: { email: 'Invalid email' },\n formError: undefined,\n status: 'invalid',\n });\n});\n```\n\n## Framework Integration\n\nUse `form.value` and subscriptions with any renderer. Bind one DOM input through `/dom`; validation scheduling remains application policy.\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useState } from 'react';\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\n\nexport function EmailForm() {\n const [, rerender] = useState(0);\n\n useEffect(() => {\n const stop = form.subscribe(() => rerender((revision) => revision + 1));\n\n return () => stop();\n }, []);\n\n return <input value={form.field('email').value} onChange={(event) => form.field('email').set(event.target.value)} />;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, ref } from 'vue';\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\nconst revision = ref(0);\nconst stop = form.subscribe(() => revision.value++);\n\nonUnmounted(stop);\n```\n\n```ts [Svelte]\n<script lang=\"ts\">\n import { onDestroy } from 'svelte';\n import { createForm } from '@vielzeug/forge';\n\n const form = createForm({ initialValues: { email: '' } });\n let revision = 0;\n const stop = form.subscribe(() => revision++);\n\n onDestroy(stop);\n</script>\n\n<input value={form.field('email').value} on:input={(event) => form.field('email').set(event.currentTarget.value)} />\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Spell when one schema owns validation and Vault when an explicit record codec owns persistence.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\nimport { customValidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst Profile = s.object({ email: s.string().email() });\nconst form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });\n```\n\n`customValidator()` preserves unrelated Spell errors, maps each union to its closest branch, and maps array-item failures to per-item array fields. Parse again at the submit boundary when a Spell transform must produce the outgoing payload.\n\n```ts\nimport { loadForm, saveForm } from '@vielzeug/forge/vault';\n\nawait saveForm(form, db, 'drafts', codec);\nconst restored = await loadForm(form, db, 'drafts', 'profile', codec);\nconsole.log(restored);\n```\n\n`loadForm()` uses `form.reset()`, so a restored value is clean. Store a selected `File`, not `FileList`, in form state; `FileList` is transport-only for `toFormData()`.\n\n## Best Practices\n\n- Keep form values to primitives, plain objects, arrays, `Date`, `File`, and `Blob`.\n- Update array fields through immutable replacement functions.\n- Validate complete values instead of rebuilding field-validator graphs.\n- Handle `aborted` validation results before rendering errors.\n- Preserve errors through field edits until a deliberate validation refresh.\n- Return subscription cleanup from framework lifecycle hooks.\n- Provide `onSubscriberError` when application subscribers can throw.\n- Decode Vault records before passing them to `loadForm()`.\n",
7
7
  "examples": "---\ntitle: Forge — Examples\ndescription: Practical immutable form recipes.\n---\n\n## Examples\n\n- [Login form](./examples/login-form.md)\n- [Conditional values](./examples/form-with-conditional-fields.md)\n- [Dynamic arrays](./examples/dynamic-form-fields.md)\n- [Contact form with file upload](./examples/contact-form-with-file-upload.md)\n- [Registration form](./examples/registration-form.md)\n- [Multi-step wizard](./examples/multi-step-wizard.md)\n- [Search form with debounce](./examples/search-form-with-debounce.md)\n"
8
8
  },
9
9
  "examples": [
@@ -59,7 +59,6 @@
59
59
  }
60
60
  ],
61
61
  "typeSignatures": {
62
- "toFormData": "export { toFormData } from './adapters/form-data';",
63
62
  "ForgeConfigError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors';",
64
63
  "ForgeDisposedError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors';",
65
64
  "ForgeError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors';",
@@ -69,16 +68,16 @@
69
68
  "Unsubscribe": "export type Unsubscribe = () => void;",
70
69
  "MaybePromise": "export type MaybePromise<T> = T | PromiseLike<T>;",
71
70
  "ReadonlyDeep": "export type ReadonlyDeep<T> = T extends (...args: never[]) => unknown\n ? T\n : T extends readonly (infer Item)[]\n ? readonly ReadonlyDeep<Item>[]\n : T extends Record<string, unknown>\n ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }\n : T;",
72
- "FormErrors": "export type FormErrors<T> = T extends readonly unknown[]\n ? string\n : T extends Record<string, unknown>\n ? string | { readonly [K in keyof T]?: FormErrors<T[K]> }\n : string;",
71
+ "FormErrors": "export type FormErrors<T> = T extends readonly (infer Item)[]\n ? string | readonly (FormErrors<Item> | undefined)[]\n : T extends Record<string, unknown>\n ? string | { readonly [K in keyof T]?: FormErrors<T[K]> }\n : string;",
73
72
  "ValidationErrors": "export type ValidationErrors<TValues extends Record<string, unknown>> = Readonly<{\n fields?: FormErrors<TValues>;\n formError?: string;\n}>;",
74
73
  "FormValidator": "export type FormValidator<TValues extends Record<string, unknown>> = (\n values: ReadonlyDeep<TValues>,\n signal: AbortSignal,\n) => MaybePromise<ValidationErrors<TValues> | undefined>;",
75
74
  "FormOptions": "export type FormOptions<TValues extends Record<string, unknown>> = Readonly<{\n initialValues: TValues;\n onSubscriberError?: (error: unknown) => void;\n validate?: FormValidator<NoInfer<TValues>>;\n}>;",
76
75
  "SubscribeOptions": "export type SubscribeOptions = Readonly<{\n immediate?: boolean;\n}>;",
77
76
  "FieldState": "export type FieldState<V> = Readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: ReadonlyDeep<V>;\n}>;",
78
- "FormState": "export type FormState<TValues extends Record<string, unknown>> = Readonly<{\n error: string | undefined;\n errors: FormErrors<TValues> | undefined;\n submitCount: number;\n submitting: boolean;\n touched: boolean;\n valid: boolean;\n validating: boolean;\n}>;",
77
+ "FormState": "export type FormState<TValues extends Record<string, unknown>> = Readonly<{\n errors: FormErrors<TValues> | undefined;\n formError: string | undefined;\n hasErrors: boolean;\n submitCount: number;\n submitting: boolean;\n touched: boolean;\n validity: 'invalid' | 'unknown' | 'valid';\n validating: boolean;\n}>;",
79
78
  "ValidationResult": "export type ValidationResult<TValues extends Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ status: 'valid' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>;",
80
- "SubmitResult": "export type SubmitResult<TResult = void, TValues extends Record<string, unknown> = Record<string, unknown>> =\n | Readonly<{ ok: true; value: TResult }>\n | Readonly<{ ok: false; type: 'aborted' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; ok: false; type: 'validation' }>;",
81
- "Field": "export type Field<V> = ChildField<V> & {\n readonly dirty: boolean;\n readonly error: string | undefined;\n reset(): void;\n set(next: V | ((previous: ReadonlyDeep<V>) => V)): void;\n subscribe(listener: (state: FieldState<V>) => void, options?: SubscribeOptions): Unsubscribe;\n touch(): void;\n readonly touched: boolean;\n readonly value: ReadonlyDeep<V>;\n};",
82
- "Form": "export type Form<TValues extends Record<string, unknown>> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;\n reset(next?: TValues): void;\n set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;\n readonly state: FormState<TValues>;\n submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>) => MaybePromise<TResult>,\n ): Promise<SubmitResult<TResult, TValues>>;\n subscribe(listener: (state: FormState<TValues>) => void, options?: SubscribeOptions): Unsubscribe;\n validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n readonly value: ReadonlyDeep<TValues>;\n};"
79
+ "SubmitResult": "export type SubmitResult<TResult = void, TValues extends Record<string, unknown> = Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ status: 'invalid'; errors: FormErrors<TValues> | undefined; formError: string | undefined }>\n | Readonly<{ status: 'ok'; value: TResult }>;",
80
+ "Field": "export type Field<V> = ChildField<V> & {\n readonly dirty: boolean;\n readonly error: string | undefined;\n reset(): void;\n set(next: V | ((previous: ReadonlyDeep<V>) => V)): void;\n readonly state: FieldState<V>;\n subscribe(listener: (state: FieldState<V>) => void, options?: SubscribeOptions): Unsubscribe;\n touch(): void;\n readonly touched: boolean;\n readonly value: ReadonlyDeep<V>;\n};",
81
+ "Form": "export type Form<TValues extends Record<string, unknown>> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;\n reset(next?: TValues): void;\n set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;\n readonly state: FormState<TValues>;\n submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>, signal: AbortSignal) => MaybePromise<TResult>,\n signal?: AbortSignal,\n ): Promise<SubmitResult<TResult, TValues>>;\n subscribe(listener: (state: FormState<TValues>) => void, options?: SubscribeOptions): Unsubscribe;\n validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n readonly value: ReadonlyDeep<TValues>;\n};"
83
82
  }
84
83
  }