@vielzeug/codex 26.10.0 → 26.10.2
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 +1 -1
- package/data/catalog.json +56 -60
- package/data/llms-full.txt +1473 -1069
- package/data/llms.txt +6 -6
- package/data/manifest.json +1 -1
- package/data/packages/arsenal.json +13 -13
- package/data/packages/assay.json +4 -4
- package/data/packages/clockwork.json +4 -4
- package/data/packages/codex.json +3 -3
- package/data/packages/coins.json +4 -4
- package/data/packages/conduit.json +4 -4
- package/data/packages/courier.json +4 -4
- package/data/packages/dnd.json +10 -10
- package/data/packages/familiar.json +4 -4
- package/data/packages/flux.json +4 -4
- package/data/packages/focus.json +8 -5
- package/data/packages/forge.json +4 -4
- package/data/packages/gesture.json +4 -4
- package/data/packages/herald.json +12 -12
- package/data/packages/illusionist.json +5 -5
- package/data/packages/keymap.json +9 -9
- package/data/packages/ledger.json +4 -4
- package/data/packages/lingua.json +4 -4
- package/data/packages/mesh.json +5 -5
- package/data/packages/necromancer.json +5 -5
- package/data/packages/orbit.json +7 -7
- package/data/packages/ore.json +5 -5
- package/data/packages/postmaster.json +4 -4
- package/data/packages/prism.json +50 -42
- package/data/packages/pulse.json +8 -8
- package/data/packages/refine.json +3 -3
- package/data/packages/ripple.json +4 -4
- package/data/packages/rune.json +9 -9
- package/data/packages/sandbox.json +4 -4
- package/data/packages/scout.json +7 -7
- package/data/packages/scroll.json +10 -10
- package/data/packages/sentinel.json +4 -4
- package/data/packages/sigil.json +6 -6
- package/data/packages/sourcerer.json +4 -4
- package/data/packages/spell.json +9 -9
- package/data/packages/tandem.json +5 -5
- package/data/packages/tavern.json +12 -12
- package/data/packages/tempo.json +4 -4
- package/data/packages/vault.json +13 -13
- package/data/packages/ward.json +9 -9
- package/data/packages/wayfinder.json +17 -17
- package/data/refine.json +259 -87
- package/data/search.json +249 -249
- package/dist/http.js +2 -2
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/refine.d.ts +1 -1
- package/dist/refine.js +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/tools/refine.js +1 -1
- package/package.json +2 -2
- package/skills/vielzeug/SKILL.md +57 -57
package/data/packages/flux.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"apiSource": "export { toAsyncIterable } from './async.js';\nexport type { Channel, ChannelOptions } from './channel.js';\nexport { createChannel } from './channel.js';\nexport { fromStore, fromSubscribe, stream } from './core.js';\nexport { FluxCapacityError, FluxEmptyError, FluxError, FluxTimeoutError } from './errors.js';\nexport { combineLatest, concat, merge } from './operators/combination.js';\nexport type { TimerOptions } from './operators/creation.js';\nexport { from, fromEvent, interval, of, timer } from './operators/creation.js';\nexport { debounce, take, takeUntil, timeout } from './operators/filtering.js';\nexport type { FlattenOptions } from './operators/transformation.js';\nexport { concatMap, filter, map, mergeMap, scan, switchMap } from './operators/transformation.js';\nexport type { RetryOptions, ToArrayOptions, ValueOptions } from './operators/utility.js';\nexport { first, last, retry, toArray } from './operators/utility.js';\nexport { pipe } from './pipe.js';\nexport type {\n AsyncIterableOptions,\n Observer,\n Operator,\n OverflowPolicy,\n Producer,\n Sink,\n Stream,\n SubscribeOptions,\n Subscription,\n Teardown,\n} from './types.js';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Flux
|
|
5
|
-
"api": "---\ntitle: Flux — API Reference\ndescription: Complete reference for @vielzeug/flux streams, operators, channels, and structural bridges.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `stream()` | Create cold stream | Lazy | Return one teardown function |\n| `pipe()` | Compose operators | Lazy | Source is first argument |\n| `of()` / `from()` | Convert known values | Sync / mixed | `from()` promise cannot be aborted |\n| `fromEvent()` | Adapt event target | Async | Unsubscribe removes listener |\n| `interval()` / `timer()` | Create timed values | Async | Use `take()` or unsubscribe for intervals |\n| `map()` / `filter()` / `scan()` | Transform values | Sync | Callback throws terminate stream |\n| `switchMap()` / `mergeMap()` / `concatMap()` | Flatten streams | Mixed | `mergeMap()` concurrency and queue are bounded; `concatMap()` queue is bounded |\n| `take()` / `takeUntil()` | Stop values | Mixed | Notifier emission completes output |\n| `debounce()` / `timeout()` / `retry()` | Control time and failures | Async | `timeout()` measures inactivity |\n| `merge()` / `concat()` / `combineLatest()` | Combine streams | Mixed | `combineLatest()` waits for every source |\n| `toArray()` / `first()` / `last()` | Consume finite values | Async | Bound `toArray()` with `maxItems` |\n| `toAsyncIterable()` | Use `for await` | Async | Capacity and overflow required |\n| `createChannel()` | Imperative multicast boundary | Sync | Dispose to complete subscribers |\n| `fromStore()` | Bridge snapshot-based state | Lazy | Snapshot and subscribe are both required |\n| `fromSubscribe()` | Bridge callback-delivered values | Lazy | Registration must return teardown |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/flux` | Core streams, operators, consumers, errors, types, and `createChannel()` |\n| `@vielzeug/flux/async` | `toAsyncIterable()` only |\n\n## Core\n\n### `stream()`\n\n```ts\nstream<T>(producer: Producer<T>): Stream<T>\n```\n\nCreates cold reusable work. Producer runs once for every subscription.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `producer` | `Producer<T>` | Emits through sink and returns optional teardown |\n\n**Returns:** `Stream<T>`.\n\n```ts\nimport { stream } from '@vielzeug/flux';\n\nconst ticks = stream<number>((sink) => {\n const id = setInterval(() => sink.next(Date.now()), 1_000);\n return () => clearInterval(id);\n});\n```\n\n---\n\n### `pipe()`\n\n```ts\npipe<Input, Operators>(source: Stream<Input>, ...operators: Operators): Stream<Output>\n```\n\nApplies operators left to right while inferring output value type.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `source` | `Stream<Input>` | Source stream |\n| `operators` | `Operator[]` | Operators applied in order |\n\n**Returns:** transformed `Stream<Output>`.\n\n```ts\nimport { map, of, pipe } from '@vielzeug/flux';\n\nconst labels = pipe(of(1, 2), map((value) => `#${value}`));\n```\n\n## Creation\n\n### `of()`\n\n```ts\nof<T>(...values: T[]): Stream<T>\n```\n\nEmits every value synchronously, then completes.\n\n```ts\nimport { of } from '@vielzeug/flux';\n\nof(1, 2, 3).subscribe(console.log);\n```\n\n---\n\n### `from()`\n\n```ts\nfrom<T>(source: Iterable<T> | AsyncIterable<T> | Promise<T>): Stream<T>\n```\n\nConverts iterable, async iterable, or promise into a stream. Cancellation stops iterable consumption and calls `return()` when available.\n\n```ts\nimport { from } from '@vielzeug/flux';\n\nfrom(Promise.resolve('ready')).subscribe({ error: console.error, next: console.log });\n```\n\n---\n\n### `fromEvent()`\n\n```ts\nfromEvent<T = Event>(\n target: {\n addEventListener(type: string, listener: (event: T) => void): void;\n removeEventListener(type: string, listener: (event: T) => void): void;\n },\n type: string,\n): Stream<T>\n```\n\nEmits target events until subscription ends.\n\n```ts\nimport { fromEvent } from '@vielzeug/flux';\n\nfromEvent<MouseEvent>(document, 'click').subscribe(console.log);\n```\n\n---\n\n### `interval()`\n\n```ts\ninterval(every: number): Stream<number>\n```\n\nEmits incrementing values starting at zero.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `every` | `number` | Interval duration from 0 through 2,147,483,647 milliseconds |\n\n---\n\n### `timer()`\n\n```ts\ntimer(options: TimerOptions): Stream<number>\n```\n\nEmits zero after `delay`; optionally continues at `interval`.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `delay` | `number` | Initial delay from 0 through 2,147,483,647 milliseconds |\n| `interval` | `number` | Optional repeat duration from 0 through 2,147,483,647 milliseconds |\n\n## Transformation Operators\n\n### `map()`\n\n```ts\nmap<A, B>(project: (value: A) => B): Operator<A, B>\n```\n\nMaps every value. A thrown callback error terminates output.\n\n---\n\n### `filter()`\n\n```ts\nfilter<T>(predicate: (value: T) => boolean): Operator<T, T>\n```\n\nForwards values matching predicate.\n\n---\n\n### `scan()`\n\n```ts\nscan<T, A>(reducer: (state: A, value: T) => A, initial: A): Operator<T, A>\n```\n\nEmits accumulated state after every source value.\n\n---\n\n### `switchMap()`\n\n```ts\nswitchMap<A, B>(project: (value: A) => Stream<B>): Operator<A, B>\n```\n\nCancels previous inner stream when source emits.\n\n---\n\n### `mergeMap()`\n\n```ts\nmergeMap<A, B>(project: (value: A) => Stream<B>, options: FlattenOptions): Operator<A, B>\n```\n\nRuns inner streams with bounded concurrency and queue capacity. Exceeding capacity errors output with `FluxCapacityError`.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `concurrency` | `number \\| Infinity` | Maximum concurrently active inner subscriptions |\n| `capacity` | `number` | Positive maximum queued source values (required when `concurrency` is finite) |\n\n---\n\n### `concatMap()`\n\n```ts\nconcatMap<A, B>(project: (value: A) => Stream<B>, options: { capacity: number }): Operator<A, B>\n```\n\nRuns inner streams in order (`concurrency: 1`). Exceeding capacity errors output with `FluxCapacityError`.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `capacity` | `number` | Positive maximum queued source values |\n\n## Control Operators\n\n### `take()`\n\n```ts\ntake<T>(count: number): Operator<T, T>\n```\n\nForwards `count` values, cancels upstream, then completes. Count must be non-negative integer.\n\n---\n\n### `takeUntil()`\n\n```ts\ntakeUntil<T>(notifier: AbortSignal | Stream<unknown>): Operator<T, T>\n```\n\nCompletes when notifier aborts or emits.\n\n---\n\n### `debounce()`\n\n```ts\ndebounce<T>(duration: number): Operator<T, T>\n```\n\nEmits latest value after configured silence. Pending value flushes on source completion.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `duration` | `number` | Silence duration from 0 through 2,147,483,647 milliseconds |\n\n---\n\n### `timeout()`\n\n```ts\ntimeout<T>(duration: number): Operator<T, T>\n```\n\nErrors with `FluxTimeoutError` when source is silent too long.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `duration` | `number` | Inactivity duration from 0 through 2,147,483,647 milliseconds |\n\n---\n\n### `retry()`\n\n```ts\nretry<T>(options: RetryOptions): Operator<T, T>\n```\n\nResubscribes after source errors until attempts are exhausted.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `times` | `number` | Non-negative retry count |\n| `delay` | `number \\| (attempt: number) => number` | Optional delay/backoff returning 0 through 2,147,483,647 milliseconds |\n\n## Combination\n\n### `merge()`\n\n```ts\nmerge<T>(...sources: Stream<T>[]): Stream<T>\n```\n\nForwards values from all sources and completes after every source completes.\n\n---\n\n### `concat()`\n\n```ts\nconcat<T>(...sources: Stream<T>[]): Stream<T>\n```\n\nSubscribes to each source only after previous source completes.\n\n---\n\n### `combineLatest()`\n\n```ts\ncombineLatest<T extends readonly Stream<unknown>[]>(...sources: T): Stream<{ [K in keyof T]: T[K] extends Stream<infer V> ? V : never }>\n```\n\nEmits latest tuple after every source emits once. Completes without emission when a source completes before first value.\n\n## Value Consumers\n\n### `toArray()`\n\n```ts\ntoArray<T>(source: Stream<T>, options: ToArrayOptions): Promise<T[]>\n```\n\nCollects finite output. Rejects on source error, the supplied signal's abort reason, or `maxItems` overflow.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `maxItems` | `number` | Non-negative maximum toArrayed values |\n| `signal` | `AbortSignal` | Optional cancellation signal |\n\n---\n\n### `first()`\n\n```ts\nfirst<T>(source: Stream<T>, options?: ValueOptions<T>): Promise<T>\n```\n\nResolves first value and cancels source. Rejects on source error, the supplied signal's abort reason, or empty completion (`FluxEmptyError`); pass `defaultValue` to resolve instead.\n\n---\n\n### `last()`\n\n```ts\nlast<T>(source: Stream<T>, options?: ValueOptions<T>): Promise<T>\n```\n\nResolves last value on completion. Rejects on source error, the supplied signal's abort reason, or empty completion (`FluxEmptyError`); pass `defaultValue` to resolve instead.\n\n## Async Conversion\n\n### `toAsyncIterable()`\n\n```ts\ntoAsyncIterable<T>(source: Stream<T>, options: AsyncIterableOptions): AsyncIterable<T>\n```\n\nConverts a push stream to an async iterable with a bounded queue. External cancellation rejects pending iteration with `signal.reason`; explicit iterator `return()` completes normally. Overflow `\"error\"` rejects with `FluxCapacityError`; unsupported policies throw `RangeError` before subscription.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `capacity` | `number` | Positive queue capacity |\n| `overflow` | `OverflowPolicy` | `error`, `drop-oldest`, or `drop-newest` |\n| `signal` | `AbortSignal` | Optional cancellation signal |\n\n## Channels\n\n### `createChannel()`\n\n```ts\ncreateChannel<T>(options?: ChannelOptions<T>): Channel<T>\n```\n\nCreates an imperative multicast boundary. Disposal completes subscribers and releases replay and pending values.\n\n> **`initial` + `replay` interaction:** When `initial` is set and `replay` is omitted, `replay` defaults to `1` so the initial value is retained. Setting `replay: 0` with `initial` throws `RangeError` — the initial value would be immediately dropped.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `initial` | `T` | Optional initial replay value |\n| `replay` | `number` | Non-negative retained value count |\n\n## Structural adapters\n\n### `fromStore()`\n\n```ts\nfromStore<T>(source: {\n getSnapshot(): T;\n subscribe(listener: () => void): () => void;\n}): Stream<T>\n```\n\nBridges snapshot-based state without package-specific dependencies. It emits the current snapshot, subscribes, rechecks for a subscription-time change, and reads the latest snapshot after each notification. Snapshot failure terminates without subscribing.\n\n### `fromSubscribe()`\n\n```ts\nfromSubscribe<T>(subscribe: (listener: (value: T) => void) => () => void): Stream<T>\n```\n\nBridges callback-delivered event values. Registration runs once per Flux subscription and its teardown runs when that subscription closes.\n\n## Types\n\n```ts\ntype Teardown = () => void;\n\ntype Subscription = {\n [Symbol.dispose](): void;\n readonly closed: boolean;\n unsubscribe(): void;\n};\n\ntype Observer<T> = {\n complete?: () => void;\n error?: (reason: unknown) => void;\n next: (value: T) => void;\n};\n\ntype SubscribeOptions = { signal?: AbortSignal };\n\ntype Sink<T> = {\n complete(): void;\n error(reason: unknown): void;\n next(value: T): void;\n};\n\ntype Producer<T> = (sink: Sink<T>, signal: AbortSignal) => Teardown | undefined;\ntype Operator<A = unknown, B = unknown> = (source: Stream<A>) => Stream<B>;\n\ninterface Stream<T> {\n subscribe(observer: Observer<T> | ((value: T) => void), options?: SubscribeOptions): Subscription;\n}\n\ntype OverflowPolicy = 'drop-newest' | 'drop-oldest' | 'error';\ntype AsyncIterableOptions = { capacity: number; overflow: OverflowPolicy; signal?: AbortSignal };\ntype TimerOptions = { delay: number; interval?: number };\ntype FlattenOptions = { concurrency: number; capacity: number };\ntype RetryOptions = { times: number; delay?: number | ((attempt: number) => number) };\ntype ToArrayOptions = { maxItems: number; signal?: AbortSignal };\ntype ValueOptions<T> = { signal?: AbortSignal; defaultValue?: T };\ntype ChannelOptions<T> = { initial?: T; replay?: number };\ntype Channel<T> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n send(value: T): void;\n readonly stream: Stream<T>;\n};\n```\n\n## Errors\n\n### `FluxError`\n\nBase Flux error. Use `instanceof FluxError` to narrow unknown values.\n\n### `FluxTimeoutError`\n\nRaised by `timeout()`. `ms` contains configured inactivity duration.\n\n### `FluxEmptyError`\n\nRaised by `first()` and `last()` when the source completes without emitting any value.\n\n### `FluxCapacityError`\n\nRaised when a bounded buffer (`mergeMap`, `concatMap`, `toArray`, async iteration) overflows. `capacity` contains the configured limit.\n",
|
|
6
|
-
"usage": "---\ntitle: Flux
|
|
7
|
-
"examples": "---\ntitle: Flux
|
|
4
|
+
"index": "---\ntitle: 'Flux: Explicit push streams for TypeScript'\ndescription: Reusable push streams with subscription-owned cancellation, bounded buffering, channels, and dependency-free structural bridges.\npackage: flux\ncategory: reactive\nkeywords: [streams, reactive, operators, cancellation, buffering, channels]\nrelated: [ripple, herald, pulse, sourcerer, courier]\nexports: [stream, fromStore, fromSubscribe, pipe, of, from, fromEvent, interval, timer, map, filter, scan, switchMap, mergeMap, concatMap, take, takeUntil, debounce, timeout, merge, concat, combineLatest, retry, toArray, first, last, toAsyncIterable]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"flux\" />\n\n## Why Flux?\n\nUse Flux when an API pushes many values over time and consumers need independent cancellation. Streams describe reusable work; subscriptions own cleanup. Explicit queue capacity keeps async iteration from silently growing memory.\n\n```ts\n// Before\nconst controller = new AbortController();\nconst render = (value: string) => console.log(value);\nconst handler = (event: Event) => render((event.target as HTMLInputElement).value);\ninput.addEventListener('input', handler);\nsetTimeout(() => controller.abort(), 5_000);\n\n// After\nimport { fromEvent, map, pipe, takeUntil } from '@vielzeug/flux';\n\nconst updates = pipe(\n fromEvent<InputEvent>(input, 'input'),\n map((event) => (event.target as HTMLInputElement).value),\n takeUntil(controller.signal),\n);\n\nupdates.subscribe({ error: console.error, next: render });\n```\n\n| Feature | Flux | RxJS | TC39 Observable |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"flux\" type=\"size\" /> | Varies by imported operators | Native proposal / polyfill |\n| Runtime dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Subscription-owned cancellation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Explicit async queue policy | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Operator-dependent | No standard policy |\n| Vielzeug adapters | Ripple, Courier, Herald, Pulse | Manual adapters | Manual adapters |\n\n<div class=\"decision-callout\">\n\n**Use Flux when** you need a small TypeScript stream primitive, explicit cancellation, and first-party Vielzeug adapters.\n\n**Consider RxJS when** you need its larger operator catalog or third-party Observable integrations.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/flux\n```\n\n```sh [npm]\nnpm install @vielzeug/flux\n```\n\n```sh [yarn]\nyarn add @vielzeug/flux\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { toArray, interval, map, pipe, take } from '@vielzeug/flux';\n\nconst firstThree = pipe(\n interval(100),\n map((value) => value * 2),\n take(3),\n);\n\ntry {\n console.log(await toArray(firstThree, { maxItems: 3 })); // [0, 2, 4]\n} catch (reason) {\n console.error('Stream failed', reason);\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `stream()`: define cold reusable work with one teardown function\n- `pipe()`: compose any number of typed operators\n- `Subscription`: own cancellation through `unsubscribe()` or `AbortSignal`\n- `createChannel()`: mutable multicast state with bounded replay\n- `toAsyncIterable()`: explicit capacity and overflow policy for pull consumers\n- `retry()`: retry failures with optional backoff\n- `fromStore()`: bridge snapshot-based state such as Ripple signals or Sourcerer queries\n- `fromSubscribe()`: bridge callback-delivered Herald or Pulse events\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/): bridge snapshot state with `fromStore()`.\n- [Courier](/courier/): compose transport promises with `from()` or custom producers.\n- [Herald](/herald/): bridge explicit subscriptions with `stream()`.\n- [Pulse](/pulse/): bridge events with `fromSubscribe()` and presence snapshots with `fromStore()`.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: 'Flux: API Reference'\ndescription: Complete reference for @vielzeug/flux streams, operators, channels, and structural bridges.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `stream()` | Create cold stream | Lazy | Return one teardown function |\n| `pipe()` | Compose operators | Lazy | Source is first argument |\n| `of()` / `from()` | Convert known values | Sync / mixed | `from()` promise cannot be aborted |\n| `fromEvent()` | Adapt event target | Async | Unsubscribe removes listener |\n| `interval()` / `timer()` | Create timed values | Async | Use `take()` or unsubscribe for intervals |\n| `map()` / `filter()` / `scan()` | Transform values | Sync | Callback throws terminate stream |\n| `switchMap()` / `mergeMap()` / `concatMap()` | Flatten streams | Mixed | `mergeMap()` concurrency and queue are bounded; `concatMap()` queue is bounded |\n| `take()` / `takeUntil()` | Stop values | Mixed | Notifier emission completes output |\n| `debounce()` / `timeout()` / `retry()` | Control time and failures | Async | `timeout()` measures inactivity |\n| `merge()` / `concat()` / `combineLatest()` | Combine streams | Mixed | `combineLatest()` waits for every source |\n| `toArray()` / `first()` / `last()` | Consume finite values | Async | Bound `toArray()` with `maxItems` |\n| `toAsyncIterable()` | Use `for await` | Async | Capacity and overflow required |\n| `createChannel()` | Imperative multicast boundary | Sync | Dispose to complete subscribers |\n| `fromStore()` | Bridge snapshot-based state | Lazy | Snapshot and subscribe are both required |\n| `fromSubscribe()` | Bridge callback-delivered values | Lazy | Registration must return teardown |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/flux` | Core streams, operators, consumers, errors, types, and `createChannel()` |\n| `@vielzeug/flux/async` | `toAsyncIterable()` only |\n\n## Core\n\n### `stream()`\n\n```ts\nstream<T>(producer: Producer<T>): Stream<T>\n```\n\nCreates cold reusable work. Producer runs once for every subscription.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `producer` | `Producer<T>` | Emits through sink and returns optional teardown |\n\n**Returns:** `Stream<T>`.\n\n```ts\nimport { stream } from '@vielzeug/flux';\n\nconst ticks = stream<number>((sink) => {\n const id = setInterval(() => sink.next(Date.now()), 1_000);\n return () => clearInterval(id);\n});\n```\n\n---\n\n### `pipe()`\n\n```ts\npipe<Input, Operators>(source: Stream<Input>, ...operators: Operators): Stream<Output>\n```\n\nApplies operators left to right while inferring output value type.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `source` | `Stream<Input>` | Source stream |\n| `operators` | `Operator[]` | Operators applied in order |\n\n**Returns:** transformed `Stream<Output>`.\n\n```ts\nimport { map, of, pipe } from '@vielzeug/flux';\n\nconst labels = pipe(of(1, 2), map((value) => `#${value}`));\n```\n\n## Creation\n\n### `of()`\n\n```ts\nof<T>(...values: T[]): Stream<T>\n```\n\nEmits every value synchronously, then completes.\n\n```ts\nimport { of } from '@vielzeug/flux';\n\nof(1, 2, 3).subscribe(console.log);\n```\n\n---\n\n### `from()`\n\n```ts\nfrom<T>(source: Iterable<T> | AsyncIterable<T> | Promise<T>): Stream<T>\n```\n\nConverts iterable, async iterable, or promise into a stream. Cancellation stops iterable consumption and calls `return()` when available.\n\n```ts\nimport { from } from '@vielzeug/flux';\n\nfrom(Promise.resolve('ready')).subscribe({ error: console.error, next: console.log });\n```\n\n---\n\n### `fromEvent()`\n\n```ts\nfromEvent<T = Event>(\n target: {\n addEventListener(type: string, listener: (event: T) => void): void;\n removeEventListener(type: string, listener: (event: T) => void): void;\n },\n type: string,\n): Stream<T>\n```\n\nEmits target events until subscription ends.\n\n```ts\nimport { fromEvent } from '@vielzeug/flux';\n\nfromEvent<MouseEvent>(document, 'click').subscribe(console.log);\n```\n\n---\n\n### `interval()`\n\n```ts\ninterval(every: number): Stream<number>\n```\n\nEmits incrementing values starting at zero.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `every` | `number` | Interval duration from 0 through 2,147,483,647 milliseconds |\n\n---\n\n### `timer()`\n\n```ts\ntimer(options: TimerOptions): Stream<number>\n```\n\nEmits zero after `delay`; optionally continues at `interval`.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `delay` | `number` | Initial delay from 0 through 2,147,483,647 milliseconds |\n| `interval` | `number` | Optional repeat duration from 0 through 2,147,483,647 milliseconds |\n\n## Transformation Operators\n\n### `map()`\n\n```ts\nmap<A, B>(project: (value: A) => B): Operator<A, B>\n```\n\nMaps every value. A thrown callback error terminates output.\n\n---\n\n### `filter()`\n\n```ts\nfilter<T>(predicate: (value: T) => boolean): Operator<T, T>\n```\n\nForwards values matching predicate.\n\n---\n\n### `scan()`\n\n```ts\nscan<T, A>(reducer: (state: A, value: T) => A, initial: A): Operator<T, A>\n```\n\nEmits accumulated state after every source value.\n\n---\n\n### `switchMap()`\n\n```ts\nswitchMap<A, B>(project: (value: A) => Stream<B>): Operator<A, B>\n```\n\nCancels previous inner stream when source emits.\n\n---\n\n### `mergeMap()`\n\n```ts\nmergeMap<A, B>(project: (value: A) => Stream<B>, options: FlattenOptions): Operator<A, B>\n```\n\nRuns inner streams with bounded concurrency and queue capacity. Exceeding capacity errors output with `FluxCapacityError`.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `concurrency` | `number \\| Infinity` | Maximum concurrently active inner subscriptions |\n| `capacity` | `number` | Positive maximum queued source values (required when `concurrency` is finite) |\n\n---\n\n### `concatMap()`\n\n```ts\nconcatMap<A, B>(project: (value: A) => Stream<B>, options: { capacity: number }): Operator<A, B>\n```\n\nRuns inner streams in order (`concurrency: 1`). Exceeding capacity errors output with `FluxCapacityError`.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `capacity` | `number` | Positive maximum queued source values |\n\n## Control Operators\n\n### `take()`\n\n```ts\ntake<T>(count: number): Operator<T, T>\n```\n\nForwards `count` values, cancels upstream, then completes. Count must be non-negative integer.\n\n---\n\n### `takeUntil()`\n\n```ts\ntakeUntil<T>(notifier: AbortSignal | Stream<unknown>): Operator<T, T>\n```\n\nCompletes when notifier aborts or emits.\n\n---\n\n### `debounce()`\n\n```ts\ndebounce<T>(duration: number): Operator<T, T>\n```\n\nEmits latest value after configured silence. Pending value flushes on source completion.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `duration` | `number` | Silence duration from 0 through 2,147,483,647 milliseconds |\n\n---\n\n### `timeout()`\n\n```ts\ntimeout<T>(duration: number): Operator<T, T>\n```\n\nErrors with `FluxTimeoutError` when source is silent too long.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `duration` | `number` | Inactivity duration from 0 through 2,147,483,647 milliseconds |\n\n---\n\n### `retry()`\n\n```ts\nretry<T>(options: RetryOptions): Operator<T, T>\n```\n\nResubscribes after source errors until attempts are exhausted.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `times` | `number` | Non-negative retry count |\n| `delay` | `number \\| (attempt: number) => number` | Optional delay/backoff returning 0 through 2,147,483,647 milliseconds |\n\n## Combination\n\n### `merge()`\n\n```ts\nmerge<T>(...sources: Stream<T>[]): Stream<T>\n```\n\nForwards values from all sources and completes after every source completes.\n\n---\n\n### `concat()`\n\n```ts\nconcat<T>(...sources: Stream<T>[]): Stream<T>\n```\n\nSubscribes to each source only after previous source completes.\n\n---\n\n### `combineLatest()`\n\n```ts\ncombineLatest<T extends readonly Stream<unknown>[]>(...sources: T): Stream<{ [K in keyof T]: T[K] extends Stream<infer V> ? V : never }>\n```\n\nEmits latest tuple after every source emits once. Completes without emission when a source completes before first value.\n\n## Value Consumers\n\n### `toArray()`\n\n```ts\ntoArray<T>(source: Stream<T>, options: ToArrayOptions): Promise<T[]>\n```\n\nCollects finite output. Rejects on source error, the supplied signal's abort reason, or `maxItems` overflow.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `maxItems` | `number` | Non-negative maximum toArrayed values |\n| `signal` | `AbortSignal` | Optional cancellation signal |\n\n---\n\n### `first()`\n\n```ts\nfirst<T>(source: Stream<T>, options?: ValueOptions<T>): Promise<T>\n```\n\nResolves first value and cancels source. Rejects on source error, the supplied signal's abort reason, or empty completion (`FluxEmptyError`); pass `defaultValue` to resolve instead.\n\n---\n\n### `last()`\n\n```ts\nlast<T>(source: Stream<T>, options?: ValueOptions<T>): Promise<T>\n```\n\nResolves last value on completion. Rejects on source error, the supplied signal's abort reason, or empty completion (`FluxEmptyError`); pass `defaultValue` to resolve instead.\n\n## Async Conversion\n\n### `toAsyncIterable()`\n\n```ts\ntoAsyncIterable<T>(source: Stream<T>, options: AsyncIterableOptions): AsyncIterable<T>\n```\n\nConverts a push stream to an async iterable with a bounded queue. External cancellation rejects pending iteration with `signal.reason`; explicit iterator `return()` completes normally. Overflow `\"error\"` rejects with `FluxCapacityError`; unsupported policies throw `RangeError` before subscription.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `capacity` | `number` | Positive queue capacity |\n| `overflow` | `OverflowPolicy` | `error`, `drop-oldest`, or `drop-newest` |\n| `signal` | `AbortSignal` | Optional cancellation signal |\n\n## Channels\n\n### `createChannel()`\n\n```ts\ncreateChannel<T>(options?: ChannelOptions<T>): Channel<T>\n```\n\nCreates an imperative multicast boundary. Disposal completes subscribers and releases replay and pending values.\n\n> **`initial` + `replay` interaction:** When `initial` is set and `replay` is omitted, `replay` defaults to `1` so the initial value is retained. Setting `replay: 0` with `initial` throws `RangeError`: the initial value would be immediately dropped.\n\n| Option | Type | Description |\n| --- | --- | --- |\n| `initial` | `T` | Optional initial replay value |\n| `replay` | `number` | Non-negative retained value count |\n\n## Structural adapters\n\n### `fromStore()`\n\n```ts\nfromStore<T>(source: {\n getSnapshot(): T;\n subscribe(listener: () => void): () => void;\n}): Stream<T>\n```\n\nBridges snapshot-based state without package-specific dependencies. It emits the current snapshot, subscribes, rechecks for a subscription-time change, and reads the latest snapshot after each notification. Snapshot failure terminates without subscribing.\n\n### `fromSubscribe()`\n\n```ts\nfromSubscribe<T>(subscribe: (listener: (value: T) => void) => () => void): Stream<T>\n```\n\nBridges callback-delivered event values. Registration runs once per Flux subscription and its teardown runs when that subscription closes.\n\n## Types\n\n```ts\ntype Teardown = () => void;\n\ntype Subscription = {\n [Symbol.dispose](): void;\n readonly closed: boolean;\n unsubscribe(): void;\n};\n\ntype Observer<T> = {\n complete?: () => void;\n error?: (reason: unknown) => void;\n next: (value: T) => void;\n};\n\ntype SubscribeOptions = { signal?: AbortSignal };\n\ntype Sink<T> = {\n complete(): void;\n error(reason: unknown): void;\n next(value: T): void;\n};\n\ntype Producer<T> = (sink: Sink<T>, signal: AbortSignal) => Teardown | undefined;\ntype Operator<A = unknown, B = unknown> = (source: Stream<A>) => Stream<B>;\n\ninterface Stream<T> {\n subscribe(observer: Observer<T> | ((value: T) => void), options?: SubscribeOptions): Subscription;\n}\n\ntype OverflowPolicy = 'drop-newest' | 'drop-oldest' | 'error';\ntype AsyncIterableOptions = { capacity: number; overflow: OverflowPolicy; signal?: AbortSignal };\ntype TimerOptions = { delay: number; interval?: number };\ntype FlattenOptions = { concurrency: number; capacity: number };\ntype RetryOptions = { times: number; delay?: number | ((attempt: number) => number) };\ntype ToArrayOptions = { maxItems: number; signal?: AbortSignal };\ntype ValueOptions<T> = { signal?: AbortSignal; defaultValue?: T };\ntype ChannelOptions<T> = { initial?: T; replay?: number };\ntype Channel<T> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n send(value: T): void;\n readonly stream: Stream<T>;\n};\n```\n\n## Errors\n\n### `FluxError`\n\nBase Flux error. Use `instanceof FluxError` to narrow unknown values.\n\n### `FluxTimeoutError`\n\nRaised by `timeout()`. `ms` contains configured inactivity duration.\n\n### `FluxEmptyError`\n\nRaised by `first()` and `last()` when the source completes without emitting any value.\n\n### `FluxCapacityError`\n\nRaised when a bounded buffer (`mergeMap`, `concatMap`, `toArray`, async iteration) overflows. `capacity` contains the configured limit.\n",
|
|
6
|
+
"usage": "---\ntitle: 'Flux: Usage Guide'\ndescription: Create streams, compose operators, consume values safely, and bridge Vielzeug primitives.\n---\n\n[[toc]]\n\n## Basic Usage\n\nDefine one cold stream. Return teardown work from producer. Every subscription runs producer independently.\n\n```ts\nimport { stream } from '@vielzeug/flux';\n\nconst clock = stream<number>((sink) => {\n let value = 0;\n const id = setInterval(() => sink.next(value++), 1_000);\n\n return () => clearInterval(id);\n});\n\nconst subscription = clock.subscribe({\n error: console.error,\n next: console.log,\n});\n\nsubscription.unsubscribe();\n```\n\nPass `AbortSignal` when another owner controls lifetime.\n\n```ts\nconst controller = new AbortController();\nclock.subscribe(console.log, { signal: controller.signal });\ncontroller.abort();\n```\n\n## Compose Streams\n\nPass source first to `pipe()`. Operators retain inferred value types across chains.\n\n```ts\nimport { filter, fromEvent, map, pipe, take } from '@vielzeug/flux';\n\nconst clicks = pipe(\n fromEvent<MouseEvent>(document, 'click'),\n filter((event) => event.button === 0),\n map((event) => ({ x: event.clientX, y: event.clientY })),\n take(10),\n);\n\nclicks.subscribe({\n complete: () => console.log('done'),\n error: console.error,\n next: console.log,\n});\n```\n\nUse `switchMap()` for latest-only work, `mergeMap()` for concurrent work with bounded concurrency and queue capacity, and `concatMap()` for ordered work with bounded queue capacity.\n\n```ts\nimport { from, pipe, retry, switchMap } from '@vielzeug/flux';\n\nconst results = pipe(\n queries,\n switchMap((query) => from(fetch(`/api/search?q=${encodeURIComponent(query)}`).then((response) => response.json()))),\n retry({ times: 2, delay: (attempt) => 250 * (attempt + 1) }),\n);\n```\n\n## Consume Values\n\nUse bounded array conversion for finite streams. `toArray()` rejects once source exceeds `maxItems`.\n\n```ts\nimport { toArray, of } from '@vielzeug/flux';\n\ntry {\n const values = await toArray(of(1, 2, 3), { maxItems: 3 });\n console.log(values);\n} catch (reason) {\n console.error('Collection failed', reason);\n}\n```\n\nUse `first()` for first emission and `last()` for last value before completion. Both reject with `FluxEmptyError` when the source completes without emitting; pass `{ defaultValue }` to resolve instead. Pass `{ signal }` to cancel waiting; cancellation rejects with `signal.reason` (the default reason is `AbortError`).\n\n## Channels\n\nUse channels only at imperative boundaries. Expose `channel.stream` to consumers; keep `send()` near event producer.\n\n```ts\nimport { createChannel } from '@vielzeug/flux';\n\nconst status = createChannel({ initial: 'starting', replay: 1 });\nstatus.stream.subscribe({ error: console.error, next: console.log });\nstatus.send('ready');\nstatus.dispose();\n```\n\nDisposal completes active and future subscribers. Replay retains only configured latest values.\n\n## Async Iteration and Bounds\n\nConvert push stream only when pull syntax is required. Capacity and overflow policy are mandatory.\n\n```ts\nimport { interval, toAsyncIterable } from '@vielzeug/flux';\n\nconst values = toAsyncIterable(interval(100), {\n capacity: 32,\n overflow: 'error',\n});\n\nfor await (const value of values) {\n console.log(value);\n if (value === 2) break;\n}\n```\n\n`return()` from a loop permanently completes its iterator; external signal cancellation rejects with `signal.reason`. Use `drop-oldest` or `drop-newest` only when loss is acceptable.\n\n## Testing\n\nUse fake timers for time operators. Test producer cleanup through returned subscription.\n\n```ts\nimport { expect, it, vi } from 'vitest';\nimport { first, pipe, stream, timeout } from '@vielzeug/flux';\n\nit('fails after inactivity', async () => {\n vi.useFakeTimers();\n const result = first(pipe(stream(() => {}), timeout(500)));\n const expectation = expect(result).rejects.toThrow('Timeout after 500ms');\n\n await vi.advanceTimersByTimeAsync(500);\n await expectation;\n vi.useRealTimers();\n});\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useState } from 'react';\nimport type { Stream } from '@vielzeug/flux';\n\nexport function useStream<T>(source: Stream<T>, initial: T): T {\n const [value, setValue] = useState(initial);\n\n useEffect(() => {\n const subscription = source.subscribe({ error: console.error, next: setValue });\n return () => subscription.unsubscribe();\n }, [source]);\n\n return value;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, ref } from 'vue';\nimport type { Stream } from '@vielzeug/flux';\n\nexport function useStream<T>(source: Stream<T>, initial: T) {\n const value = ref(initial);\n const subscription = source.subscribe({ error: console.error, next: (next) => (value.value = next) });\n\n onUnmounted(() => subscription.unsubscribe());\n\n return value;\n}\n```\n\n```ts [Svelte]\nimport type { Stream } from '@vielzeug/flux';\n\nexport function streamStore<T>(source: Stream<T>, initial: T) {\n return {\n subscribe(run: (value: T) => void) {\n run(initial);\n const subscription = source.subscribe({ error: console.error, next: run });\n return () => subscription.unsubscribe();\n },\n };\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse `fromStore()` for snapshot state and `fromSubscribe()` for callback-delivered event values. Write stream output into the owning state or event layer explicitly.\n\n```ts\nimport { fromStore, fromSubscribe } from '@vielzeug/flux';\n\nconst state = fromStore({\n getSnapshot: () => store.value,\n subscribe: (listener) => store.subscribe(listener),\n});\n\nconst events = fromSubscribe<Message>((listener) => bus.on('message', listener));\n```\n\n## Best Practices\n\n- Return one idempotent producer teardown function.\n- Pass `{ signal }` from component, request, or task owner.\n- Provide `error` when subscription can recover locally.\n- Use `pipe(source, ...)`; never mutate stream definitions.\n- Bound `mergeMap()` concurrency and queue capacity.\n- Bound `concatMap()` queue capacity.\n- Bound `toArray()` with realistic `maxItems`.\n- Choose async iterator overflow policy deliberately.\n- Keep timer-backed durations between 0 and 2,147,483,647 milliseconds.\n- Keep `Channel.send()` at integration boundaries.\n",
|
|
7
|
+
"examples": "---\ntitle: 'Flux: Examples'\ndescription: Practical examples and recipes for @vielzeug/flux.\n---\n\n## Examples\n\n- [Debounced Search Input](./examples/debounce-search.md)\n- [Combining Streams with combineLatest](./examples/combine-streams.md)\n- [Structural State Integration](./examples/signal-integration.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
package/data/packages/focus.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"apiSource": "export { FocusConfigError, FocusError } from './errors.js';\nexport type {\n GridColumns,\n GridKeyResult,\n GridNavigation,\n GridNavigationAction,\n GridNavigationChange,\n GridNavigationOptions,\n} from './grid-navigation.js';\nexport { createGridNavigation } from './grid-navigation.js';\nexport 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, rescueFocus, restoreFocus } from './restore-focus.js';\n",
|
|
2
|
+
"apiSource": "export { FocusConfigError, FocusError } from './errors.js';\nexport type {\n GridColumns,\n GridKeyResult,\n GridNavigation,\n GridNavigationAction,\n GridNavigationChange,\n GridNavigationOptions,\n} from './grid-navigation.js';\nexport { createGridNavigation } from './grid-navigation.js';\nexport 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 { QuickLookGrid, QuickLookGridOptions } from './quick-look-grid.js';\nexport { createQuickLookGrid } from './quick-look-grid.js';\nexport type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';\nexport { captureFocus, rescueFocus, restoreFocus } from './restore-focus.js';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Focus
|
|
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| `createGridNavigation()` | Build two-dimensional arrow-key navigation for grids | Sync | Columns resolve per navigation — responsive grids need a getter |\n| `restoreFocus()` | Restore focus to a target or fallback | Sync | Returns `false` when neither target can receive focus |\n| `rescueFocus()` | Re-home focus after the focused element unmounts | Sync | Returns `false` when focus is already on a real element |\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\nKeys are matched through `matchKey` from `@vielzeug/keymap`, so `keys` overrides accept shortcut patterns with aliases (`esc`, `space`, `up`) and modifiers (`shift+Home`), and modifier state must match exactly: a plain `ArrowDown` binding does not fire on Ctrl+ArrowDown.\n\n---\n\n### `createGridNavigation()`\n\n```ts\nfunction createGridNavigation<T>(options: GridNavigationOptions<T>): GridNavigation<T>;\n```\n\nCreates a two-dimensional keyboard navigation controller: horizontal arrows step one item, vertical arrows step one row (the column count), Home/End jump to the grid's ends.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `GridNavigationOptions<T>` | Item lookup, column resolution, active-index source, key mapping, and wrapping options. |\n\n**Returns:** `GridNavigation<T>`.\n\n**Example**\n\n```ts\nimport { createGridNavigation } from '@vielzeug/focus';\n\nconst grid = createGridNavigation<HTMLElement>({\n columns: 4,\n getActiveIndex: () => tiles().indexOf(document.activeElement as HTMLElement),\n getItems: tiles,\n});\n\nconst result = grid.handleKeydown(event);\nif (result?.change) result.change.item.focus();\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `handleKeydown(event)` | `GridKeyResult<T> \\| null` | Returns handled state for navigation keys; recognized keys are always consumed, even at clamped edges. |\n| `navigate(action)` | `GridNavigationChange<T> \\| null` | Moves programmatically and returns the committed change. |\n| `set(index)` | `void` | Sets the tracked index for grids without `getActiveIndex`. |\n| `reset()` | `void` | Clears the tracked index. |\n| `getIndex()` | `number` | Returns the active index — derived from `getActiveIndex` when provided — or `-1`. |\n| `getActiveItem()` | `T \\| undefined` | Returns the item at the active index. |\n\nUnlike `createListNavigation`, items are not skipped when disabled — skipping in two dimensions would break row alignment. With no active index, forward moves start at the first item and backward moves at the last. `columns` may be a getter resolved on every navigation, so responsive grids can read a media query and measured grids can read the rendered row length. `FocusConfigError` is thrown when `columns` resolves below `1`.\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### `rescueFocus()`\n\n```ts\nfunction rescueFocus(target: FocusTarget, options?: RestoreFocusOptions): boolean;\n```\n\nHands focus to `target` when focus has been lost to the document body — the state left behind when the focused element unmounts mid-swap, where keydown never reaches a handler.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `target` | `FocusTarget` | Element or getter resolved when the rescue runs. |\n| `options` | `RestoreFocusOptions` | Optional lazy fallback and `preventScroll` flag, as in `restoreFocus()`. |\n\n**Returns:** `boolean` — `true` when focus was rescued; `false` when focus is already on a real element or neither target can receive focus.\n\n**Example**\n\n```ts\nimport { rescueFocus } from '@vielzeug/focus';\n\n// After a swap that may have unmounted the focused element.\nrescueFocus(() => dialog.querySelector<HTMLElement>('footer button'));\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[]>>; // shortcut patterns matched via @vielzeug/keymap\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 GridNavigationAction = 'first' | 'last' | 'next' | 'nextRow' | 'prev' | 'prevRow';\n\ntype GridNavigationChange<T> = {\n readonly action: GridNavigationAction;\n readonly event?: KeyboardEvent;\n readonly index: number;\n readonly item: T;\n};\n\ntype GridKeyResult<T> = {\n readonly change: GridNavigationChange<T> | null;\n readonly handled: true;\n};\n\ntype GridColumns = number | (() => number);\n\ntype GridNavigationOptions<T> = {\n columns: GridColumns;\n direction?: MaybeGetter<'ltr' | 'rtl'>;\n disabled?: MaybeGetter<boolean>;\n getActiveIndex?: () => number;\n getItems: () => readonly T[];\n keys?: Partial<Record<GridNavigationAction, readonly string[]>>;\n loop?: boolean;\n};\n\ntype GridNavigation<T> = {\n getActiveItem(): T | undefined;\n getIndex(): number;\n handleKeydown(event: KeyboardEvent): GridKeyResult<T> | null;\n navigate(action: GridNavigationAction): GridNavigationChange<T> | null;\n reset(): void;\n set(index: number): void;\n};\n\ntype FocusTarget = HTMLElement | SVGElement | null | undefined | (() => HTMLElement | SVGElement | null | undefined);\n\ntype RestoreFocusOptions = {\n fallback?: FocusTarget;\n preventScroll?: boolean;\n};\n\ntype CaptureFocusOptions = RestoreFocusOptions & {\n signal?: AbortSignal;\n};\n\ntype FocusRestorer = () => boolean;\n```\n\n`typeahead.delayMs` defaults to `500`; 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| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `FocusError` | Base class for every focus-originated error | `instanceof FocusError` catches any focus error |\n| `FocusConfigError` | Invalid navigation configuration | Extends `FocusError`. Thrown for a key assigned to two actions, a non-positive `typeahead.delayMs`, or grid `columns` resolving below `1` |\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 and are matched through `matchKey` from `@vielzeug/keymap`, so entries accept aliases and modifiers and require exact modifier state; assigning one key to multiple actions throws `FocusConfigError`.\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## Navigate Grids\n\nUse `createGridNavigation()` when items lay out in rows: vertical arrows step one row, horizontal arrows one item, and Home/End jump to the ends. Point `getActiveIndex` at the DOM so focus moved by pointer or Tab stays in sync:\n\n```ts\nimport { createGridNavigation } from '@vielzeug/focus';\n\nconst tiles = () => [...grid.querySelectorAll<HTMLElement>('.tile')];\n\nconst nav = createGridNavigation<HTMLElement>({\n columns: 4,\n getActiveIndex: () => tiles().indexOf(document.activeElement as HTMLElement),\n getItems: tiles,\n});\n\ngrid.addEventListener('keydown', (event) => {\n nav.handleKeydown(event)?.change?.item.focus();\n});\n```\n\n`columns` resolves on every navigation, so responsive grids can read a media query and measured grids can read the rendered row length. Clamping is the default; set `loop: true` to wrap moves around the grid's edges by flat index. Items are never skipped when disabled — skipping in two dimensions would break row alignment.\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\nWhen the focused element unmounts — browsing a dialog whose swap removes the item that held focus — the browser drops focus to `<body>`, where keydown never reaches a handler. Call `rescueFocus()` after the swap to hand focus to a control that survives it:\n\n```ts\nimport { rescueFocus } from '@vielzeug/focus';\n\nconst onAfterSwap = () => {\n rescueFocus(() => footer.querySelector<HTMLElement>('button'));\n};\n```\n\n`rescueFocus()` returns `false` without touching anything when focus is already on a real element, so it is safe to call from both a watcher and a transition's after-leave hook.\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- Rescue focus after swaps that can unmount the focused element.\n- Remove the owning event listener when a widget unmounts.\n",
|
|
7
|
-
"examples": "---\ntitle: Focus
|
|
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, grid navigation, quick look]\nexports: [createListNavigation, createGridNavigation, createQuickLookGrid, captureFocus, restoreFocus, rescueFocus]\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| Grid navigation | Flat only | `createGridNavigation()` |\n| Quick Look grids | Manual Space handling | `createQuickLookGrid()` |\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- `createGridNavigation()`: two-dimensional arrow-key navigation with fixed or measured columns\n- `createQuickLookGrid()`: tile-grid arrow browsing with a Space preview key for card pickers\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()`, `restoreFocus()`, and `rescueFocus()`: explicit return-focus and unmount-rescue 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| `createGridNavigation()` | Build two-dimensional arrow-key navigation for grids | Sync | Columns resolve per navigation: responsive grids need a getter |\n| `createQuickLookGrid()` | Build arrow-key tile browsing with a preview key for card grids | Sync | The preview key consumes Space: the focused button never activates |\n| `restoreFocus()` | Restore focus to a target or fallback | Sync | Returns `false` when neither target can receive focus |\n| `rescueFocus()` | Re-home focus after the focused element unmounts | Sync | Returns `false` when focus is already on a real element |\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\nKeys are matched through `matchKey` from `@vielzeug/keymap`, so `keys` overrides accept shortcut patterns with aliases (`esc`, `space`, `up`) and modifiers (`shift+Home`), and modifier state must match exactly: a plain `ArrowDown` binding does not fire on Ctrl+ArrowDown.\n\n---\n\n### `createGridNavigation()`\n\n```ts\nfunction createGridNavigation<T>(options: GridNavigationOptions<T>): GridNavigation<T>;\n```\n\nCreates a two-dimensional keyboard navigation controller: horizontal arrows step one item, vertical arrows step one row (the column count), Home/End jump to the grid's ends.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `GridNavigationOptions<T>` | Item lookup, column resolution, active-index source, key mapping, and wrapping options. |\n\n**Returns:** `GridNavigation<T>`.\n\n**Example**\n\n```ts\nimport { createGridNavigation } from '@vielzeug/focus';\n\nconst grid = createGridNavigation<HTMLElement>({\n columns: 4,\n getActiveIndex: () => tiles().indexOf(document.activeElement as HTMLElement),\n getItems: tiles,\n});\n\nconst result = grid.handleKeydown(event);\nif (result?.change) result.change.item.focus();\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `handleKeydown(event)` | `GridKeyResult<T> \\| null` | Returns handled state for navigation keys; recognized keys are always consumed, even at clamped edges. |\n| `navigate(action)` | `GridNavigationChange<T> \\| null` | Moves programmatically and returns the committed change. |\n| `set(index)` | `void` | Sets the tracked index for grids without `getActiveIndex`. |\n| `reset()` | `void` | Clears the tracked index. |\n| `getIndex()` | `number` | Returns the active index: derived from `getActiveIndex` when provided, or `-1`. |\n| `getActiveItem()` | `T \\| undefined` | Returns the item at the active index. |\n\nUnlike `createListNavigation`, items are not skipped when disabled: skipping in two dimensions would break row alignment. With no active index, forward moves start at the first item and backward moves at the last. `columns` may be a getter resolved on every navigation, so responsive grids can read a media query and measured grids can read the rendered row length. `FocusConfigError` is thrown when `columns` resolves below `1`.\n\n---\n\n### `createQuickLookGrid()`\n\n```ts\nfunction createQuickLookGrid(options: QuickLookGridOptions): QuickLookGrid;\n```\n\nCreates keyboard browsing over a tile grid with a Quick Look key: arrows and Home/End move focus through the items (delegating to `createGridNavigation()`) and `inspectKey` previews the item under the event instead of activating it: the pair every card picker needs.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `QuickLookGridOptions` | Item lookup, active-index source, column resolution, preview key, wrapping, and inspection callback. |\n\n**Returns:** `QuickLookGrid`.\n\n**Example**\n\n```ts\nimport { createQuickLookGrid } from '@vielzeug/focus';\n\nconst quickLook = createQuickLookGrid({\n getItems: () => [...grid.querySelectorAll<HTMLElement>('.tile')],\n onInspect: (item, index) => previewCard(index),\n});\n\ngrid.addEventListener('keydown', (event) => quickLook.handleKeydown(event));\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `handleKeydown(event)` | `void` | Routes one `keydown`: navigation keys move focus to the reported item, `inspectKey` calls `preventDefault()`/`stopPropagation()` then `onInspect(item, index)`. Unrecognized, already-prevented, composing, modifier, and disabled events pass through. |\n\n`columns` defaults to the rendered row length at the active item (items sharing its top edge), which follows the grid's own responsive wrapping without a media query; supply `columns` to read a track list instead. `getActiveIndex` defaults to the item that is or contains `document.activeElement`; supply it when focus sits on a wrapper around the item. `inspectKey` defaults to `' '` (Space), the platform preview key. The recognizer pairs with a pointer long-press at the call site as the touch counterpart of `onInspect`.\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### `rescueFocus()`\n\n```ts\nfunction rescueFocus(target: FocusTarget, options?: RestoreFocusOptions): boolean;\n```\n\nHands focus to `target` when focus has been lost to the document body: the state left behind when the focused element unmounts mid-swap, where keydown never reaches a handler.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `target` | `FocusTarget` | Element or getter resolved when the rescue runs. |\n| `options` | `RestoreFocusOptions` | Optional lazy fallback and `preventScroll` flag, as in `restoreFocus()`. |\n\n**Returns:** `boolean`: `true` when focus was rescued; `false` when focus is already on a real element or neither target can receive focus.\n\n**Example**\n\n```ts\nimport { rescueFocus } from '@vielzeug/focus';\n\n// After a swap that may have unmounted the focused element.\nrescueFocus(() => dialog.querySelector<HTMLElement>('footer button'));\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[]>>; // shortcut patterns matched via @vielzeug/keymap\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 GridNavigationAction = 'first' | 'last' | 'next' | 'nextRow' | 'prev' | 'prevRow';\n\ntype GridNavigationChange<T> = {\n readonly action: GridNavigationAction;\n readonly event?: KeyboardEvent;\n readonly index: number;\n readonly item: T;\n};\n\ntype GridKeyResult<T> = {\n readonly change: GridNavigationChange<T> | null;\n readonly handled: true;\n};\n\ntype GridColumns = number | (() => number);\n\ntype GridNavigationOptions<T> = {\n columns: GridColumns;\n direction?: MaybeGetter<'ltr' | 'rtl'>;\n disabled?: MaybeGetter<boolean>;\n getActiveIndex?: () => number;\n getItems: () => readonly T[];\n keys?: Partial<Record<GridNavigationAction, readonly string[]>>;\n loop?: boolean;\n};\n\ntype GridNavigation<T> = {\n getActiveItem(): T | undefined;\n getIndex(): number;\n handleKeydown(event: KeyboardEvent): GridKeyResult<T> | null;\n navigate(action: GridNavigationAction): GridNavigationChange<T> | null;\n reset(): void;\n set(index: number): void;\n};\n\ntype QuickLookGridOptions = {\n columns?: GridColumns;\n disabled?: MaybeGetter<boolean>;\n getActiveIndex?: () => number;\n getItems: () => readonly HTMLElement[];\n inspectKey?: string;\n loop?: boolean;\n onInspect: (item: HTMLElement, index: number) => void;\n};\n\ntype QuickLookGrid = {\n handleKeydown(event: KeyboardEvent): void;\n};\n\ntype FocusTarget = HTMLElement | SVGElement | null | undefined | (() => HTMLElement | SVGElement | null | undefined);\n\ntype RestoreFocusOptions = {\n fallback?: FocusTarget;\n preventScroll?: boolean;\n};\n\ntype CaptureFocusOptions = RestoreFocusOptions & {\n signal?: AbortSignal;\n};\n\ntype FocusRestorer = () => boolean;\n```\n\n`typeahead.delayMs` defaults to `500`; 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| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `FocusError` | Base class for every focus-originated error | `instanceof FocusError` catches any focus error |\n| `FocusConfigError` | Invalid navigation configuration | Extends `FocusError`. Thrown for a key assigned to two actions, a non-positive `typeahead.delayMs`, or grid `columns` resolving below `1` |\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 and are matched through `matchKey` from `@vielzeug/keymap`, so entries accept aliases and modifiers and require exact modifier state; assigning one key to multiple actions throws `FocusConfigError`.\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## Navigate Grids\n\nUse `createGridNavigation()` when items lay out in rows: vertical arrows step one row, horizontal arrows one item, and Home/End jump to the ends. Point `getActiveIndex` at the DOM so focus moved by pointer or Tab stays in sync:\n\n```ts\nimport { createGridNavigation } from '@vielzeug/focus';\n\nconst tiles = () => [...grid.querySelectorAll<HTMLElement>('.tile')];\n\nconst nav = createGridNavigation<HTMLElement>({\n columns: 4,\n getActiveIndex: () => tiles().indexOf(document.activeElement as HTMLElement),\n getItems: tiles,\n});\n\ngrid.addEventListener('keydown', (event) => {\n nav.handleKeydown(event)?.change?.item.focus();\n});\n```\n\n`columns` resolves on every navigation, so responsive grids can read a media query and measured grids can read the rendered row length. Clamping is the default; set `loop: true` to wrap moves around the grid's edges by flat index. Items are never skipped when disabled: skipping in two dimensions would break row alignment.\n\n## Browse Card Grids with Quick Look\n\nUse `createQuickLookGrid()` for card pickers, where arrows walk the tiles and Space previews the focused card instead of activating it: the WAI-ARIA Quick Look convention. The primitive pairs the navigation with the preview key, consumes the Space press so the focused button never toggles, and reports the item through `onInspect`:\n\n```ts\nimport { createQuickLookGrid } from '@vielzeug/focus';\n\nconst tiles = () => [...grid.querySelectorAll<HTMLElement>('.tile')];\n\nconst quickLook = createQuickLookGrid({\n getItems: tiles,\n onInspect: (_item, index) => openPreview(index),\n});\n\ngrid.addEventListener('keydown', (event) => quickLook.handleKeydown(event));\n```\n\nLeave `columns` unset to measure the rendered row length at the focused tile, which follows the grid's own responsive wrapping without a media query. Supply `getActiveIndex` when focus may sit on a wrapper around the item rather than the item itself. Enter keeps activating the focused tile through its own handler: only the preview key is intercepted. A pointer long-press recognizer is the touch counterpart of `onInspect`; pair them at the call site.\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\nWhen the focused element unmounts: browsing a dialog whose swap removes the item that held focus: the browser drops focus to `<body>`, where keydown never reaches a handler. Call `rescueFocus()` after the swap to hand focus to a control that survives it:\n\n```ts\nimport { rescueFocus } from '@vielzeug/focus';\n\nconst onAfterSwap = () => {\n rescueFocus(() => footer.querySelector<HTMLElement>('button'));\n};\n```\n\n`rescueFocus()` returns `false` without touching anything when focus is already on a real element, so it is safe to call from both a watcher and a transition's after-leave hook.\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- Rescue focus after swaps that can unmount the focused element.\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
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
|
@@ -37,6 +37,9 @@
|
|
|
37
37
|
"ListNavigationTypeaheadOptions": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
38
38
|
"MaybeGetter": "export type {\n ListKeyAction,\n ListKeyResult,\n ListNavigation,\n ListNavigationAction,\n ListNavigationChange,\n ListNavigationOptions,\n ListNavigationTypeaheadOptions,\n MaybeGetter,\n} from './list-navigation.js';",
|
|
39
39
|
"createListNavigation": "export { createListNavigation } from './list-navigation.js';",
|
|
40
|
+
"QuickLookGrid": "export type { QuickLookGrid, QuickLookGridOptions } from './quick-look-grid.js';",
|
|
41
|
+
"QuickLookGridOptions": "export type { QuickLookGrid, QuickLookGridOptions } from './quick-look-grid.js';",
|
|
42
|
+
"createQuickLookGrid": "export { createQuickLookGrid } from './quick-look-grid.js';",
|
|
40
43
|
"CaptureFocusOptions": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';",
|
|
41
44
|
"FocusRestorer": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';",
|
|
42
45
|
"FocusTarget": "export type {\n CaptureFocusOptions,\n FocusRestorer,\n FocusTarget,\n RestoreFocusOptions,\n} from './restore-focus.js';",
|
package/data/packages/forge.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
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
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Forge
|
|
5
|
-
"api": "---\ntitle: Forge
|
|
6
|
-
"usage": "---\ntitle: Forge
|
|
7
|
-
"examples": "---\ntitle: Forge
|
|
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
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
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 { GestureConfigError, GestureError } from './errors.js';\nexport {\n createLongPress,\n type LongPress,\n type LongPressDetail,\n type LongPressOptions,\n} from './long-press.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
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Gesture
|
|
5
|
-
"api": "---\ntitle: Gesture
|
|
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## Recognize Long Presses\n\nUse `createLongPress()` for hold interactions — the touch counterpart of a keyboard preview key. `onLongPress` receives the originating pointerdown once the hold outlasts `durationMs`:\n\n```ts\nimport { createLongPress } from '@vielzeug/gesture';\n\nconst hold = createLongPress(grid, {\n durationMs: 500,\n shouldStart: (event) =>\n event.pointerType !== 'mouse' &&\n (event.target as HTMLElement).closest('.tile') !== null,\n onLongPress: (detail) => {\n const tile = (detail.event.target as HTMLElement).closest<HTMLElement>('.tile');\n openPreview(tile?.dataset.id);\n },\n});\n```\n\nMovement beyond `slopPx` cancels back to a normal press, so scrolling never triggers the hold. The click that follows a fired hold is swallowed through a capture-phase listener on `target` — bind `target` to a stable ancestor of the pressed controls so the swallow precedes their click handlers, and gate `shouldStart` to the elements the hold belongs to.\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
|
|
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, long press]\nexports: [createDragGesture, createPanGesture, createLongPress, GestureConfigError]\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- `createLongPress()`: hold recognition with slop cancellation and click swallowing\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| `createLongPress()` | Recognize a primary pointer held in place | Sync | The click that follows a fired hold is swallowed |\n| `DragGesture` / `PanGesture` / `LongPress` | 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| `LongPressOptions` | Configure hold recognition | Sync | `durationMs` and `slopPx` are validated at construction |\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---\n\n### `createLongPress()`\n\n```ts\nfunction createLongPress(target: Element, options?: LongPressOptions): LongPress;\n```\n\nRecognizes a primary pointer held in place: `onLongPress` fires with the originating pointerdown once the hold outlasts `durationMs`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `target` | `Element` | Element that owns pointer admission, document tracking, and click swallowing |\n| `options` | `LongPressOptions` | Hold time, slop distance, admission, callback, and lifecycle configuration |\n\n**Returns:** A lifecycle-owned `LongPress` handle with the same members as the other gestures; `active` is `true` while a hold is pending.\n\n**Example**\n\n```ts\nimport { createLongPress } from '@vielzeug/gesture';\n\nconst hold = createLongPress(list, {\n shouldStart: (event) => event.pointerType !== 'mouse',\n onLongPress: (detail) => {\n const tile = (detail.event.target as HTMLElement).closest('.tile');\n openDetail(tile?.dataset.id);\n },\n});\n```\n\nMovement beyond `slopPx` cancels back to a normal press, and window blur or a hidden document cancels a pending hold. The click that follows a fired hold is swallowed through a capture-phase listener on `target`, so releasing never commits what the hold opened; bind `target` to a stable ancestor of the pressed controls so the swallow precedes their click handlers.\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### Long-press types\n\n```ts\ntype LongPressDetail = Readonly<{\n event: PointerEvent;\n pointerId: number;\n pointerType: string;\n target: Element;\n}>;\n\ntype LongPressOptions = Readonly<{\n durationMs?: number;\n disabled?: boolean | (() => boolean | undefined);\n onLongPress?: (detail: LongPressDetail) => void;\n shouldStart?: (event: PointerEvent) => boolean;\n slopPx?: number;\n signal?: AbortSignal;\n}>;\n```\n\n`durationMs` defaults to `500` and `slopPx` to `8`. `event` is the pointerdown that started the hold, so `event.target` is the pressed element.\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` | N/A | Admission predicate evaluated for the primary pointer before tracking starts |\n| `signal` | N/A | Disposes the handle when the external owner aborts |\n| `onStart` | N/A | Runs once on the activating movement |\n| `onMove` | N/A | Runs on the activating movement and each later matching movement |\n| `onEnd` | N/A | 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\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `GestureError` | Base class for every gesture-originated error | `instanceof GestureError` catches any gesture error |\n| `GestureConfigError` | Invalid option value | Extends `GestureError` |\n\n- Invalid `activationDistance` values throw `GestureConfigError` during construction.\n- Invalid fixed `axis` values throw `GestureConfigError` during construction.\n- Invalid values returned by a dynamic `axis` getter throw `GestureConfigError` 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## Recognize Long Presses\n\nUse `createLongPress()` for hold interactions: the touch counterpart of a keyboard preview key. `onLongPress` receives the originating pointerdown once the hold outlasts `durationMs`:\n\n```ts\nimport { createLongPress } from '@vielzeug/gesture';\n\nconst hold = createLongPress(grid, {\n durationMs: 500,\n shouldStart: (event) =>\n event.pointerType !== 'mouse' &&\n (event.target as HTMLElement).closest('.tile') !== null,\n onLongPress: (detail) => {\n const tile = (detail.event.target as HTMLElement).closest<HTMLElement>('.tile');\n openPreview(tile?.dataset.id);\n },\n});\n```\n\nMovement beyond `slopPx` cancels back to a normal press, so scrolling never triggers the hold. The click that follows a fired hold is swallowed through a capture-phase listener on `target`: bind `target` to a stable ancestor of the pressed controls so the swallow precedes their click handlers, and gate `shouldStart` to the elements the hold belongs to.\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
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|