@vielzeug/codex 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/data/catalog.json +1984 -0
- package/data/llms-full.txt +31429 -0
- package/data/llms.txt +47 -0
- package/data/manifest.json +8 -0
- package/data/packages/arsenal.json +216 -0
- package/data/packages/assay.json +46 -0
- package/data/packages/clockwork.json +66 -0
- package/data/packages/codex.json +29 -0
- package/data/packages/coins.json +101 -0
- package/data/packages/conduit.json +68 -0
- package/data/packages/courier.json +55 -0
- package/data/packages/dnd.json +60 -0
- package/data/packages/familiar.json +17 -0
- package/data/packages/flux.json +94 -0
- package/data/packages/focus.json +38 -0
- package/data/packages/forge.json +60 -0
- package/data/packages/gesture.json +37 -0
- package/data/packages/herald.json +81 -0
- package/data/packages/illusionist.json +132 -0
- package/data/packages/keymap.json +51 -0
- package/data/packages/ledger.json +60 -0
- package/data/packages/lingua.json +70 -0
- package/data/packages/mesh.json +54 -0
- package/data/packages/necromancer.json +50 -0
- package/data/packages/orbit.json +99 -0
- package/data/packages/ore.json +65 -0
- package/data/packages/postmaster.json +49 -0
- package/data/packages/prism.json +66 -0
- package/data/packages/pulse.json +71 -0
- package/data/packages/refine.json +12 -0
- package/data/packages/ripple.json +88 -0
- package/data/packages/rune.json +78 -0
- package/data/packages/sandbox.json +26 -0
- package/data/packages/scout.json +63 -0
- package/data/packages/scroll.json +110 -0
- package/data/packages/sentinel.json +35 -0
- package/data/packages/sigil.json +49 -0
- package/data/packages/sourcerer.json +70 -0
- package/data/packages/spell.json +132 -0
- package/data/packages/tempo.json +81 -0
- package/data/packages/vault.json +93 -0
- package/data/packages/ward.json +80 -0
- package/data/packages/wayfinder.json +114 -0
- package/data/refine.json +12828 -0
- package/data/search.json +1582 -0
- package/dist/cli.js +32 -0
- package/dist/cli.js.map +1 -1
- package/dist/skills.d.ts +26 -0
- package/dist/skills.js +46 -0
- package/dist/skills.js.map +1 -0
- package/package.json +6 -6
- package/skills/vielzeug/SKILL.md +153 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiSource": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';\nexport { createListNavigation } from './list-navigation.js';\nexport type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';\nexport { captureFocus, restoreFocus } from './restore-focus.js';\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 });\nlist.addEventListener('keydown', (event) => {\n nav.handleKeydown(event)?.change?.item.focus();\n});\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});\nconst onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus();\n\ncontainer.addEventListener('keydown', onKeydown);\n\nrestore();\ncontainer.removeEventListener('keydown', onKeydown);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createListNavigation()` — pure composite navigation with explicit handled/change results\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- [Migration Guide](./migration.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 | Apply returned changes to DOM focus |\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, dynamic direction/orientation, and typeahead 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});\n\nconst result = nav.handleKeydown(event);\nresult?.change?.item.focus();\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `handleKeydown(event)` | `ListKeyResult<T> \\| null` | Returns explicit handled/change state for navigation and typeahead keys. |\n| `navigate(action)` | `ListNavigationChange<T> \\| null` | Moves programmatically and returns the committed change. |\n| `set(index)` | `number` | Sets an integer usable index, or resets 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\nBoundary navigation keys return `{ handled: true, change: null }` and remain consumed. Unrecognized, already-prevented, composing, and disabled events return `null`. Successful typeahead returns a change and follows `typeahead.preventDefault`.\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. Throwing target getters or `focus()` implementations count as failed attempts and fall through to the configured fallback.\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 readonly action: ListKeyAction;\n readonly event?: KeyboardEvent;\n readonly index: number;\n readonly item: T;\n};\n\ntype ListKeyResult<T> = {\n readonly change: ListNavigationChange<T> | null;\n readonly handled: true;\n};\n\ntype ListNavigationTypeaheadOptions<T> = {\n delayMs?: number;\n getLabel: (item: T, index: number) => string;\n preventDefault?: boolean;\n};\n\ntype ListNavigationOptions<T> = {\n direction?: MaybeGetter<'ltr' | 'rtl'>;\n disabled?: MaybeGetter<boolean>;\n getItems: () => readonly T[];\n isItemDisabled?: (item: T, index: number) => boolean;\n keys?: Partial<Record<ListNavigationAction, readonly string[]>>;\n loop?: boolean;\n orientation?: MaybeGetter<'both' | 'horizontal' | 'vertical'>;\n typeahead?: ListNavigationTypeaheadOptions<T>;\n};\n\ntype ListNavigation<T> = {\n getActiveItem(): T | undefined;\n getIndex(): number;\n handleKeydown(event: KeyboardEvent): ListKeyResult<T> | null;\n navigate(action: ListNavigationAction): ListNavigationChange<T> | null;\n reset(): void;\n set(index: number): number;\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`; supplied values must be positive and finite. `preventDefault` defaults to `false`, which preserves editable combobox input. Set it to `true` for menu-style typeahead.\n\n## Errors\n\n`@vielzeug/focus` does not export custom error classes.\n",
|
|
6
|
+
"usage": "---\ntitle: Focus — Usage Guide\ndescription: Build keyboard navigation and restore focus across transient browser interfaces.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one navigation model for the items in a composite widget. Focus remains an application side effect: apply the returned change after each key operation.\n\n```ts\nimport { createListNavigation } from '@vielzeug/focus';\n\nconst nav = createListNavigation({\n getItems: () => items,\n isItemDisabled: (item) => item.matches('[aria-disabled=\"true\"]'),\n loop: true,\n});\n\nconst onKeydown = (event: KeyboardEvent) => {\n const result = nav.handleKeydown(event);\n result?.change?.item.focus();\n};\n\nlist.addEventListener('keydown', onKeydown);\n```\n\n`createListNavigation()` owns only index and typeahead state. The layer that attaches the event listener owns its cleanup.\n\n## Inspect Keyboard Results\n\n`handleKeydown()` returns `null` when Focus did not handle the event. Recognized keys return explicit handling state:\n\n```ts\nconst result = nav.handleKeydown(event);\n\nif (result?.handled && result.change) {\n console.log(result.change.action, result.change.index, result.change.item);\n}\n```\n\nA boundary arrow may return `{ handled: true, change: null }`: the composite consumed the key but its active item did not move. Events that are already prevented, disabled, or part of IME composition return `null`.\n\nProgrammatic navigation returns only committed changes:\n\n```ts\nnav.navigate('first')?.item.focus();\nnav.navigate('next')?.item.focus();\nnav.navigate('prev')?.item.focus();\nnav.navigate('last')?.item.focus();\n```\n\n`set(index)` accepts integer indexes. Invalid or disabled targets reset navigation to `-1`.\n\n## Configure Orientation and Direction\n\nDirection, orientation, and disabled state may be live getters:\n\n```ts\nconst nav = createListNavigation({\n direction: () => document.documentElement.dir === 'rtl' ? 'rtl' : 'ltr',\n disabled: () => panel.hidden,\n getItems: () => items,\n orientation: () => 'horizontal',\n});\n```\n\nHorizontal arrows mirror in RTL. Vertical arrows do not. Custom key tables override defaults; assigning one key to multiple actions throws `RangeError`.\n\n## Add Typeahead\n\nProvide stable labels for menu, listbox, or command navigation:\n\n```ts\nconst nav = createListNavigation({\n getItems: () => menuItems,\n typeahead: {\n delayMs: 300,\n getLabel: (item) => item.textContent ?? '',\n preventDefault: true,\n },\n});\n```\n\n`delayMs` defaults to `500` and must be positive and finite. Repeated characters cycle matching items. Set `preventDefault: true` for menu-style typeahead; leave it false when an editable combobox input must receive the printable key.\n\n## Restore Focus\n\nCapture focus before opening a transient surface, then invoke the one-shot restorer when it closes:\n\n```ts\nimport { captureFocus } from '@vielzeug/focus';\n\nconst restore = captureFocus({\n fallback: () => document.querySelector<HTMLElement>('#main'),\n preventScroll: true,\n});\n\ndialog.showModal();\ndialog.addEventListener('close', restore, { once: true });\n```\n\nA supplied signal can cancel pending restoration:\n\n```ts\nconst controller = new AbortController();\nconst restore = captureFocus({ signal: controller.signal });\n\ncontroller.abort();\nrestore(); // false\n```\n\nUse `restoreFocus()` directly when the target is already known. Disconnected, disabled, inert, throwing, or non-focusable targets fall through to the lazy fallback.\n\n## Framework Integration\n\nCreate navigation once per mounted widget. Framework cleanup removes only the event listener because the navigation model owns no external resources.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createListNavigation } from '@vielzeug/focus';\n\nfunction Tabs({ tabs }: { tabs: 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((item): item is HTMLButtonElement => item !== null),\n loop: true,\n orientation: 'horizontal',\n });\n const onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus();\n\n list.addEventListener('keydown', onKeydown);\n return () => list.removeEventListener('keydown', onKeydown);\n }, []);\n\n return (\n <div ref={listRef} role=\"tablist\">\n {tabs.map((tab, index) => (\n <button key={tab} ref={(element) => { tabRefs.current[index] = element; }}>{tab}</button>\n ))}\n </div>\n );\n}\n```\n\n```ts [Vue]\nimport { onMounted, onUnmounted, ref } from 'vue';\nimport { createListNavigation } from '@vielzeug/focus';\n\nconst list = ref<HTMLElement>();\nlet onKeydown: ((event: KeyboardEvent) => void) | undefined;\n\nonMounted(() => {\n const nav = createListNavigation({ getItems: () => items, orientation: 'horizontal' });\n onKeydown = (event) => nav.handleKeydown(event)?.change?.item.focus();\n list.value?.addEventListener('keydown', onKeydown);\n});\n\nonUnmounted(() => {\n if (onKeydown) list.value?.removeEventListener('keydown', onKeydown);\n});\n```\n\n```ts [Svelte]\nimport { onMount } from 'svelte';\nimport { createListNavigation } from '@vielzeug/focus';\n\nonMount(() => {\n const nav = createListNavigation({ getItems: () => items, orientation: 'horizontal' });\n const onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus();\n list.addEventListener('keydown', onKeydown);\n return () => list.removeEventListener('keydown', onKeydown);\n});\n```\n\n:::\n\n## Integrate with Keymap\n\nUse Focus for local composite navigation and Keymap for application shortcuts:\n\n```ts\nimport { createListNavigation } from '@vielzeug/focus';\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst nav = createListNavigation({ getItems: () => items });\nconst onKeydown = (event: KeyboardEvent) => nav.handleKeydown(event)?.change?.item.focus();\nconst map = createKeymap([\n { id: 'palette', shortcut: 'mod+k', handler: () => openPalette() },\n { id: 'reset', shortcut: 'escape', handler: () => nav.reset() },\n]);\n\nlist.addEventListener('keydown', onKeydown);\nmap.mount(document);\n```\n\n## Best Practices\n\n- Keep item discovery in one live `getItems()` function.\n- Apply focus from returned changes rather than hiding effects in navigation state.\n- Treat `{ handled: true, change: null }` as a consumed boundary key.\n- Ignore events before Focus when an inner widget already called `preventDefault()`.\n- Reset navigation when an overlay closes or its item context changes.\n- Use typeahead only with stable labels, and set `preventDefault` according to whether the keyboard surface is editable.\n- Capture return focus before opening transient surfaces.\n- Remove the owning event listener when a widget unmounts.\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.setAttribute('role', 'option')\n item.setAttribute('aria-selected', String(index === 0))\n item.disabled = index === 1\n item.setAttribute('aria-disabled', String(item.disabled))\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})\n\nconst onKeydown = (event) => {\n const change = navigation.handleKeydown(event)?.change\n if (!change) return\n items.forEach((candidate) => {\n const active = candidate === change.item\n candidate.tabIndex = active ? 0 : -1\n candidate.setAttribute('aria-selected', String(active))\n })\n change.item.focus()\n}\n\nnavigation.set(0)\nlist.addEventListener('keydown', onKeydown)\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 ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
23
|
+
"ListKeyResult": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
24
|
+
"ListNavigation": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
25
|
+
"ListNavigationAction": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
26
|
+
"ListNavigationChange": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
27
|
+
"ListNavigationOptions": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
28
|
+
"ListNavigationTypeaheadOptions": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
29
|
+
"MaybeGetter": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
30
|
+
"createListNavigation": "export { createListNavigation } from './list-navigation.js';",
|
|
31
|
+
"CaptureFocusOptions": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';",
|
|
32
|
+
"FocusRestorer": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';",
|
|
33
|
+
"FocusTarget": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';",
|
|
34
|
+
"RestoreFocusOptions": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';",
|
|
35
|
+
"captureFocus": "export { captureFocus, restoreFocus } from './restore-focus.js';",
|
|
36
|
+
"restoreFocus": "export { captureFocus, restoreFocus } from './restore-focus.js';"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiSource": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors.js';\nexport { createForm } from './form.js';\nexport { toPlainValues } from './plain.js';\nexport * from './types.js';\n",
|
|
3
|
+
"docs": {
|
|
4
|
+
"index": "---\ntitle: Forge — Immutable typed form state\ndescription: Framework-neutral form and field state with nested handles, flat validation issues, and explicit integration helpers.\npackage: forge\ncategory: state\nkeywords: [forms, validation, fields, immutable, standard schema, formdata]\nexports: [createForm, toPlainValues]\nrelated: [spell, vault, assay]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"forge\" />\n\n## Why Forge?\n\nForge owns form state without owning rendering or validation timing. Nested field handles keep access typed, flat validation issues compose across arrays and unions, and Forge-owned validation and persistence operations settle on cancellation.\n\n## Quick Start\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: { email: '', profile: { name: '' } },\n validate: (values) =>\n values.email.includes('@') ? undefined : [{ path: ['email'], message: 'Invalid email' }],\n});\n\nform.field('profile').field('name').set('Ada');\nconst result = await form.validate();\n```\n\n| Feature | Forge | Local component state |\n| --- | --- | --- |\n| Bundle size | <PackageInfo package=\"forge\" type=\"size\" /> | n/a |\n| Nested typed fields | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual |\n| Flat validation issues | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual |\n| Abortable validation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Manual |\n| Framework-neutral | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Varies |\n| Zero third-party dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Forge when** several fields need coordinated validation, submission, dirty/touched state, nested updates, or explicit persistence.\n\n**Use local state when** a form has only one or two fields and no shared validation lifecycle.\n\n</div>\n\n## Installation\n\n```sh\npnpm add @vielzeug/forge\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createForm()` — deeply readonly form state with structural sharing and cloned dates\n- `field().field()` — typed nested field handles\n- `patch()` — shallow top-level merge without spread casts\n- `normalize` + `toPlainValues()` — hold domain-model classes in form state\n- Flat `{ path, message }` validation issues\n- Explicit `validate()` and `submit()` timing\n- `/dom` — optional element binding\n- `/schema` — validator-agnostic Standard Schema adapter\n- `/persist` — explicit structural store integration\n- `/form-data` — browser submission serialization\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- [Spell](/spell/) — Standard Schema-compatible validation.\n- [Vault](/vault/) — durable typed storage for drafts.\n- [Assay](/assay/) — DOM test helpers for form interaction.\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Forge — API Reference\ndescription: Immutable typed form state, flat validation issues, and optional integration helpers.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createForm()` | Create immutable form and field state | Sync | Dispose with its owner |\n| `toPlainValues()` | Flatten class instances to the plain value shape | Sync | Drops circular branches |\n| `validate()` | Run the configured full-form validator | Async | Superseded work returns `aborted` |\n| `submit()` | Validate and submit one captured snapshot | Async | Concurrent submission rejects |\n| `bindField()` | Bind a field to an HTML element | Sync | Caller owns returned cleanup |\n| `schemaValidator()` | Adapt Standard Schema validation | Async | Transform output is not applied |\n| `loadForm()` / `saveForm()` | Explicit structural draft persistence | Async | Loading will not overwrite newer edits |\n| `toFormData()` | Serialize supported values | Sync | Dotted keys and object arrays reject |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/forge` | Form and field state |\n| `@vielzeug/forge/dom` | `bindField()` DOM binding |\n| `@vielzeug/forge/form-data` | `toFormData()` serialization |\n| `@vielzeug/forge/schema` | Standard Schema validation adapter |\n| `@vielzeug/forge/persist` | Explicit draft save/load helpers |\n\n## createForm\n\n```ts\nfunction createForm<TValues extends Record<string, unknown>>(\n options: FormOptions<TValues>,\n): Form<TValues>\n```\n\n`initialValues` is required. Validation is explicit and returns flat issues.\n\n```ts\nconst form = createForm({\n initialValues: { email: '' },\n validate: (values) =>\n values.email.includes('@') ? undefined : [{ path: ['email'], message: 'Invalid email' }],\n});\n```\n\n`normalize` runs before value validation at every write boundary (`initialValues`, `set`, `patch`, `reset`, `field().set`), so a caller that normalizes once at `createForm` stays correct everywhere. Pair it with `toPlainValues()` to hold domain-model classes in form state.\n\n```ts\nimport { createForm, toPlainValues } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { user: new UserModel() }, normalize: toPlainValues });\n\nform.reset({ user: new UserModel() }); // same normalization as init\n```\n\n## toPlainValues\n\n```ts\nfunction toPlainValues<T>(value: T): T;\n```\n\nDeeply converts a value into the plain shape Forge accepts: primitives pass through, `Date` is cloned, `File`/`Blob` keep identity, arrays map, and class instances flatten to their own enumerable entries. Unsafe keys (`__proto__`, `constructor`, `prototype`) are dropped, so the output always passes value validation. The returned type parameter is a convenience cast — the flattened shape differs from the input type whenever class instances are present.\n\nLossy conversions replace the error an un-normalized write would throw: `Map`, `Set`, and other keyless built-ins flatten to `{}`; sparse array slots are dropped, shifting later indexes; `NaN`, `Infinity`, functions, and circular branches become `undefined`. Normalize model classes, not arbitrary graphs.\n\n```ts\nimport { createForm, toPlainValues } from '@vielzeug/forge';\n\nclass UserModel {\n email = '';\n name = '';\n}\n\ntoPlainValues(new UserModel()); // { email: '', name: '' }\nconst form = createForm({ initialValues: { user: new UserModel() }, normalize: toPlainValues });\n```\n\n## Form\n\n```ts\ninterface Form<TValues extends Record<string, unknown>> {\n readonly value: ReadonlyDeep<TValues>;\n readonly state: FormState;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;\n set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;\n patch(next: Partial<TValues>): void;\n patch(updater: (previous: ReadonlyDeep<TValues>) => Partial<TValues>): void;\n reset(next?: TValues): void;\n validate(signal?: AbortSignal): Promise<ValidationResult>;\n submit<TResult>(handler, signal?: AbortSignal): Promise<SubmitResult<TResult>>;\n subscribe(listener, options?: { immediate?: boolean }): () => void;\n dispose(): void;\n [Symbol.dispose](): void;\n}\n```\n\n`validate()` and `submit()` return `{ status: 'valid' }`, `{ status: 'invalid', issues }`, or `{ status: 'aborted' }`. Successful submission returns `{ status: 'ok', value }`. Submission handlers receive the exact snapshot that passed validation. `form.state` retains one stable reference until the next transition for framework external-store adapters.\n\n`patch()` shallow-merges top-level keys — `form.patch({ count: 1 })` equals `form.set((prev) => ({ ...prev, count: 1 }))` without the `as T` cast a spread requires. Nested values replace by identity; there is no deep merge.\n\n```ts\ntype FormState = Readonly<{\n formError: string | undefined;\n hasErrors: boolean;\n issues: readonly ValidationIssue[] | undefined;\n submitCount: number;\n submitting: boolean;\n touched: boolean;\n validity: 'invalid' | 'unknown' | 'valid';\n validating: boolean;\n}>;\n```\n\n## Field\n\n```ts\ninterface Field<Value> {\n readonly value: ReadonlyDeep<Value>;\n readonly state: FieldState<Value>;\n readonly dirty: boolean;\n readonly touched: boolean;\n readonly error: string | undefined;\n field(keyOrIndex): Field<unknown>;\n set(next: Value | ((previous: Value) => Value)): void;\n reset(): void;\n touch(): void;\n subscribe(listener, options?: { immediate?: boolean }): () => void;\n}\n```\n\nNested `field().field()` handles avoid string paths while preserving type inference.\n\n## Value types\n\n```ts\ntype Atomic = Date | File | Blob;\ntype ReadonlyDate = Omit<Date, Extract<keyof Date, `set${string}`>>;\ntype ReadonlyDeep<T> = T extends Date\n ? ReadonlyDate\n : T extends File | Blob\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```\n\nForge clones valid dates, preserves binary identity, freezes plain containers, and rejects unsupported, circular, sparse, or non-finite values.\n\n## ValidationIssue\n\n```ts\ntype ValidationIssue = Readonly<{\n path: readonly (string | number)[];\n message: string;\n}>;\n```\n\n`path: []` denotes a form-level issue. A field derives its first matching error from the flat issue list.\n\n## bindField\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) => { element.value = value; },\n});\n```\n\nThe binding reads the configured event (`input` by default), marks the field touched on blur, writes external state changes, and returns cleanup.\n\n## schemaValidator\n\n```ts\nimport { schemaValidator } from '@vielzeug/forge/schema';\n\nconst form = createForm({\n initialValues: { email: '' },\n validate: schemaValidator(UserSchema),\n});\n```\n\nAccepts any Standard Schema-compatible validator. Portable issues are copied into Forge-owned immutable `{ path, message }` values. Cancellation settles promptly, although the underlying schema operation may continue. Successful transformed output is intentionally not written into the form.\n\n## Draft persistence\n\n```ts\nimport { loadForm, saveForm } from '@vielzeug/forge/persist';\n\nawait saveForm(form, store, 'drafts', codec);\nawait loadForm(form, store, 'drafts', 'profile', codec, { signal });\n```\n\nThe store is structural: it only needs typed `get(table, key)` and `put(table, value)` methods. The codec owns record conversion. The optional signal and form disposal cancel pending work; a load returns `false` when values changed before the record arrived.\n\n## FormData\n\n```ts\nimport { toFormData } from '@vielzeug/forge/form-data';\n\nconst body = toFormData(form.value);\n```\n\nFlattens nested plain objects to dotted keys and repeats scalar or binary array entries. Dots in object keys and nested objects or arrays inside arrays throw `ForgeConfigError` to prevent ambiguous or lossy output.\n\n## Errors\n\n- `ForgeError` — base package error.\n- `ForgeConfigError` — invalid value, path, issue, or serialization configuration.\n- `ForgeDisposedError` — operation attempted after disposal.\n- `ForgeSubmitError` — concurrent submission attempt.\n- `ForgeValidationError` — unexpected validator execution failure, with the original `cause`.\n",
|
|
6
|
+
"usage": "---\ntitle: Forge — Usage Guide\ndescription: Build, validate, submit, bind, and persist typed forms with Forge.\n---\n\n[[toc]]\n\n## Basic Usage\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: {\n email: '',\n profile: { name: '' },\n },\n});\n```\n\nRead `form.value` for the current deeply readonly snapshot. Use `set()` for whole-form replacement and field handles for focused updates.\n\n```ts\nform.field('email').set('ada@example.com');\nform.field('profile').field('name').set('Ada');\n```\n\n`patch()` shallow-merges top-level keys without the cast a spread `set()` needs:\n\n```ts\nform.patch({ email: 'ada@example.com' }); // siblings keep their values\n```\n\n## Hold domain-model classes\n\nForge values must be plain JSON-safe data. When your app stores class instances (models, DTOs with behavior), pass `normalize: toPlainValues` once at `createForm`; it runs before validation at every write boundary — init, `set`, `patch`, `reset`, and nested `field().set` — so a reset with fresh models behaves like init.\n\n```ts\nimport { createForm, toPlainValues } from '@vielzeug/forge';\n\nclass ProviderUserModel {\n id: number;\n email = '';\n constructor(id: number) { this.id = id; }\n}\n\nconst form = createForm<{ users: ProviderUserModel[] }>({\n initialValues: { users: [new ProviderUserModel(1)] },\n normalize: toPlainValues,\n});\n\nform.field('users').field(0).set(new ProviderUserModel(2)); // flattened like init\nform.reset({ users: [new ProviderUserModel(3)] }); // flattened like init\n// Rebuild models at submit:\nawait form.submit((values) => send(values.users.map((u) => new ProviderUserModel(u.id))));\n```\n\nWithout `normalize`, a rejected write throws `ForgeConfigError` naming the offending path (for example `list[0]`).\n\n## Validate explicitly\n\nValidators return a flat issue list. Forge does not choose blur/change/submit policy for the application.\n\n```ts\nconst form = createForm({\n initialValues: { email: '', password: '' },\n validate: (values) => {\n const issues = [];\n if (!values.email.includes('@')) issues.push({ path: ['email'], message: 'Invalid email' });\n if (values.password.length < 12) issues.push({ path: ['password'], message: 'Use at least 12 characters' });\n return issues.length ? issues : undefined;\n },\n});\n\nconst result = await form.validate();\nif (result.status === 'invalid') console.log(result.issues);\n```\n\nA field's `error` is derived from the first issue whose path equals that field path. Use `path: []` for form-level failures.\n\n## Submit\n\n```ts\nconst result = await form.submit(async (values, signal) => {\n const response = await fetch('/profile', {\n body: JSON.stringify(values),\n method: 'POST',\n signal,\n });\n return response.json();\n});\n\nif (result.status === 'ok') console.log(result.value);\n```\n\n`submit()` validates first, passes the exact validated snapshot to the handler, prevents duplicate active submissions, and reports aborts explicitly.\n\n## Bind an element\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) => { element.value = value; },\n});\n```\n\nCall `stop()` when the element owner unmounts.\n\n## Use a Standard Schema validator\n\n```ts\nimport { schemaValidator } from '@vielzeug/forge/schema';\n\nconst form = createForm({\n initialValues: { email: '' },\n validate: schemaValidator(UserSchema),\n});\n```\n\nSpell schemas implement Standard Schema directly. Forge uses the schema result only for success and issues; transformations do not replace form values. Cancellation settles Forge promptly even when underlying validator work cannot be stopped.\n\n## Persist a draft\n\n```ts\nimport { loadForm, saveForm } from '@vielzeug/forge/persist';\n\nconst codec = {\n fromRecord: (record) => record.value,\n toRecord: (values) => ({ id: 'profile', value: values }),\n};\n\nawait saveForm(form, store, 'drafts', codec);\nawait loadForm(form, store, 'drafts', 'profile', codec, { signal });\n```\n\nPersistence remains explicit. The store owns durability and validation; the codec owns record shape. Loading returns `false` rather than overwriting edits made while storage was pending.\n\n## Serialize FormData\n\n```ts\nimport { toFormData } from '@vielzeug/forge/form-data';\n\nawait fetch('/profile', { body: toFormData(form.value), method: 'POST' });\n```\n\nObject keys containing dots and arrays containing nested objects reject instead of producing ambiguous or lossy entries.\n\n## Dispose\n\n```ts\nform.dispose();\n```\n\nDisposal settles active Forge validation and signals active submission/persistence work and clears subscribers. It does not dispose borrowed validators, stores, or DOM elements.\n\n## Framework Integration\n\nInject an owner-scoped form and subscribe to its stable `FormState` snapshot.\n\n::: code-group\n\n```tsx [React]\nimport { useSyncExternalStore } from 'react';\nimport type { Form } from '@vielzeug/forge';\n\nexport function useFormState<T extends Record<string, unknown>>(form: Form<T>) {\n return useSyncExternalStore(form.subscribe, () => form.state, () => form.state);\n}\n```\n\nWhen the component is the form's sole owner, create it in a ref and skip explicit disposal — writes on a disposed form throw `ForgeDisposedError`, which is what a StrictMode double-mount hits when effect cleanup disposes a ref-held form that survives the remount. Garbage collection reclaims a sole-subscriber form; dispose explicitly only when external subscribers or in-flight work need teardown.\n\n```ts [Vue]\nimport { onUnmounted, shallowRef } from 'vue';\nimport type { Form } from '@vielzeug/forge';\n\nexport function useFormState<T extends Record<string, unknown>>(form: Form<T>) {\n const state = shallowRef(form.state);\n const stop = form.subscribe((next) => { state.value = next; }, { immediate: true });\n onUnmounted(stop);\n return state;\n}\n```\n\n```ts [Svelte]\nimport type { Form } from '@vielzeug/forge';\n\nexport const formState = <T extends Record<string, unknown>>(form: Form<T>) => ({\n subscribe(run: (state: Form<T>['state']) => void) {\n return form.subscribe(run, { immediate: true });\n },\n});\n```\n\n:::\n\nThe owner that creates the form disposes it. Framework subscriptions own only their unsubscribe callbacks.\n\n## Working with Other Vielzeug Libraries\n\n- Pass Spell schemas to `schemaValidator()` through Standard Schema.\n- Pass Vault stores directly to `loadForm()` and `saveForm()`.\n- Bind Ore or native controls with `bindField()` and owner cleanup.\n\n## Best Practices\n\n- Keep validation timing in the UI layer.\n- Return flat issues rather than shape-coupled error trees.\n- Use field handles instead of string paths.\n- Decode persisted/network data at its boundary.\n- Dispose forms with their owner.\n",
|
|
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
|
+
},
|
|
9
|
+
"examples": [
|
|
10
|
+
{
|
|
11
|
+
"id": "array-fields",
|
|
12
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({ initialValues: { tags: ['typescript'] } })\nconst tags = form.field('tags')\n\ntags.set((previous) => [...previous, 'forms'])\ntags.set((previous) => previous.filter((tag) => tag !== 'typescript'))\nconsole.log(form.value.tags)",
|
|
13
|
+
"name": "Immutable Array Updates"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "create-form",
|
|
17
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({ initialValues: { account: { email: '' }, name: '' } })\nconst email = form.field('account').field('email')\n\nemail.set('ada@example.com')\nconsole.log(form.value)\nconsole.log(form.state.validity)",
|
|
18
|
+
"name": "Create Immutable Form"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "dynamic-fields",
|
|
22
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({ initialValues: { contacts: [] as { email: string }[] } })\nconst contacts = form.field('contacts')\n\ncontacts.set((previous) => [...previous, { email: 'ada@example.com' }])\ncontacts.set((previous) => previous.slice(1))\nconsole.log(form.value)",
|
|
23
|
+
"name": "Dynamic Values"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "field-operations",
|
|
27
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({ initialValues: { profile: { name: 'Ada' } } })\nconst name = form.field('profile').field('name')\n\nname.set('Grace')\nname.touch()\nconsole.log(name.value, name.dirty, name.touched)\nname.reset()\nconsole.log(form.value)",
|
|
28
|
+
"name": "Focused Field Operations"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "form-submission",
|
|
32
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({\n initialValues: { email: '' },\n validate: (value) => (value.email.includes('@') ? undefined : [{ path: ['email'], message: 'Invalid email' }]),\n})\n\nform.field('email').set('ada@example.com')\nconst result = await form.submit(async (value) => ({ ...value, saved: true }))\nconsole.log(result)",
|
|
33
|
+
"name": "Form Submission"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "form-subscriptions",
|
|
37
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({\n initialValues: { email: '', name: '' },\n onSubscriberError: (error) => console.log('Subscriber error:', error),\n})\nconst stopForm = form.subscribe((state) => console.log('Validity:', state.validity), { immediate: true })\nconst stopEmail = form.field('email').subscribe((state) => console.log('Email:', state), { immediate: true })\n\nform.field('name').set('Ada')\nform.field('email').set('ada@example.com')\nstopEmail()\nstopForm()",
|
|
38
|
+
"name": "Form and Field Subscriptions"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "form-validation",
|
|
42
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({\n initialValues: { password: '', passwordConfirmation: '' },\n validate: (value) => {\n const issues: { path: (string | number)[]; message: string }[] = []\n\n if (value.password.length < 8) issues.push({ path: ['password'], message: 'Use at least eight characters' })\n if (value.password !== value.passwordConfirmation) {\n issues.push({ path: ['passwordConfirmation'], message: 'Passwords must match' })\n }\n\n return issues\n },\n})\n\nform.field('password').set('short')\nconsole.log(await form.validate())\n\nform.field('password').set('strong-password')\nform.field('passwordConfirmation').set('strong-password')\nconsole.log(await form.validate())",
|
|
43
|
+
"name": "Whole-Value Validation"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "scoped-sub-forms",
|
|
47
|
+
"code": "import { createForm } from '@vielzeug/forge'\n\nconst form = createForm({ initialValues: { shipping: { city: '', street: '' } } })\nconst shipping = form.field('shipping')\n\nshipping.field('street').set('123 Main Street')\nshipping.field('city').set('Portland')\nconsole.log(shipping.value)\nconsole.log(form.value.shipping)",
|
|
48
|
+
"name": "Nested Field Handles"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"typeSignatures": {
|
|
52
|
+
"ForgeConfigError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors.js';",
|
|
53
|
+
"ForgeDisposedError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors.js';",
|
|
54
|
+
"ForgeError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors.js';",
|
|
55
|
+
"ForgeSubmitError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors.js';",
|
|
56
|
+
"ForgeValidationError": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors.js';",
|
|
57
|
+
"createForm": "export { createForm } from './form.js';",
|
|
58
|
+
"toPlainValues": "export { toPlainValues } from './plain.js';"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiSource": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';\nexport {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';\n",
|
|
3
|
+
"docs": {
|
|
4
|
+
"index": "---\ntitle: Gesture — Pointer movement primitives\ndescription: Framework-neutral two-dimensional pointer drag and one-axis pan recognition with lifecycle-owned handles.\npackage: gesture\ncategory: input\nkeywords: [pointer, pan, swipe, gesture, touch, drag]\nexports: [createDragGesture, createPanGesture]\nrelated: [refine, dnd, keymap]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"gesture\" />\n\n## Why Gesture?\n\nPointer-driven interfaces need reliable one-pointer movement tracking without coupling recognition to rendering, drag-and-drop semantics, or product-specific completion policy.\n\n```ts\n// Before\nelement.addEventListener('pointermove', (event) => {\n // Coordinate tracking, pointer identity, direction locking, and cleanup\n});\n\n// After\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst pan = createPanGesture(element, {\n axis: 'x',\n onMove: ({ distance }) => console.log('distance', distance),\n onEnd: ({ reason }) => console.log('ended', reason),\n});\n```\n\n| Feature | Gesture | Ad-hoc pointer handling |\n| --- | --- | --- |\n| Bundle size | <PackageInfo package=\"gesture\" type=\"size\" /> | n/a |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | n/a |\n| Two-dimensional drag tracking | Built in | Manual |\n| Axis intent recognition | Built in | Manual |\n| Pointer ownership | Tracked across the document | Manual |\n| Lifecycle cleanup | `dispose()` + `disposalSignal` | Manual |\n\n<div class=\"decision-callout\">\n\n**Use Gesture when** UI surfaces need consistent free drag or axis-locked pan tracking while retaining their own rendering and completion rules.\n\n**Consider direct pointer handling when** the interaction is isolated and does not need reusable lifecycle or direction-lock behavior.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/gesture\n```\n\n```sh [npm]\nnpm install @vielzeug/gesture\n```\n\n```sh [yarn]\nyarn add @vielzeug/gesture\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst element = document.querySelector<HTMLElement>('[data-swipe]');\nif (!element) throw new Error('Missing [data-swipe] element');\n\nconst pan = createPanGesture(element, {\n axis: 'x',\n onMove: ({ distance }) => {\n element.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n element.style.transform = '';\n if (reason === 'release' && Math.abs(distance) >= 48) {\n pan.dispose();\n element.remove();\n }\n },\n});\n\nwindow.addEventListener('pagehide', () => pan.dispose(), { once: true });\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createDragGesture()` — unrestricted two-dimensional pointer movement tracking\n- `createPanGesture()` — one-axis pointer movement with direction intent recognition\n- Direction locking — activates only when movement favors the configured axis\n- Configurable pointer capture — own the pointer by default or preserve native targeting\n- Configurable `activationDistance` — tune slop for touch density and component needs\n- Consumer-owned policy — completion thresholds, snapping, and outcomes stay in application code\n- Stable completion — one `onEnd` callback for release and cancellation\n- Lifecycle ownership — `dispose()`, `disposed`, `disposalSignal`, optional owner `signal`, and `[Symbol.dispose]()`\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- [Refine](/refine/) — components that use pan recognition for carousel, drawer, toast, and list interactions.\n- [Dnd](/dnd/) — builds touch sorting on `createDragGesture()` and adds files, previews, drop targets, keyboard reordering, and connected-list transactions.\n- [Keymap](/keymap/) — keyboard interaction primitives for complementary input paths.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Gesture — API Reference\ndescription: API reference for two-dimensional drag and one-axis pan recognition.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createDragGesture()` | Track unrestricted two-dimensional pointer movement | Sync | Does not provide DOM movement or drop semantics |\n| `createPanGesture()` | Track pointer movement projected onto one axis | Sync | Cross-axis intent ends a pending interaction |\n| `DragGesture` / `PanGesture` | Control recognition lifecycle | Sync | `dispose()` does not invoke `onEnd` |\n| `DragGestureOptions` | Configure free-drag recognition | Sync | `activationDistance` uses Euclidean distance |\n| `PanGestureOptions` | Configure axis-locked recognition | Sync | Completion thresholds belong in `onEnd` |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/gesture` | Drag and pan factories, handles, callback details, options, axes, points, and end reasons |\n\n## Core Functions\n\n### `createDragGesture()`\n\n```ts\nfunction createDragGesture(target: Element, options?: DragGestureOptions): DragGesture;\n```\n\nTracks one primary pointer without restricting movement to an axis.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `target` | `Element` | Element that owns pointer admission and optional capture |\n| `options` | `DragGestureOptions` | Activation, admission, capture, callback, and lifecycle configuration |\n\n**Returns:** A lifecycle-owned `DragGesture` handle.\n\n**Example**\n\n```ts\nimport { createDragGesture } from '@vielzeug/gesture';\n\nconst drag = createDragGesture(element, {\n onMove: ({ delta }) => {\n element.style.translate = `${delta.x}px ${delta.y}px`;\n },\n onEnd: () => {\n element.style.translate = '';\n },\n});\n```\n\n---\n\n### `createPanGesture()`\n\n```ts\nfunction createPanGesture(target: Element, options?: PanGestureOptions): PanGesture;\n```\n\nTracks one primary pointer after movement establishes intent on the configured axis.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `target` | `Element` | Element that owns pointer admission and optional capture |\n| `options` | `PanGestureOptions` | Axis, activation, admission, capture, callback, and lifecycle configuration |\n\n**Returns:** A lifecycle-owned `PanGesture` handle.\n\n**Example**\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst pan = createPanGesture(element, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && Math.abs(distance) >= 48) dismiss();\n },\n});\n```\n\nBoth handles expose the same members:\n\n| Member | Type | Contract |\n| --- | --- | --- |\n| `active` | `boolean` | `true` after activation and before release or cancellation |\n| `cancel()` | `() => boolean` | Clears pending recognition or ends active recognition with `reason: 'cancel'`; returns whether a session existed |\n| `disposalSignal` | `AbortSignal` | Aborts when the handle is disposed |\n| `dispose()` | `() => void` | Aborts `disposalSignal`, clears recognition, releases capture, and detaches listeners; idempotent |\n| `disposed` | `boolean` | `true` after disposal |\n| `[Symbol.dispose]()` | `() => void` | Calls `dispose()` |\n\n## Types\n\n### Drag types\n\n```ts\ntype DragEndReason = 'cancel' | 'release';\n\ntype DragPoint = Readonly<{\n x: number;\n y: number;\n}>;\n\ntype DragGestureDetail = Readonly<{\n current: DragPoint;\n delta: DragPoint;\n event: PointerEvent;\n pointerId: number;\n pointerType: string;\n start: DragPoint;\n target: Element;\n}>;\n\ntype DragGestureEndDetail = DragGestureDetail &\n Readonly<{\n reason: DragEndReason;\n }>;\n\ntype DragGestureOptions = Readonly<{\n activationDistance?: number;\n disabled?: boolean | (() => boolean | undefined);\n onEnd?: (detail: DragGestureEndDetail) => void;\n onMove?: (detail: DragGestureDetail) => void;\n onStart?: (detail: DragGestureDetail) => void;\n pointerCapture?: boolean;\n shouldStart?: (event: PointerEvent) => boolean;\n signal?: AbortSignal;\n}>;\n```\n\n`start`, `current`, and `delta` use viewport `clientX` and `clientY` coordinates. Activation uses `Math.hypot(delta.x, delta.y)`.\n\n### Pan types\n\n```ts\ntype PanAxis = 'x' | 'y';\ntype PanEndReason = 'cancel' | 'release';\n\ntype PanGestureDetail = Readonly<{\n axis: PanAxis;\n current: number;\n distance: number;\n event: PointerEvent;\n pointerId: number;\n pointerType: string;\n start: number;\n target: Element;\n}>;\n\ntype PanGestureEndDetail = PanGestureDetail &\n Readonly<{\n reason: PanEndReason;\n }>;\n\ntype PanGestureOptions = Readonly<{\n activationDistance?: number;\n axis?: PanAxis | (() => PanAxis);\n disabled?: boolean | (() => boolean | undefined);\n onEnd?: (detail: PanGestureEndDetail) => void;\n onMove?: (detail: PanGestureDetail) => void;\n onStart?: (detail: PanGestureDetail) => void;\n pointerCapture?: boolean;\n shouldStart?: (event: PointerEvent) => boolean;\n signal?: AbortSignal;\n}>;\n```\n\nPan details project `start`, `current`, and `distance` onto `axis`. The default axis is `'x'`.\n\n### Handle types\n\n```ts\ntype DragGesture = {\n readonly active: boolean;\n [Symbol.dispose](): void;\n cancel(): boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n};\n\ntype PanGesture = DragGesture;\n```\n\n### Shared option behavior\n\n| Option | Default | Contract |\n| --- | --- | --- |\n| `activationDistance` | `6` | Non-negative finite CSS pixels; drag uses Euclidean distance, pan waits for dominant-axis intent |\n| `axis` | `'x'` | Pan only; fixed value or getter sampled when each interaction starts |\n| `disabled` | `false` | Fixed value or getter; blocks starts and cancels active recognition on the next matching pointer event |\n| `pointerCapture` | `true` | Best-effort capture after activation; document tracking continues when capture is disabled or fails |\n| `shouldStart` | — | Admission predicate evaluated for the primary pointer before tracking starts |\n| `signal` | — | Disposes the handle when the external owner aborts |\n| `onStart` | — | Runs once on the activating movement |\n| `onMove` | — | Runs on the activating movement and each later matching movement |\n| `onEnd` | — | Runs once for active release or cancellation; pending interactions end without callbacks |\n\nOptions and callback details are readonly. Fixed callbacks and capture policy are snapshotted at construction. Function-valued `axis` and `disabled` options remain dynamic.\n\nGesture tracks accepted movement with capture-phase listeners on the target's current owner document. Window blur, hidden-document transition, `pointercancel`, and `lostpointercapture` cancel active recognition.\n\n## Errors\n\nGesture does not export custom error classes.\n\n- Invalid `activationDistance` values throw `RangeError` during construction.\n- Invalid fixed `axis` values throw `RangeError` during construction.\n- Invalid values returned by a dynamic `axis` getter throw `RangeError` when a pointer interaction starts.\n",
|
|
6
|
+
"usage": "---\ntitle: Gesture — Usage Guide\ndescription: Track unrestricted pointer dragging and axis-locked pans with application-owned rendering and completion.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one handle for the element that owns the interaction. This example creates its surface, applies the required touch policy, and disposes the handle when the page unloads.\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst row = document.body.appendChild(document.createElement('div'));\nrow.textContent = 'Swipe to archive';\nrow.style.touchAction = 'pan-y';\n\nconst pan = createPanGesture(row, {\n axis: 'x',\n onMove: ({ distance }) => {\n row.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n row.style.transform = '';\n if (reason === 'release' && Math.abs(distance) >= 64) {\n pan.dispose();\n row.remove();\n }\n },\n});\n\nwindow.addEventListener('pagehide', () => pan.dispose(), { once: true });\n```\n\n## Track Two-Dimensional Dragging\n\nUse `createDragGesture()` when movement must remain unrestricted in both dimensions:\n\n```ts\nimport { createDragGesture } from '@vielzeug/gesture';\n\nconst card = document.querySelector<HTMLElement>('[data-draggable]');\nif (!card) throw new Error('Missing [data-draggable] element');\n\nconst drag = createDragGesture(card, {\n activationDistance: 6,\n onMove: ({ delta }) => {\n card.style.translate = `${delta.x}px ${delta.y}px`;\n },\n onEnd: () => {\n card.style.translate = '';\n },\n});\n\nwindow.addEventListener('pagehide', () => drag.dispose(), { once: true });\n```\n\n`start`, `current`, and `delta` are readonly `{ x, y }` points. Set `touch-action: none` when the surface owns movement in both dimensions. Drag recognition does not provide previews, drop targets, DOM movement, or `DataTransfer`; use Dnd for those semantics.\n\n## Completion Rules\n\nGesture reports movement and terminal state but does not decide what constitutes a swipe. Apply thresholds and allowed directions in `onEnd`.\n\n```ts\nconst pan = createPanGesture(panel, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && distance <= -80) {\n openNext();\n } else {\n resetPanel();\n }\n },\n});\n```\n\n## Direction Recognition\n\nThe gesture remains pending during small movement. It activates only after movement favors the configured axis. Cross-axis movement ends the pending interaction without invoking callbacks.\n\nThe default `activationDistance` is 6 pixels. Tune direction-recognition slop for touch density or component-specific needs:\n\n```ts\nconst pan = createPanGesture(row, {\n axis: 'x',\n activationDistance: 12,\n onMove,\n onEnd,\n});\n```\n\nSet `activationDistance: 0` when the first pointer move with non-zero displacement should activate immediately. Zero-motion events remain pending.\n\nUse the corresponding `touch-action` value so the browser retains native scrolling on the other axis.\n\n```css\n.swipe-row {\n touch-action: pan-y;\n}\n```\n\n```ts\nconst pan = createPanGesture(row, { axis: 'x', onMove });\n```\n\n## Pointer Capture\n\nPointer capture is enabled by default. After axis intent is accepted, Gesture attempts to capture the pointer while continuing to track movement through document-level listeners. Capture failure—for example after target detachment or pointer termination—falls back to document tracking without stranding the session.\n\nDisable capture when nested or newly revealed controls must retain native pointer-up and click targeting:\n\n```ts\nconst pan = createPanGesture(row, {\n axis: 'x',\n pointerCapture: false,\n onMove: renderReveal,\n onEnd: settleReveal,\n});\n```\n\nDocument-level tracking still keeps the pan active outside the target. Disabling capture changes event targeting, not gesture tracking.\n\n## Interactive Descendants\n\nUse `shouldStart` when buttons, links, or form controls inside the surface must not start a pan.\n\n```ts\nconst pan = createPanGesture(notification, {\n axis: 'x',\n pointerCapture: false,\n shouldStart: (event) =>\n !event\n .composedPath()\n .some((node) => node instanceof Element && node.matches('button, a, input, select, textarea')),\n onMove,\n onEnd,\n});\n```\n\n`shouldStart` protects controls under the initial pointer. `pointerCapture: false` additionally protects controls that appear beneath the pointer during a reveal interaction.\n\n## Disabled State\n\nA boolean disables the recognizer permanently. A getter supports state that changes while the handle is alive.\n\n```ts\nconst pan = createPanGesture(row, {\n disabled: () => isLocked,\n onEnd: ({ reason }) => {\n if (reason === 'cancel') resetRow();\n },\n});\n```\n\nWhen the getter becomes `true`, the next pointer event cancels an active pan. Window blur or a hidden owner document also cancels active tracking so a lost release cannot block later gestures.\n\nGesture snapshots fixed callbacks and capture policy at construction. Use the `axis` and `disabled` functions for behavior that must remain dynamic instead of mutating the options object.\n\n## Lifecycle\n\nDispose the target-bound handle when its owning UI scope unmounts.\n\n```ts\nconst element = document.querySelector<HTMLElement>('[data-pan]');\nif (!element) throw new Error('Missing [data-pan] element');\n\nconst owner = new AbortController();\nconst request = new AbortController();\nconst pan = createPanGesture(element, {\n axis: 'x',\n signal: owner.signal,\n});\n\nconst childSignal = AbortSignal.any([pan.disposalSignal, request.signal]);\nwindow.addEventListener('pagehide', () => owner.abort(), { once: true });\n```\n\nPass `{ signal }` to dispose with an existing owner signal. `disposalSignal` always aborts when Gesture finishes teardown, so child work can compose with the handle regardless of how disposal starts.\n\nUse `cancel()` to stop a pending or active interaction without disposing the handle. An active interaction emits `onEnd` with `reason: 'cancel'`. Call `cancel()` before `dispose()` when a connected surface needs its `onEnd` reset path; disposal itself does not invoke callbacks.\n\n## Framework Integration\n\nCreate the handle after the target element exists and dispose it on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createPanGesture } from '@vielzeug/gesture';\n\nfunction SwipeRow({ onDismiss }: { onDismiss: () => void }) {\n const rowRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n const row = rowRef.current;\n if (!row) return;\n\n const pan = createPanGesture(row, {\n axis: 'x',\n onMove: ({ distance }) => {\n row.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n row.style.transform = '';\n if (reason === 'release' && Math.abs(distance) >= 64) onDismiss();\n },\n });\n\n return () => pan.dispose();\n }, [onDismiss]);\n\n return <div ref={rowRef}>Swipe me</div>;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, ref } from 'vue';\nimport { createPanGesture, type PanGesture } from '@vielzeug/gesture';\n\nconst emit = defineEmits<{ dismiss: [] }>();\nconst rowEl = ref<HTMLDivElement | null>(null);\nlet pan: PanGesture | undefined;\n\nonMounted(() => {\n const row = rowEl.value;\n if (!row) return;\n\n pan = createPanGesture(row, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && Math.abs(distance) >= 64) emit('dismiss');\n },\n });\n});\n\nonUnmounted(() => pan?.dispose());\n</script>\n\n<template>\n <div ref=\"rowEl\">Swipe me</div>\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createPanGesture } from '@vielzeug/gesture';\n\n let { ondismiss = () => {} }: { ondismiss: () => void } = $props();\n let rowEl: HTMLDivElement;\n\n onMount(() => {\n const pan = createPanGesture(rowEl, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && Math.abs(distance) >= 64) ondismiss();\n },\n });\n\n return () => pan.dispose();\n });\n</script>\n\n<div bind:this={rowEl}>Swipe me</div>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Gesture + Refine\n\nRefine uses Gesture internally for carousel, drawer, toast, and list-item pointer interactions. Custom surfaces can use the same pan lifecycle while keeping visual state local.\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst pan = createPanGesture(panel, {\n axis: 'x',\n onMove: ({ distance }) => {\n panel.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n panel.style.transform = '';\n if (reason === 'release' && Math.abs(distance) >= 80) revealActions();\n },\n});\n```\n\n### Gesture + Dnd\n\nGesture owns pointer recognition: unrestricted two-dimensional drag and one-axis pan. Dnd builds touch sorting on `createDragGesture()`, then owns `DataTransfer`, previews, hit-testing, file drop zones, keyboard sorting, DOM reordering, and connected-list transactions. Use Dnd when an item is picked up and dropped; use Gesture directly when application code owns rendering and completion.\n\n## Best Practices\n\n- **Set** `touch-action` for the axis the browser should continue scrolling.\n- **Use** `shouldStart` to exclude nested interactive controls.\n- **Disable** pointer capture when nested or newly revealed controls must keep native release targeting.\n- **Apply** completion thresholds and direction rules in `onEnd`; use `activationDistance` only for recognition slop.\n- **Treat** `reason: 'cancel'` as a reset path, never a commit path.\n- **Keep** `onMove` rendering lightweight.\n- **Dispose** the handle when its target leaves the UI.\n",
|
|
7
|
+
"examples": "---\ntitle: Gesture — Examples\ndescription: Worked examples for @vielzeug/gesture.\n---\n\n## Examples\n\n- [Carousel Pan Navigation](./examples/carousel-swipe-navigation.md)\n- [Swipe-to-Dismiss Notifications](./examples/swipe-dismiss-notifications.md)\n"
|
|
8
|
+
},
|
|
9
|
+
"examples": [
|
|
10
|
+
{
|
|
11
|
+
"id": "drag-basic",
|
|
12
|
+
"code": "import { createDragGesture } from '@vielzeug/gesture'\n\nconst surface = document.createElement('div')\nsurface.textContent = 'Drag freely'\nsurface.style.cssText = 'width:160px;padding:32px;text-align:center;background:#dcfce7;border-radius:12px;touch-action:none;user-select:none;'\ndocument.body.appendChild(surface)\n\nconst drag = createDragGesture(surface, {\n onMove: ({ delta }) => {\n surface.style.translate = `${delta.x}px ${delta.y}px`\n },\n onEnd: () => {\n surface.style.translate = ''\n },\n})\n\nconsole.log('Drag gesture ready:', drag.disposed === false)",
|
|
13
|
+
"name": "createDragGesture - Basic"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "pan-basic",
|
|
17
|
+
"code": "import { createPanGesture } from '@vielzeug/gesture'\n\nconst surface = document.createElement('div')\nsurface.textContent = 'Drag horizontally'\nsurface.style.cssText = 'width:240px;padding:32px;text-align:center;background:#e0e7ff;border-radius:12px;touch-action:pan-y;user-select:none;'\ndocument.body.appendChild(surface)\n\nconst output = document.createElement('pre')\ndocument.body.appendChild(output)\n\nconst pan = createPanGesture(surface, {\n axis: 'x',\n onMove: ({ distance }) => {\n surface.style.transform = `translateX(${distance}px)`\n output.textContent = `distance: ${Math.round(distance)}px`\n },\n onEnd: ({ distance, reason }) => {\n surface.style.transform = ''\n output.textContent = reason === 'release' && Math.abs(distance) >= 48\n ? `swipe: ${distance < 0 ? 'left' : 'right'}`\n : `ended: ${reason}`\n },\n})\n\nconsole.log('Pan gesture ready:', pan.disposed === false)",
|
|
18
|
+
"name": "createPanGesture - Basic"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"typeSignatures": {
|
|
22
|
+
"createDragGesture": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';",
|
|
23
|
+
"DragEndReason": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';",
|
|
24
|
+
"DragGesture": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';",
|
|
25
|
+
"DragGestureDetail": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';",
|
|
26
|
+
"DragGestureEndDetail": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';",
|
|
27
|
+
"DragGestureOptions": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';",
|
|
28
|
+
"DragPoint": "export {\n createDragGesture,\n type DragEndReason,\n type DragGesture,\n type DragGestureDetail,\n type DragGestureEndDetail,\n type DragGestureOptions,\n type DragPoint,\n} from './drag-gesture.js';",
|
|
29
|
+
"createPanGesture": "export {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';",
|
|
30
|
+
"PanAxis": "export {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';",
|
|
31
|
+
"PanEndReason": "export {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';",
|
|
32
|
+
"PanGesture": "export {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';",
|
|
33
|
+
"PanGestureDetail": "export {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';",
|
|
34
|
+
"PanGestureEndDetail": "export {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';",
|
|
35
|
+
"PanGestureOptions": "export {\n createPanGesture,\n type PanAxis,\n type PanEndReason,\n type PanGesture,\n type PanGestureDetail,\n type PanGestureEndDetail,\n type PanGestureOptions,\n} from './pan-gesture.js';"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"apiSource": "export { createBus } from './bus';\nexport { BusDisposedError, HeraldConfigError, HeraldError } from './errors';\nexport type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';\n",
|
|
3
|
+
"docs": {
|
|
4
|
+
"index": "---\ntitle: Herald — Typed event bus for TypeScript\ndescription: Typed synchronous event delivery with wildcard subscriptions, one-shot waits, tracing, and AbortSignal lifecycle.\npackage: herald\ncategory: events\nkeywords: [event-bus, typed-events, pub-sub, async-wait, abort-signal]\nrelated: [ripple, wayfinder, familiar]\nexports: [createBus, HeraldError, BusDisposedError, HeraldConfigError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"herald\" />\n\n## Why Herald?\n\nRaw event emitters lose payload inference and leave one-shot waiting, cancellation, diagnostics, and teardown to every caller. Herald keeps events temporal: use [Ripple](/ripple/) when you need retained state.\n\n```ts\n// Before\nconst listeners = new Set<(payload: unknown) => void>();\nlisteners.add((payload) => loadProfile((payload as { id: string }).id));\n\n// After\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'user:login': { id: string };\n}\n\nfunction loadProfile(id: string): void {\n console.log(id);\n}\n\nconst bus = createBus<AppEvents>();\nbus.on('user:login', ({ id }) => loadProfile(id));\n```\n\nDelivery is synchronous and temporal: events are not retained or replayed. Listener failures do not stop later listeners; `emit()` rethrows the first failure after dispatch, while `tap()` exposes every failure for diagnostics.\n\n| Feature | Herald | mitt | EventEmitter3 |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"herald\" type=\"size\" /> | ~200 B | ~1.5 kB |\n| Typed payloads | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| One-shot async wait | <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 lifecycle | <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| Runtime activity tracing | <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| Zero dependencies | <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\n<div class=\"decision-callout\">\n\n**Use Herald when** modules need typed temporal event delivery with owned lifecycle.\n\n**Consider Ripple when** consumers need current state and replayed values.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/herald\n```\n\n```sh [npm]\nnpm install @vielzeug/herald\n```\n\n```sh [yarn]\nyarn add @vielzeug/herald\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'user:login': { id: string };\n 'user:logout': void;\n}\n\nconst bus = createBus<AppEvents>();\nconst stop = bus.on('user:login', ({ id }) => console.log(id));\n\nbus.emit('user:login', { id: '42' });\nstop();\nbus.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `on()` / `once()` — typed subscriptions with explicit teardown\n- `onAny()` — cross-cutting event observation\n- `tap()` — observe bus activity for logging and diagnostics\n- `wait()` / `waitAny()` — one-shot async coordination\n- `listenerCount()` / `wildcardCount()` / `eventNames()` — listener inspection\n- `error` tap events — isolate listener failures without interrupting delivery\n- `AbortSignal` — subscription, wait, tap, and disposal ownership\n- `createTestBus()` — emitted-payload recording for tests\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- [Ripple](/ripple/) — retained reactive state.\n- [Wayfinder](/wayfinder/) — route lifecycle events.\n- [Familiar](/familiar/) — worker completion events.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Herald — API Reference\ndescription: Reference for typed synchronous events, subscriptions, waits, tracing, lifecycle, and test helpers.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `createBus()` | Create a typed temporal event bus | Sync | Events are not retained or replayed |\n| `emit()` | Deliver one event | Sync | Returns `void`; rethrows the first listener failure after dispatch |\n| `on()` / `once()` | Subscribe to one event | Sync | Keep the unsubscribe function or pass a signal |\n| `onAny()` | Subscribe to every event | Sync | Payload is `unknown` |\n| `wait()` / `waitAny()` | Await future one-shot events | Async | Disposal and external abort reject the promise |\n| `tap()` | Observe bus runtime activity | Sync | Tap-handler failures are swallowed |\n| `createTestBus()` | Record emitted payloads | Sync | Import from `/testing` |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/herald` | Bus factory, errors, and public bus types |\n| `@vielzeug/herald/testing` | `createTestBus()` and `TestBus` |\n\n## `createBus()`\n\n```ts\nfunction createBus<T extends EventMap = Record<string, unknown>>(\n options?: BusOptions<T>,\n): Bus<T>\n```\n\nCreates a synchronous bus for future event delivery. Construction has no external side effects.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface Events {\n count: number;\n ready: void;\n}\n\nconst bus = createBus<Events>({ maxListeners: 20, name: 'worker' });\nbus.emit('count', 1);\nbus.emit('ready');\nbus.dispose();\n```\n\n### `BusOptions`\n\n```ts\ntype BusOptions<T extends EventMap = EventMap> = {\n maxListeners?: number;\n name?: string;\n};\n```\n\n| Field | Description |\n| --- | --- |\n| `maxListeners` | Development warning threshold applied separately to one event's listeners and wildcard listeners. Omit to disable the check. |\n| `name` | Display name included in disposal errors and listener-threshold warnings. |\n\n## `Bus`\n\n```ts\ntype Bus<T extends EventMap> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n emit<K extends EventKey<T>>(event: K, ...args: T[K] extends void ? [] : [payload: T[K]]): void;\n eventNames(): EventKey<T>[];\n listenerCount(event?: EventKey<T>): number;\n on<K extends EventKey<T>>(event: K, listener: Listener<T[K]>, options?: SubscribeOptions): Unsubscribe;\n onAny(listener: (event: EventKey<T>, payload: unknown) => void, options?: SubscribeOptions): Unsubscribe;\n once<K extends EventKey<T>>(event: K, listener: Listener<T[K]>, options?: { signal?: AbortSignal }): Unsubscribe;\n tap(handler: (event: HeraldEvent<T>) => void, options?: { signal?: AbortSignal }): Unsubscribe;\n wait<K extends EventKey<T>>(event: K, options?: { signal?: AbortSignal }): Promise<T[K]>;\n waitAny<const K extends readonly [EventKey<T>, EventKey<T>, ...EventKey<T>[]]>(\n events: K,\n options?: { signal?: AbortSignal },\n ): Promise<WaitAnyResult<T, K>>;\n wildcardCount(): number;\n};\n```\n\n### `emit()`\n\nCalls a snapshot of event-specific listeners synchronously, then wildcard listeners, then emits an `emit` trace event. It always returns `void`. Synchronous listener failures produce `error` trace events without stopping delivery; after dispatch, the first failure is rethrown.\n\nAfter disposal, emission is a no-op.\n\n### `on()` and `once()`\n\n```ts\ntype Listener<T> = (payload: T) => void;\ntype SubscribeOptions = { once?: boolean; signal?: AbortSignal };\ntype Unsubscribe = () => void;\n```\n\n`on()` returns an idempotent unsubscribe function. `{ once: true }` is equivalent to `once()`. Once-listeners unsubscribe before invocation. An already-aborted signal creates no subscription.\n\n### `onAny()`\n\nReceives `(event, payload)` after event-specific listeners. It supports both `signal` and `once`. Wildcard listeners are excluded from `listenerCount()` and reported by `wildcardCount()`.\n\n### `wait()`\n\nResolves with the next payload for one event. It rejects with the external signal's reason when aborted, or `BusDisposedError` when bus disposal wins. Earlier emissions are not replayed.\n\n### `waitAny()`\n\nRequires at least two event keys at compile time and runtime. It resolves once with a typed `{ event, payload }` discriminated union and removes every losing subscription.\n\n```ts\nconst result = await bus.waitAny(['count', 'ready']);\nif (result.event === 'count') console.log(result.payload);\n```\n\n### Listener inspection\n\n- `listenerCount(event)` returns specific listeners for one key.\n- `listenerCount()` returns all specific listeners.\n- `wildcardCount()` returns `onAny()` listeners.\n- `eventNames()` returns keys with active specific listeners.\n\n### `tap()`\n\nObserves runtime activity without affecting delivery. It returns an idempotent unsubscribe function and accepts an ownership signal. Tap-handler errors are swallowed.\n\n```ts\nconst stop = bus.tap((event) => {\n if (event.type === 'error') recordListenerFailure(event.error);\n});\n```\n\n### Disposal\n\n`dispose()` is permanent and idempotent. It emits a final `dispose` trace event, aborts `disposalSignal` with `BusDisposedError`, rejects pending waits, removes subscriptions, and clears taps. `[Symbol.dispose]()` delegates to it.\n\n## Runtime Events\n\n```ts\ntype HeraldEvent<T extends EventMap = EventMap> =\n | { readonly event: EventKey<T>; readonly listeners: number; readonly payload: unknown; readonly type: 'emit' }\n | { readonly event: EventKey<T>; readonly type: 'subscribe' }\n | { readonly event: EventKey<T>; readonly type: 'unsubscribe' }\n | { readonly type: 'subscribe-any' }\n | { readonly type: 'unsubscribe-any' }\n | { readonly error: unknown; readonly event: EventKey<T>; readonly type: 'error' }\n | { readonly type: 'dispose' };\n```\n\n`listeners` on an `emit` event counts every specific and wildcard listener invoked, including listeners that threw. Runtime events intentionally contain no timestamp; observers can add one using their own clock.\n\n## Shared Types\n\n```ts\ntype EventMap = object;\ntype EventKey<T extends EventMap> = Extract<keyof T, string>;\n\ntype WaitAnyResult<T extends EventMap, K extends readonly EventKey<T>[]> = {\n [I in keyof K]: K[I] extends EventKey<T>\n ? { event: K[I]; payload: T[K[I]] }\n : never;\n}[number];\n```\n\nInterfaces and object type aliases are valid event maps. Only string keys participate in the bus API.\n\n## Testing\n\n### `createTestBus()`\n\n```ts\nimport { createTestBus } from '@vielzeug/herald/testing';\n\nfunction createTestBus<T extends EventMap = Record<string, unknown>>(\n options?: BusOptions<T>,\n): TestBus<T>\n```\n\nCreates a regular bus that records dispatches before listeners run.\n\n### `TestBus`\n\n```ts\ntype TestBus<T extends EventMap> = Bus<T> & {\n allEmitted(): { [K in EventKey<T>]?: T[K][] };\n emitted<K extends EventKey<T>>(event: K): T[K][];\n emittedCount<K extends EventKey<T>>(event: K): number;\n reset(): void;\n};\n```\n\n`emitted()` and `allEmitted()` return array snapshots. `reset()` clears records without affecting subscriptions. Disposal clears records and the underlying bus. To prevent prototype mutation in the returned plain object, `allEmitted()` omits records named `__proto__`, `constructor`, or `prototype`; `emitted(event)` remains available for those keys.\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `BusDisposedError` | Bus disposal aborts `disposalSignal` or interrupts `wait()` / `waitAny()` | Configured bus name appears in the message |\n| `HeraldConfigError` | Runtime `waitAny()` input contains fewer than two keys | Extends `HeraldError` |\n| `HeraldError` | Base class for Herald-originated errors | Use `instanceof HeraldError` |\n",
|
|
6
|
+
"usage": "---\ntitle: Herald — Usage Guide\ndescription: Define typed events, own subscriptions, await one-shot events, trace failures, and test emissions.\n---\n\n[[toc]]\n\n## Basic Usage\n\nUse an interface or object type whose string keys are event names and whose values are payloads. Use `void` or `undefined` for payloadless events.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'cart:updated': { count: number };\n 'user:logout': void;\n}\n\nusing bus = createBus<AppEvents>({ name: 'app' });\nconst stop = bus.on('cart:updated', ({ count }) => console.log(count));\n\nbus.emit('cart:updated', { count: 1 });\nbus.emit('user:logout');\nstop();\n```\n\n`emit()` calls listeners synchronously and returns `void`. Event-specific listeners run before wildcard listeners. Events emitted without listeners are discarded.\n\n## Own Subscription Lifetimes\n\nKeep the returned unsubscribe function or bind a subscription to an `AbortSignal`. `once()` and `{ once: true }` unsubscribe before invoking the listener.\n\n```ts\nconst controller = new AbortController();\n\nbus.on('cart:updated', renderCart, { signal: controller.signal });\nbus.once('user:logout', clearSession);\nbus.on('user:logout', clearSession, { once: true });\n\ncontroller.abort();\n```\n\nAn already-aborted signal creates no subscription and returns an idempotent no-op unsubscribe function.\n\n## Observe Every Event\n\nUse `onAny()` for cross-cutting application behavior. Wildcard listeners receive the event key and untyped payload after event-specific listeners.\n\n```ts\nconst stop = bus.onAny((event, payload) => {\n audit(event, payload);\n});\n\nstop();\n```\n\nPass `{ once: true }` to stop after the first event of any type. `wildcardCount()` reports wildcard listeners separately from `listenerCount()`.\n\n## Await One Event\n\n`wait()` resolves on the next matching event. `waitAny()` requires at least two keys and returns a discriminated union.\n\n```ts\nconst cart = await bus.wait('cart:updated', { signal: AbortSignal.timeout(5_000) });\nconst result = await bus.waitAny(['cart:updated', 'user:logout'], {\n signal: AbortSignal.timeout(5_000),\n});\n\nif (result.event === 'cart:updated') console.log(result.payload.count);\n```\n\nThese methods do not replay earlier events. External cancellation rejects with the signal reason. Bus disposal rejects pending waits with `BusDisposedError`.\n\n## Handle Listener Failures\n\nListener exceptions do not prevent remaining specific or wildcard listeners from running. Each failure produces a `error` tap event, then `emit()` rethrows the first error after dispatch.\n\n```ts\nconst stopTrace = bus.tap((event) => {\n if (event.type === 'error') {\n recordListenerFailure(event.error);\n }\n});\n\nstopTrace();\n```\n\nHerald captures synchronous listener throws only, and `tap()` does not suppress the error rethrown after dispatch. A listener that starts asynchronous work must handle its own rejected promises.\n\n## Trace Bus Activity\n\n`tap()` observes subscriptions, unsubscriptions, wildcard subscriptions, emissions, listener failures, and disposal without changing bus behavior. Tap-handler failures are swallowed.\n\n```ts\nconst stopTrace = bus.tap((event) => {\n recordHeraldEvent(event);\n}, { signal: owner.signal });\n```\n\nAn `emit` trace includes the event key, payload, and number of specific plus wildcard listeners invoked. Trace events do not include timestamps; add them in the observer if needed.\n\n## Inspect Listeners\n\n```ts\nbus.listenerCount('cart:updated'); // specific listeners for this key\nbus.listenerCount(); // all specific listeners\nbus.wildcardCount(); // onAny listeners\nbus.eventNames(); // keys with active specific listeners\n```\n\n`eventNames()` does not include events that only have wildcard listeners.\n\n## Dispose the Bus\n\n`dispose()` is permanent and idempotent. It emits a final tap event, aborts `disposalSignal` with `BusDisposedError`, clears all listeners and taps, and rejects pending waits. Later subscriptions and emissions are no-ops.\n\n```ts\nusing scopedBus = createBus<AppEvents>({ name: 'checkout' });\nscopedBus.disposalSignal.addEventListener('abort', () => releaseOwnedWork());\n```\n\nThe configured name appears in disposal errors and listener-threshold warnings.\n\n## Test Emissions\n\nImport test helpers from the focused testing entry point.\n\n```ts\nimport { createTestBus } from '@vielzeug/herald/testing';\n\nusing testBus = createTestBus<AppEvents>();\ntestBus.emit('cart:updated', { count: 2 });\n\nexpect(testBus.emitted('cart:updated')).toEqual([{ count: 2 }]);\nexpect(testBus.emittedCount('cart:updated')).toBe(1);\nexpect(testBus.allEmitted()).toEqual({ 'cart:updated': [{ count: 2 }] });\n\ntestBus.reset();\n```\n\nReturned recordings are snapshots. `reset()` clears recording history without removing listeners; disposal clears both.\n\n## Working with Other Vielzeug Libraries\n\nUse Herald for temporal facts, Ripple for retained state, Rune for structured logging inside `tap()`, and Postmaster when work must survive reloads or retry durably.\n\n## Best Practices\n\n- Define one explicit event map per module or application boundary.\n- Emit facts, not mutable retained state.\n- Keep synchronous listeners short.\n- Handle rejected promises inside asynchronous listener work.\n- Keep unsubscribe functions or pass ownership signals.\n- Use `wait()` and `waitAny()` only for future one-shot events.\n- Install a `tap()` observer when listener failures must be visible.\n- Dispose owner-scoped buses.\n",
|
|
7
|
+
"examples": "---\ntitle: Herald — Examples\ndescription: Recipes for typed delivery, waits, lifecycle ownership, diagnostics, bridging, and testing.\n---\n\n## Examples\n\n- [Standalone Bus](./examples/standalone-entry.md)\n- [Module-Level Bus](./examples/module-level-bus.md)\n- [Await a One-Time Event](./examples/awaiting-a-one-time-event.md)\n- [Inspect Listener Counts](./examples/inspecting-listener-counts.md)\n- [Observe Listener Failures](./examples/custom-error-boundary.md)\n- [Handle Disposal in Async Code](./examples/handling-disposal-in-async-code.md)\n- [Scope a Bus to a Request](./examples/request-scoping.md)\n- [Consume Ongoing Events](./examples/streaming-with-events.md)\n- [Bridge Buses Explicitly](./examples/bus-bridging-with-pipeevents.md)\n- [Test with `createTestBus()`](./examples/testing-with-createtestbus.md)\n"
|
|
8
|
+
},
|
|
9
|
+
"examples": [
|
|
10
|
+
{
|
|
11
|
+
"id": "abort-signal",
|
|
12
|
+
"code": "import { createBus, BusDisposedError } from '@vielzeug/herald'\n\n// Demonstrate AbortSignal auto-unsubscribe and BusDisposedError\nconst bus = createBus()\n\nconst controller = new AbortController()\nconst { signal } = controller\n\nbus.on('message', (msg) => {\n console.log('listener received:', msg)\n}, { signal })\n\nbus.emit('message', 'first') // fires\nbus.emit('message', 'second') // fires\n\ncontroller.abort() // removes the listener\n\nbus.emit('message', 'third') // ignored — no listeners\nconsole.log('listeners after abort:', bus.listenerCount())\n\n// BusDisposedError: pending wait() rejects when bus is disposed\nconst bus2 = createBus()\n\nvoid bus2.wait('done').catch((err) => {\n if (err instanceof BusDisposedError) {\n console.log('BusDisposedError caught:', err.message)\n }\n})\n\nbus2.dispose()",
|
|
13
|
+
"name": "AbortSignal & BusDisposedError"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "async-wait",
|
|
17
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\n// Await a single event with wait(), race multiple with waitAny()\nconst bus = createBus()\n\n// Emit after a short delay\nsetTimeout(() => bus.emit('user:login', { userId: '42', email: 'alice@example.com' }), 50)\nsetTimeout(() => bus.emit('theme:change', 'dark'), 80)\n\n// wait() resolves on the next emit of that event\nconst loginPayload = await bus.wait('user:login')\nconsole.log('got login:', loginPayload.userId)\n\n// Reset and race two events — whichever fires first wins\nsetTimeout(() => bus.emit('user:login', { userId: '1', email: 'a@b.com' }), 20)\nsetTimeout(() => bus.emit('theme:change', 'light'), 60)\n\nconst result = await bus.waitAny(['user:login', 'theme:change'])\n\nif (result.event === 'user:login') {\n console.log('login won the race:', result.payload.userId)\n} else {\n console.log('theme won the race:', result.payload)\n}\n\nbus.dispose()",
|
|
18
|
+
"name": "Async wait()"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"id": "basic-bus",
|
|
22
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\n// Typed pub/sub with on(), emit(), and once()\nconst bus = createBus()\n\nconst unsub = bus.on('user:login', ({ userId, email }) => {\n console.log('login:', userId, email)\n})\n\nbus.once('user:logout', () => {\n console.log('logged out (fires once)')\n})\n\nbus.emit('user:login', { userId: '1', email: 'alice@example.com' })\nbus.emit('user:logout')\nbus.emit('user:logout') // once() already removed; no output\n\nunsub()\nbus.emit('user:login', { userId: '2', email: 'bob@example.com' }) // no output — unsubscribed\n\nbus.dispose()",
|
|
23
|
+
"name": "Basic Bus"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "bus-basics",
|
|
27
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\n// on() returns an unsubscribe handle; emit() delivers to all active listeners\nconst bus = createBus()\n\nconst unsubLogin = bus.on('user:login', (payload) => {\n console.log('login:', payload.name, '(' + payload.userId + ')')\n})\n\nbus.on('user:logout', () => console.log('logged out'))\nbus.on('notification', (msg) => console.log('notification:', msg))\n\nbus.emit('user:login', { userId: '123', name: 'Alice' })\nbus.emit('notification', 'welcome back!')\n\nunsubLogin()\n\nbus.emit('user:login', { userId: '456', name: 'Bob' }) // no output — unsubscribed\nconsole.log('active listeners:', bus.listenerCount())\n\nbus.dispose()",
|
|
28
|
+
"name": "createBus - Basics"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "disposal-signal",
|
|
32
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\n// disposalSignal ties an external subscription's lifetime to this bus\nconst mainBus = createBus()\nconst childBus = createBus()\n\n// Pass disposalSignal so the child listener is removed when mainBus disposes\nchildBus.on('data:update', ({ value }) => {\n console.log('child received:', value)\n}, { signal: mainBus.disposalSignal })\n\nconsole.log('child listeners before dispose:', childBus.listenerCount())\n\nchildBus.emit('data:update', { value: 10 }) // fires — listener is active\n\nmainBus.dispose() // disposalSignal fires — child listener auto-removed\n\nconsole.log('child listeners after mainBus.dispose():', childBus.listenerCount())\n\nchildBus.emit('data:update', { value: 20 }) // no output — listener is gone\nconsole.log('mainBus.disposed:', mainBus.disposed)\nconsole.log('disposalSignal aborted:', mainBus.disposalSignal.aborted)",
|
|
33
|
+
"name": "disposalSignal"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"id": "error-handling",
|
|
37
|
+
"code": "import { createBus, HeraldError } from '@vielzeug/herald'\n\n// tap() captures every listener error; emit() rethrows the first after all listeners run\nconst errors = []\n\nconst bus = createBus()\n\nbus.tap((event) => {\n if (event.type === 'error') {\n errors.push({ event: event.event, message: event.error instanceof Error ? event.error.message : String(event.error) })\n }\n})\n\nbus.on('order:placed', () => console.log('confirmation email sent'))\nbus.on('order:placed', () => {\n throw new Error('inventory check failed')\n})\nbus.on('order:placed', () => console.log('analytics event recorded')) // still runs\n\ntry {\n bus.emit('order:placed', { id: 'ORD-1', total: 49.99 })\n} catch (err) {\n console.log('first error rethrown:', err.message)\n}\n\nconsole.log('captured errors:', errors)\n// [{ event: 'order:placed', message: 'inventory check failed' }]\n\ntry {\n bus.waitAny(['event-a']) // waitAny requires at least 2 event keys\n} catch (err) {\n console.log('caught herald error?', err instanceof HeraldError, '-', err.message)\n}\n\nbus.dispose()",
|
|
38
|
+
"name": "Error Handling"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "named-bus",
|
|
42
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\n// name appears in BusDisposedError messages\nconst authBus = createBus({ name: 'auth', maxListeners: 1 })\n\nauthBus.on('login', (userId) => console.log('user:', userId))\nauthBus.on('login', (userId) => console.log('audit:', userId)) // triggers maxListeners warn\n\nauthBus.emit('login', 'alice')\n\nconst pending = authBus.wait('logout')\nauthBus.dispose()\n\npending.catch((err) => {\n console.log('error name:', err.name)\n console.log('error message:', err.message)\n})",
|
|
43
|
+
"name": "Named Bus"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "once-and-wait",
|
|
47
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\n// once() fires exactly once then auto-removes; wait() resolves on the next emit\nconst bus = createBus()\n\nbus.once('data:ready', (payload) => {\n console.log('data (once):', payload.items.join(', '))\n})\n\nbus.emit('data:ready', { items: ['alpha', 'beta', 'gamma'] }) // fires\nbus.emit('data:ready', { items: ['ignored'] }) // once already consumed\n\nasync function waitForTask() {\n console.log('waiting for task...')\n const result = await bus.wait('task:done')\n console.log('task done! result:', result.result)\n}\n\nvoid waitForTask()\n\nsetTimeout(() => {\n bus.emit('task:done', { result: 99 })\n}, 50)",
|
|
48
|
+
"name": "once() and wait()"
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"id": "test-bus",
|
|
52
|
+
"code": "import { createTestBus } from '@vielzeug/herald/testing'\n\n// TestBus wraps a normal bus with emission recording — no mocking required\nconst bus = createTestBus()\n\nconst stop = bus.on('cart:updated', (cart) => console.log('handler saw total:', cart.total))\n\nbus.emit('cart:updated', { items: 1, total: 19.99 })\nbus.emit('cart:updated', { items: 2, total: 39.98 })\nbus.emit('user:logout')\n\nconsole.log('emitted count:', bus.emittedCount('cart:updated')) // 2\nconsole.log('all payloads:', bus.emitted('cart:updated'))\nconsole.log('every recorded event:', bus.allEmitted())\n\nstop()\nbus.emit('cart:updated', { items: 3, total: 59.97 }) // still recorded\n\nconsole.log('after unsubscribe:', bus.emitted('cart:updated'))\n\nbus.dispose()",
|
|
53
|
+
"name": "createTestBus()"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
"id": "wait-any",
|
|
57
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\n// waitAny resolves with { event, payload } for whichever event fires first\nconst bus = createBus()\n\nasync function watchNextSessionEvent() {\n console.log('waiting for first session event...')\n\n const result = await bus.waitAny(['user:login', 'user:logout', 'session:expired'])\n\n if (result.event === 'user:login') {\n console.log('login:', result.payload.email, '(id:', result.payload.userId + ')')\n } else if (result.event === 'session:expired') {\n console.log('session expired:', result.payload.reason)\n } else {\n console.log('user logged out')\n }\n}\n\nvoid watchNextSessionEvent()\n\nsetTimeout(() => {\n bus.emit('user:login', { email: 'alice@example.com', userId: '42' })\n bus.emit('user:logout') // ignored — waitAny already resolved\n}, 30)",
|
|
58
|
+
"name": "waitAny()"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "wildcard-listeners",
|
|
62
|
+
"code": "import { createBus } from '@vielzeug/herald'\n\ntype AppEvents = {\n 'user:login': { userId: string }\n 'user:logout': void\n 'cart:updated': { items: number }\n}\n\nconst bus = createBus<AppEvents>({ name: 'app' })\n\n// onAny receives every event — useful for logging, analytics, tracing\nconst unsub = bus.onAny((event, payload) => {\n console.log('[audit]', event, payload)\n})\n\nconsole.log('wildcard listeners:', bus.wildcardCount()) // 1\n\nbus.emit('user:login', { userId: 'alice' })\nbus.emit('cart:updated', { items: 3 })\nbus.emit('user:logout')\n\n// Remove the wildcard listener\nunsub()\nconsole.log('after unsub:', bus.wildcardCount()) // 0\n\nbus.dispose()",
|
|
63
|
+
"name": "onAny + wildcardCount()"
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
"typeSignatures": {
|
|
67
|
+
"createBus": "export { createBus } from './bus';",
|
|
68
|
+
"BusDisposedError": "export { BusDisposedError, HeraldConfigError, HeraldError } from './errors';",
|
|
69
|
+
"HeraldConfigError": "export { BusDisposedError, HeraldConfigError, HeraldError } from './errors';",
|
|
70
|
+
"HeraldError": "export { BusDisposedError, HeraldConfigError, HeraldError } from './errors';",
|
|
71
|
+
"Bus": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
72
|
+
"BusOptions": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
73
|
+
"EventKey": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
74
|
+
"EventMap": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
75
|
+
"HeraldEvent": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
76
|
+
"Listener": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
77
|
+
"SubscribeOptions": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
78
|
+
"Unsubscribe": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
79
|
+
"WaitAnyResult": "export type {\n Bus,\n BusOptions,\n EventKey,\n EventMap,\n HeraldEvent,\n Listener,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';"
|
|
80
|
+
}
|
|
81
|
+
}
|