@vizejs/composable 0.302.0 → 0.303.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ubugeeei
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.d.mts CHANGED
@@ -50,18 +50,47 @@ interface UseAsyncResourceOptions<Data> {
50
50
  }
51
51
  /** Reactive state and controls for an asynchronous loader. */
52
52
  interface AsyncResource<Data, Arguments extends readonly unknown[], Failure> {
53
+ /** Data of the newest successful execution, retained according to `keepData`. */
53
54
  readonly data: Readonly<ShallowRef<Data | undefined>>;
55
+ /** Failure of the newest settled execution, cleared when a new one starts. */
54
56
  readonly error: Readonly<ShallowRef<Failure | undefined>>;
57
+ /** Current lifecycle status, driven only by the newest execution. */
55
58
  readonly status: Readonly<Ref<AsyncResourceStatus>>;
59
+ /** Whether an execution is currently pending. */
56
60
  readonly pending: ComputedRef<boolean>;
61
+ /**
62
+ * Run the loader. The returned promise never rejects: loader failures,
63
+ * cancellation, and supersession are reported as the discriminated result,
64
+ * and stale executions leave the reactive state untouched.
65
+ */
57
66
  readonly execute: (...arguments_: Arguments) => Promise<AsyncResourceExecution<Data, Failure>>;
67
+ /**
68
+ * Abort the active execution and mark the resource cancelled.
69
+ *
70
+ * @param reason Abort reason forwarded to the loader's signal.
71
+ * @default reason DOMException("AbortError")
72
+ * @returns Whether an active execution was cancelled.
73
+ */
58
74
  readonly cancel: (reason?: unknown) => boolean;
75
+ /** Cancel any active execution and restore the initial idle state. */
59
76
  readonly reset: () => void;
60
77
  }
61
78
  /**
62
79
  * Create a scoped, abortable asynchronous resource with latest-result-wins
63
80
  * state. Every execution returns a discriminated result, so cancellation,
64
81
  * supersession, loader failure, and successful `undefined` data stay distinct.
82
+ *
83
+ * When created inside an active reactive scope (and `scope` is enabled), the
84
+ * active execution is aborted when that scope stops; outside a scope,
85
+ * cancellation ownership stays with the caller. The execute promise never
86
+ * rejects — synchronous and asynchronous loader failures both settle into
87
+ * the `"error"` result. Safe during server rendering: no browser globals are
88
+ * read and abort reasons use the runtime-native `DOMException`.
89
+ *
90
+ * @param loader Asynchronous loader receiving the abort context first.
91
+ * @param options Data retention, supersession, and scope behavior.
92
+ * @default options {}
93
+ * @returns Reactive state and controls for the loader.
65
94
  */
66
95
  declare function useAsyncResource<Data, Arguments extends readonly unknown[], Failure = unknown>(loader: (context: AsyncResourceContext, ...arguments_: Arguments) => Promise<Data>, options?: UseAsyncResourceOptions<Data>): AsyncResource<Data, Arguments, Failure>;
67
96
  //#endregion
@@ -87,7 +116,8 @@ interface UseEventListenerOptions {
87
116
  */
88
117
  readonly passive?: boolean;
89
118
  /**
90
- * Stop listening when this signal is aborted.
119
+ * Stop listening when this signal is aborted. An already-aborted signal
120
+ * prevents listening from ever starting.
91
121
  *
92
122
  * @default undefined
93
123
  */
@@ -112,7 +142,9 @@ interface EventListenerControls {
112
142
  /**
113
143
  * Begin listening.
114
144
  *
115
- * @returns Whether a new reactive listener was started.
145
+ * @returns Whether a new reactive watcher was started. `false` while the
146
+ * watcher is already active (including a null-target watcher with no
147
+ * listener attached) and after the abort signal has fired.
116
148
  */
117
149
  readonly start: () => boolean;
118
150
  /** Stop listening. Repeated calls are safe. */
@@ -120,13 +152,23 @@ interface EventListenerControls {
120
152
  }
121
153
  /**
122
154
  * Attach an event listener to a reactive target and clean it up with the
123
- * current reactive scope. Missing targets are valid during server rendering.
155
+ * current reactive scope. Missing targets are valid during server rendering:
156
+ * a `null`/`undefined` target keeps the listener detached until a concrete
157
+ * target appears, so no browser globals are required.
158
+ *
159
+ * The listener is re-attached whenever the reactive target changes and is
160
+ * removed when the owning reactive scope stops, when
161
+ * {@link EventListenerControls.stop} is called, or when the abort signal
162
+ * fires. Outside an active scope, teardown ownership stays with the caller,
163
+ * who must call `stop` explicitly. Errors thrown by a custom target's
164
+ * add/remove methods propagate to the active watcher run.
124
165
  *
125
166
  * @param target Reactive event target.
126
167
  * @param event Event name.
127
168
  * @param listener Typed event listener.
128
169
  * @param options Listener lifecycle and scheduling options.
129
170
  * @default options {}
171
+ * @returns Controls to observe and change the listening state.
130
172
  */
131
173
  declare function useEventListener<Key extends keyof WindowEventMap>(target: MaybeRefOrGetter<Window | null | undefined>, event: Key, listener: (event: WindowEventMap[Key]) => void, options?: UseEventListenerOptions): EventListenerControls;
132
174
  declare function useEventListener<Key extends keyof DocumentEventMap>(target: MaybeRefOrGetter<Document | null | undefined>, event: Key, listener: (event: DocumentEventMap[Key]) => void, options?: UseEventListenerOptions): EventListenerControls;
@@ -151,7 +193,12 @@ interface UseLocaleOptions {
151
193
  */
152
194
  readonly fallback?: Intl.Locale | string;
153
195
  }
154
- /** Reactive locale metadata and cached formatter factories. */
196
+ /**
197
+ * Reactive locale metadata and cached formatter factories.
198
+ *
199
+ * Formatter factories propagate `TypeError` and `RangeError` from the
200
+ * platform `Intl` constructors when the supplied options are invalid.
201
+ */
155
202
  interface LocaleControls {
156
203
  /** Canonical Unicode locale identifier. */
157
204
  readonly locale: ComputedRef<string>;
@@ -174,6 +221,19 @@ interface LocaleControls {
174
221
  * Equivalent formatter options reuse instances. The bounded cache follows the
175
222
  * active locale automatically and prevents repeated constructor overhead in
176
223
  * reactive render paths.
224
+ *
225
+ * Detection is lazy and guarded: the default detector reads
226
+ * `navigator.language` behind a `typeof` check at call time, so importing and
227
+ * calling this during server rendering is safe, and runtimes without a
228
+ * `navigator` resolve to the fallback locale. The composable owns no timers
229
+ * or listeners, so no scope cleanup is required.
230
+ *
231
+ * @param source Reactive locale source. Empty values defer to the detector.
232
+ * @param options Detection and fallback behavior.
233
+ * @default options {}
234
+ * @throws `RangeError` on first read of the reactive values when the winning
235
+ * candidate is not a structurally valid locale identifier.
236
+ * @returns Reactive locale metadata and cached formatter factories.
177
237
  */
178
238
  declare function useLocale(source?: MaybeRefOrGetter<Intl.Locale | string | null | undefined>, options?: UseLocaleOptions): LocaleControls;
179
239
  //#endregion
@@ -201,9 +261,18 @@ interface UseMediaQueryOptions {
201
261
  /**
202
262
  * Evaluate a reactive media query without requiring browser globals.
203
263
  *
264
+ * During server rendering (or whenever no capability host resolves) the ref
265
+ * holds the configured server value and no subscription is created. The
266
+ * change subscription follows the reactive query and host: each
267
+ * re-evaluation removes the previous listener, and the final listener is
268
+ * removed when the owning reactive scope stops. Call inside an active scope
269
+ * so the subscription is released. A host whose matcher throws propagates
270
+ * the error to the active effect run; the browser default never throws.
271
+ *
204
272
  * @param query Reactive media-query source.
205
273
  * @param options Runtime capability and server-rendered fallback.
206
274
  * @default options {}
275
+ * @returns Readonly ref that is `true` while the query matches.
207
276
  */
208
277
  declare function useMediaQuery(query: MaybeRefOrGetter<string>, options?: UseMediaQueryOptions): Readonly<Ref<boolean>>;
209
278
  /** User motion preference exposed by {@link useReducedMotion}. */
@@ -211,8 +280,13 @@ type MotionPreference = "reduce" | "no-preference";
211
280
  /**
212
281
  * Return the reactive user motion preference.
213
282
  *
283
+ * Shares {@link useMediaQuery} semantics: during server rendering the
284
+ * preference is `"no-preference"` unless `ssrValue` is `true`, and the
285
+ * underlying subscription is removed when the owning reactive scope stops.
286
+ *
214
287
  * @param options Runtime capability and server-rendered fallback.
215
288
  * @default options {}
289
+ * @returns Computed preference for `(prefers-reduced-motion: reduce)`.
216
290
  */
217
291
  declare function useReducedMotion(options?: UseMediaQueryOptions): ComputedRef<MotionPreference>;
218
292
  //#endregion
@@ -220,9 +294,492 @@ declare function useReducedMotion(options?: UseMediaQueryOptions): ComputedRef<M
220
294
  /**
221
295
  * Register cleanup in the active reactive scope when one exists.
222
296
  *
297
+ * This is the shared lifecycle primitive of the package: composables hand
298
+ * their teardown here so owned resources are released when the surrounding
299
+ * scope stops.
300
+ *
301
+ * Never throws and is safe during server rendering; no browser globals are
302
+ * read. When no scope is active the cleanup is not registered and disposal
303
+ * ownership stays with the caller.
304
+ *
223
305
  * @param cleanup Cleanup invoked exactly once when the scope is disposed.
224
306
  * @returns Whether the cleanup was registered.
225
307
  */
226
308
  declare function tryOnScopeDispose(cleanup: () => void): boolean;
227
309
  //#endregion
228
- export { AsyncResource, AsyncResourceContext, AsyncResourceExecution, AsyncResourceStatus, EventListenerControls, LocaleControls, MediaQueryHost, MotionPreference, TextDirection, UseAsyncResourceOptions, UseEventListenerOptions, UseLocaleOptions, UseMediaQueryOptions, tryOnScopeDispose, useAsyncResource, useEventListener, useLocale, useMediaQuery, useReducedMotion };
310
+ //#region src/timeout-scheduler.d.ts
311
+ /**
312
+ * Single-shot timer host used by the debounced and throttled state
313
+ * utilities.
314
+ *
315
+ * Implement this interface to integrate a deterministic test clock, a native
316
+ * runtime timer, or an application-owned scheduler. Handles are opaque: the
317
+ * utilities only hand them back to {@link TimeoutScheduler.clearTimeout}.
318
+ * This module declares types only and contributes no runtime code.
319
+ */
320
+ interface TimeoutScheduler {
321
+ /** Starts a single-shot callback and returns its opaque cancellation handle. */
322
+ readonly setTimeout: (callback: () => void, delayMs: number) => unknown;
323
+ /** Cancels a handle previously returned by {@link TimeoutScheduler.setTimeout}. */
324
+ readonly clearTimeout: (handle: unknown) => void;
325
+ }
326
+ //#endregion
327
+ //#region src/use-counter.d.ts
328
+ /** Options for {@link useCounter}. */
329
+ interface UseCounterOptions {
330
+ /**
331
+ * Inclusive lower bound applied to every value the counter takes.
332
+ *
333
+ * @default Number.NEGATIVE_INFINITY
334
+ */
335
+ readonly min?: number;
336
+ /**
337
+ * Inclusive upper bound applied to every value the counter takes.
338
+ *
339
+ * @default Number.POSITIVE_INFINITY
340
+ */
341
+ readonly max?: number;
342
+ }
343
+ /** Reactive controls returned by {@link useCounter}. */
344
+ interface CounterControls {
345
+ /** Current count. Changes only through the controls, never by assignment. */
346
+ readonly count: Readonly<ShallowRef<number>>;
347
+ /** Whether the count currently sits on the configured lower bound. */
348
+ readonly atMin: ComputedRef<boolean>;
349
+ /** Whether the count currently sits on the configured upper bound. */
350
+ readonly atMax: ComputedRef<boolean>;
351
+ /**
352
+ * Add `delta` (default `1`) to the count and clamp into the bounds.
353
+ *
354
+ * @param delta Amount added; may be negative or infinite.
355
+ * @returns The count after clamping.
356
+ */
357
+ readonly increment: (delta?: number) => number;
358
+ /**
359
+ * Subtract `delta` (default `1`) from the count and clamp into the bounds.
360
+ *
361
+ * @param delta Amount subtracted; may be negative or infinite.
362
+ * @returns The count after clamping.
363
+ */
364
+ readonly decrement: (delta?: number) => number;
365
+ /**
366
+ * Assign a value directly, clamped into the bounds.
367
+ *
368
+ * @returns The count after clamping.
369
+ */
370
+ readonly set: (value: number) => number;
371
+ /**
372
+ * Restore the reset baseline, or establish a new one.
373
+ *
374
+ * Without an argument the count returns to the creation-time initial value
375
+ * (after its original clamping). With an argument, the clamped value
376
+ * becomes both the new count and the baseline used by later `reset()`
377
+ * calls.
378
+ *
379
+ * @param value Replacement baseline.
380
+ * @returns The count after clamping.
381
+ */
382
+ readonly reset: (value?: number) => number;
383
+ }
384
+ /**
385
+ * Create a clamped counter whose every transition stays inside `[min, max]`.
386
+ *
387
+ * All operations clamp instead of failing, including the initial value, so
388
+ * the count is inside the bounds at every observable moment. Only `NaN` is
389
+ * rejected — silently corrupting the count is never an option. Purely
390
+ * synchronous state: safe during server rendering (no browser globals, no
391
+ * timers) and nothing to dispose, so it works inside and outside reactive
392
+ * scopes alike. Bounds are fixed at creation and not reactive.
393
+ *
394
+ * @example
395
+ * ```ts
396
+ * const { count, increment, atMax } = useCounter(9, { min: 0, max: 10 });
397
+ * increment(); // 10
398
+ * increment(); // 10 (clamped)
399
+ * atMax.value; // true
400
+ * ```
401
+ *
402
+ * @param initial Count before any operation, clamped into the bounds.
403
+ * @default initial 0
404
+ * @param options Inclusive bounds for every value the counter takes.
405
+ * @default options {}
406
+ * @throws `RangeError` tagged `VIZE_COMPOSE_COUNTER_INVALID_RANGE` when a
407
+ * bound is `NaN` or `min` exceeds `max`.
408
+ * @throws `RangeError` tagged `VIZE_COMPOSE_COUNTER_INVALID_VALUE` when an
409
+ * initial value, operand, or arithmetic result is `NaN` (for example
410
+ * incrementing `-Infinity` by `Infinity`); the count is left unchanged.
411
+ * @returns Reactive count, bound flags, and mutation controls.
412
+ */
413
+ declare function useCounter(initial?: number, options?: UseCounterOptions): CounterControls;
414
+ //#endregion
415
+ //#region src/use-debounced.d.ts
416
+ /** Options for {@link useDebounced}. */
417
+ interface UseDebouncedOptions {
418
+ /**
419
+ * Applies the timing policy when no browser `window` is available.
420
+ *
421
+ * Keep this disabled during server rendering, where the debounced view
422
+ * mirrors the source synchronously instead of starting timers. Enable it
423
+ * for native, desktop, worker, and test runtimes whose scheduler is
424
+ * lifecycle-bound.
425
+ *
426
+ * @default false
427
+ */
428
+ readonly runOnServer?: boolean;
429
+ /**
430
+ * Owns the single-shot timer.
431
+ *
432
+ * @default globalThis timer functions
433
+ */
434
+ readonly scheduler?: TimeoutScheduler;
435
+ }
436
+ /** Reactive debounced view and controls returned by {@link useDebounced}. */
437
+ interface DebouncedControls<Value> {
438
+ /** Readonly view of the source that settles `waitMs` after the last change. */
439
+ readonly debounced: Readonly<ShallowRef<Value>>;
440
+ /** Whether a trailing update is currently scheduled. */
441
+ readonly pending: Readonly<ShallowRef<boolean>>;
442
+ /**
443
+ * Discard the scheduled trailing update and keep the last settled value.
444
+ * Later source changes debounce again as usual.
445
+ *
446
+ * @returns Whether a scheduled update was discarded.
447
+ */
448
+ readonly cancel: () => boolean;
449
+ /**
450
+ * Apply the current source value immediately instead of waiting out the
451
+ * delay.
452
+ *
453
+ * @returns Whether a scheduled update was applied.
454
+ */
455
+ readonly flush: () => boolean;
456
+ }
457
+ /**
458
+ * Create a readonly debounced view of a reactive source.
459
+ *
460
+ * The view starts at the current source value. Each source change (observed
461
+ * with `flush: "sync"`, so every synchronous write counts) restarts a
462
+ * single-shot timer of `waitMs` milliseconds; when it fires, the view takes
463
+ * the source value current at that moment. `waitMs` is reactive and is read
464
+ * when a timer is scheduled; changing it does not restart an already-pending
465
+ * timer. A wait of `0` still defers to the next scheduler tick.
466
+ *
467
+ * Server rendering is explicit: without a browser `window` (and with
468
+ * {@link UseDebouncedOptions.runOnServer} disabled) no timer ever starts and
469
+ * the view mirrors the source synchronously, so server-rendered output shows
470
+ * current values and nothing leaks. `pending` stays `false` and the controls
471
+ * report `false` in that mode.
472
+ *
473
+ * Cleanup rule: the watcher and any pending timer are released when the
474
+ * owning reactive scope stops; call inside an active scope. Outside one, the
475
+ * watcher lives as long as the source and `cancel()` only clears the pending
476
+ * timer.
477
+ *
478
+ * @example
479
+ * ```ts
480
+ * const query = shallowRef("");
481
+ * const { debounced, flush } = useDebounced(query, 300);
482
+ * query.value = "vize"; // debounced.value still "" for 300ms
483
+ * flush(); // debounced.value === "vize" immediately
484
+ * ```
485
+ *
486
+ * @param source Reactive source to debounce.
487
+ * @param waitMs Reactive delay in milliseconds; must be finite and at least
488
+ * zero. Fractions are truncated.
489
+ * @param options Runtime scheduling overrides.
490
+ * @default options {}
491
+ * @throws `RangeError` tagged `VIZE_COMPOSE_DEBOUNCE_INVALID_WAIT` when the
492
+ * resolved wait is not finite or is negative, both synchronously at creation
493
+ * (even in mirror mode) and again for every scheduled delay.
494
+ * @returns Readonly debounced view, pending flag, and cancel/flush controls.
495
+ */
496
+ declare function useDebounced<Value>(source: MaybeRefOrGetter<Value>, waitMs: MaybeRefOrGetter<number>, options?: UseDebouncedOptions): DebouncedControls<Value>;
497
+ //#endregion
498
+ //#region src/use-history.d.ts
499
+ /** Options for {@link useHistory}. */
500
+ interface UseHistoryOptions<Value> {
501
+ /**
502
+ * Maximum number of undo entries retained; recording a change beyond it
503
+ * drops the oldest entry. The redo stack is bounded by construction, since
504
+ * redo entries only ever come from undone changes. Must be an integer
505
+ * greater than zero and is fixed at creation.
506
+ *
507
+ * @default 100
508
+ */
509
+ readonly capacity?: number;
510
+ /**
511
+ * Clone applied to every value captured into history and to every value
512
+ * restored out of it, isolating snapshots from later in-place mutation.
513
+ *
514
+ * @default identity — values are stored and restored by reference
515
+ */
516
+ readonly clone?: (value: Value) => Value;
517
+ }
518
+ /** Reactive undo/redo controls returned by {@link useHistory}. */
519
+ interface HistoryControls {
520
+ /** Whether {@link HistoryControls.undo} currently has an entry to restore. */
521
+ readonly canUndo: ComputedRef<boolean>;
522
+ /** Whether {@link HistoryControls.redo} currently has an entry to restore. */
523
+ readonly canRedo: ComputedRef<boolean>;
524
+ /** Number of retained undo entries. */
525
+ readonly undoCount: ComputedRef<number>;
526
+ /** Number of retained redo entries. */
527
+ readonly redoCount: ComputedRef<number>;
528
+ /**
529
+ * Restore the newest undo entry and move the current value onto the redo
530
+ * stack. The restoring write is not recorded.
531
+ *
532
+ * @returns Whether an entry was restored.
533
+ */
534
+ readonly undo: () => boolean;
535
+ /**
536
+ * Restore the newest redo entry and move the current value back onto the
537
+ * undo stack. The restoring write is not recorded.
538
+ *
539
+ * @returns Whether an entry was restored.
540
+ */
541
+ readonly redo: () => boolean;
542
+ /**
543
+ * Group every source write inside `run` into at most one undo entry.
544
+ *
545
+ * The entry restores the value from just before the batch. It is committed
546
+ * only when the final value differs (`Object.is`) from the starting value,
547
+ * and it is committed even when `run` throws, so a partially applied batch
548
+ * stays undoable as one step. Nested calls collapse into the outermost
549
+ * batch. The callback's return value is passed through.
550
+ */
551
+ readonly batch: <Result>(run: () => Result) => Result;
552
+ /** Drop every undo and redo entry while keeping the current value. */
553
+ readonly clear: () => void;
554
+ }
555
+ /**
556
+ * Record bounded undo/redo history over the writes of a ref.
557
+ *
558
+ * Recording is shallow and identity-based, matching Vue's own change
559
+ * detection: assignments to `source.value` are recorded (observed with
560
+ * `flush: "sync"`, so every synchronous write counts), writes that are
561
+ * `Object.is`-equal to the current value are not changes, and in-place
562
+ * mutations of object values are invisible — pair mutable values with
563
+ * {@link UseHistoryOptions.clone} and reassign. Undoing and redoing restore
564
+ * values through `clone` as well, so snapshots never share identity with the
565
+ * live value unless the default identity clone is kept. When a
566
+ * user-provided `clone` throws, the failed operation leaves history
567
+ * unchanged and the error propagates.
568
+ *
569
+ * Safe during server rendering: no browser globals are read and no timers
570
+ * start. Cleanup rule: when the owning reactive scope stops, recording stops
571
+ * and every retained snapshot is released, so `undo`/`redo` return `false`
572
+ * afterwards; call inside an active scope, or the watcher lives as long as
573
+ * the source.
574
+ *
575
+ * @example
576
+ * ```ts
577
+ * const text = shallowRef("");
578
+ * const { undo, redo, batch } = useHistory(text);
579
+ * text.value = "a";
580
+ * batch(() => {
581
+ * text.value = "ab";
582
+ * text.value = "abc";
583
+ * });
584
+ * undo(); // text.value === "a" (the batch is one step)
585
+ * redo(); // text.value === "abc"
586
+ * ```
587
+ *
588
+ * @param source Ref whose writes are recorded.
589
+ * @param options Retention bound and snapshot cloning.
590
+ * @default options {}
591
+ * @throws `RangeError` tagged `VIZE_COMPOSE_HISTORY_INVALID_CAPACITY` when
592
+ * the capacity is not an integer greater than zero.
593
+ * @throws `Error` tagged `VIZE_COMPOSE_HISTORY_IN_BATCH` when `undo`,
594
+ * `redo`, or `clear` is called inside {@link HistoryControls.batch}, where
595
+ * stack movement would corrupt the pending group.
596
+ * @returns Reactive undo/redo state and controls.
597
+ */
598
+ declare function useHistory<Value>(source: Ref<Value>, options?: UseHistoryOptions<Value>): HistoryControls;
599
+ //#endregion
600
+ //#region src/use-previous.d.ts
601
+ /**
602
+ * Track the value a reactive source held before its latest change.
603
+ *
604
+ * The semantics are precise and arity-based:
605
+ *
606
+ * - Without an `initial` argument the ref holds `undefined` until the source
607
+ * changes for the first time.
608
+ * - With an `initial` argument (including an explicit `undefined` when
609
+ * `Value` allows it) the ref holds that value until the first change, and
610
+ * the return type never widens with `undefined`.
611
+ * - Every synchronous write is observed (`flush: "sync"`), so a sequence of
612
+ * writes in one tick shifts the previous value step by step instead of
613
+ * collapsing into one batch.
614
+ * - Writes whose value is `Object.is`-equal to the current value do not
615
+ * count as changes, matching Vue's own change detection.
616
+ * - Tracking is shallow: reassignments are observed, in-place mutations of
617
+ * object values are not.
618
+ *
619
+ * Safe during server rendering: no browser globals are read and no timers
620
+ * start. The underlying watcher is bound to the current reactive scope and
621
+ * stops with it; call inside an active scope, or the watcher lives as long
622
+ * as the source. A plain non-reactive source never changes, so the ref stays
623
+ * at its initial value.
624
+ *
625
+ * @example
626
+ * ```ts
627
+ * const route = shallowRef("/home");
628
+ * const previousRoute = usePrevious(route, "/");
629
+ * route.value = "/settings";
630
+ * previousRoute.value; // "/home"
631
+ * ```
632
+ *
633
+ * @param source Reactive source to observe.
634
+ * @param initial Value reported before the first change.
635
+ * @returns Readonly shallow ref holding the previous source value.
636
+ */
637
+ declare function usePrevious<Value>(source: MaybeRefOrGetter<Value>): Readonly<ShallowRef<Value | undefined>>;
638
+ declare function usePrevious<Value>(source: MaybeRefOrGetter<Value>, initial: Value): Readonly<ShallowRef<Value>>;
639
+ //#endregion
640
+ //#region src/use-throttled.d.ts
641
+ /** Options for {@link useThrottled}. */
642
+ interface UseThrottledOptions {
643
+ /**
644
+ * Apply the first change of a cooldown window immediately.
645
+ *
646
+ * @default true
647
+ */
648
+ readonly leading?: boolean;
649
+ /**
650
+ * Apply the newest change collected during a cooldown window when the
651
+ * window ends. When disabled, changes inside a window are dropped.
652
+ *
653
+ * @default true
654
+ */
655
+ readonly trailing?: boolean;
656
+ /**
657
+ * Applies the timing policy when no browser `window` is available.
658
+ *
659
+ * Keep this disabled during server rendering, where the throttled view
660
+ * mirrors the source synchronously instead of starting timers. Enable it
661
+ * for native, desktop, worker, and test runtimes whose scheduler is
662
+ * lifecycle-bound.
663
+ *
664
+ * @default false
665
+ */
666
+ readonly runOnServer?: boolean;
667
+ /**
668
+ * Owns the single-shot cooldown timer.
669
+ *
670
+ * @default globalThis timer functions
671
+ */
672
+ readonly scheduler?: TimeoutScheduler;
673
+ }
674
+ /** Reactive throttled view and controls returned by {@link useThrottled}. */
675
+ interface ThrottledControls<Value> {
676
+ /** Readonly view of the source updated at most once per cooldown window. */
677
+ readonly throttled: Readonly<ShallowRef<Value>>;
678
+ /** Whether a trailing update is waiting for the current window to end. */
679
+ readonly pending: Readonly<ShallowRef<boolean>>;
680
+ /**
681
+ * Discard the waiting trailing update and close the cooldown window, so
682
+ * the next change starts fresh on a leading edge.
683
+ *
684
+ * @returns Whether a waiting trailing update was discarded.
685
+ */
686
+ readonly cancel: () => boolean;
687
+ /**
688
+ * Apply the waiting trailing update immediately and close the cooldown
689
+ * window. Without a waiting update the window is left untouched.
690
+ *
691
+ * @returns Whether a waiting trailing update was applied.
692
+ */
693
+ readonly flush: () => boolean;
694
+ }
695
+ /**
696
+ * Create a readonly throttled view of a reactive source.
697
+ *
698
+ * Changes are observed with `flush: "sync"`, so every synchronous write
699
+ * counts. Outside a cooldown window, a change applies immediately when
700
+ * {@link UseThrottledOptions.leading} is enabled (otherwise it waits as a
701
+ * trailing update) and opens a window of `waitMs` milliseconds. Changes
702
+ * inside a window are collected as the trailing candidate; when the window
703
+ * ends with a candidate waiting, the source value current at that moment is
704
+ * applied and the next window opens back to back, keeping applications
705
+ * spaced by `waitMs`. A window that ends without a candidate closes silently.
706
+ * `waitMs` is reactive and is read each time a window opens; changing it
707
+ * never disturbs an already-open window. A wait of `0` still defers trailing
708
+ * updates to the next scheduler tick.
709
+ *
710
+ * Server rendering is explicit: without a browser `window` (and with
711
+ * {@link UseThrottledOptions.runOnServer} disabled) no timer ever starts and
712
+ * the view mirrors the source synchronously, so server-rendered output shows
713
+ * current values and nothing leaks. `pending` stays `false` and the controls
714
+ * report `false` in that mode.
715
+ *
716
+ * Cleanup rule: the watcher and any open window timer are released when the
717
+ * owning reactive scope stops; call inside an active scope. Outside one, the
718
+ * watcher lives as long as the source and `cancel()` only clears the window.
719
+ *
720
+ * @example
721
+ * ```ts
722
+ * const scrollY = shallowRef(0);
723
+ * const { throttled } = useThrottled(scrollY, 100);
724
+ * scrollY.value = 40; // applied immediately (leading edge)
725
+ * scrollY.value = 80; // applied when the 100ms window ends
726
+ * ```
727
+ *
728
+ * @param source Reactive source to throttle.
729
+ * @param waitMs Reactive cooldown in milliseconds; must be finite and at
730
+ * least zero. Fractions are truncated.
731
+ * @param options Edge policy and runtime scheduling overrides.
732
+ * @default options {}
733
+ * @throws `RangeError` tagged `VIZE_COMPOSE_THROTTLE_INVALID_WAIT` when the
734
+ * resolved wait is not finite or is negative, both synchronously at creation
735
+ * (even in mirror mode) and again each time a window opens.
736
+ * @throws `TypeError` tagged `VIZE_COMPOSE_THROTTLE_INVALID_EDGES` when both
737
+ * `leading` and `trailing` are disabled, because updates could then never
738
+ * propagate.
739
+ * @returns Readonly throttled view, pending flag, and cancel/flush controls.
740
+ */
741
+ declare function useThrottled<Value>(source: MaybeRefOrGetter<Value>, waitMs: MaybeRefOrGetter<number>, options?: UseThrottledOptions): ThrottledControls<Value>;
742
+ //#endregion
743
+ //#region src/use-toggle.d.ts
744
+ /** Reactive controls returned by {@link useToggle}. */
745
+ interface ToggleControls {
746
+ /**
747
+ * Owned boolean state.
748
+ *
749
+ * Deliberately writable: unlike the derived views elsewhere in this
750
+ * package, the toggle owns its state, so assigning the ref directly (for
751
+ * example through `v-model`) is equivalent to calling
752
+ * {@link ToggleControls.toggle} with a forced value.
753
+ */
754
+ readonly state: Ref<boolean>;
755
+ /**
756
+ * Invert the state, or force it to `force` when the argument is given.
757
+ * Passing an explicit `undefined` behaves like passing no argument.
758
+ *
759
+ * @param force Value assigned instead of inverting.
760
+ * @returns The state after the change.
761
+ */
762
+ readonly toggle: (force?: boolean) => boolean;
763
+ }
764
+ /**
765
+ * Create owned boolean state with an inverting control.
766
+ *
767
+ * Purely synchronous state: safe during server rendering (no browser
768
+ * globals, no timers) and nothing to dispose, so it works inside and
769
+ * outside reactive scopes alike.
770
+ *
771
+ * @example
772
+ * ```ts
773
+ * const { state: open, toggle } = useToggle();
774
+ * toggle(); // true
775
+ * toggle(false); // false
776
+ * open.value; // false
777
+ * ```
778
+ *
779
+ * @param initial State before the first toggle.
780
+ * @default initial false
781
+ * @returns The writable state and its toggle control.
782
+ */
783
+ declare function useToggle(initial?: boolean): ToggleControls;
784
+ //#endregion
785
+ export { AsyncResource, AsyncResourceContext, AsyncResourceExecution, AsyncResourceStatus, CounterControls, DebouncedControls, EventListenerControls, HistoryControls, LocaleControls, MediaQueryHost, MotionPreference, TextDirection, ThrottledControls, TimeoutScheduler, ToggleControls, UseAsyncResourceOptions, UseCounterOptions, UseDebouncedOptions, UseEventListenerOptions, UseHistoryOptions, UseLocaleOptions, UseMediaQueryOptions, UseThrottledOptions, tryOnScopeDispose, useAsyncResource, useCounter, useDebounced, useEventListener, useHistory, useLocale, useMediaQuery, usePrevious, useReducedMotion, useThrottled, useToggle };
package/dist/index.mjs CHANGED
@@ -3,6 +3,14 @@ import { computed, getCurrentScope, onScopeDispose, readonly, ref, shallowRef, t
3
3
  /**
4
4
  * Register cleanup in the active reactive scope when one exists.
5
5
  *
6
+ * This is the shared lifecycle primitive of the package: composables hand
7
+ * their teardown here so owned resources are released when the surrounding
8
+ * scope stops.
9
+ *
10
+ * Never throws and is safe during server rendering; no browser globals are
11
+ * read. When no scope is active the cleanup is not registered and disposal
12
+ * ownership stays with the caller.
13
+ *
6
14
  * @param cleanup Cleanup invoked exactly once when the scope is disposed.
7
15
  * @returns Whether the cleanup was registered.
8
16
  */
@@ -17,6 +25,18 @@ function tryOnScopeDispose(cleanup) {
17
25
  * Create a scoped, abortable asynchronous resource with latest-result-wins
18
26
  * state. Every execution returns a discriminated result, so cancellation,
19
27
  * supersession, loader failure, and successful `undefined` data stay distinct.
28
+ *
29
+ * When created inside an active reactive scope (and `scope` is enabled), the
30
+ * active execution is aborted when that scope stops; outside a scope,
31
+ * cancellation ownership stays with the caller. The execute promise never
32
+ * rejects — synchronous and asynchronous loader failures both settle into
33
+ * the `"error"` result. Safe during server rendering: no browser globals are
34
+ * read and abort reasons use the runtime-native `DOMException`.
35
+ *
36
+ * @param loader Asynchronous loader receiving the abort context first.
37
+ * @param options Data retention, supersession, and scope behavior.
38
+ * @default options {}
39
+ * @returns Reactive state and controls for the loader.
20
40
  */
21
41
  function useAsyncResource(loader, options = {}) {
22
42
  const data = shallowRef(options.initialData);
@@ -152,6 +172,19 @@ const FORMATTER_CACHE_LIMIT = 32;
152
172
  * Equivalent formatter options reuse instances. The bounded cache follows the
153
173
  * active locale automatically and prevents repeated constructor overhead in
154
174
  * reactive render paths.
175
+ *
176
+ * Detection is lazy and guarded: the default detector reads
177
+ * `navigator.language` behind a `typeof` check at call time, so importing and
178
+ * calling this during server rendering is safe, and runtimes without a
179
+ * `navigator` resolve to the fallback locale. The composable owns no timers
180
+ * or listeners, so no scope cleanup is required.
181
+ *
182
+ * @param source Reactive locale source. Empty values defer to the detector.
183
+ * @param options Detection and fallback behavior.
184
+ * @default options {}
185
+ * @throws `RangeError` on first read of the reactive values when the winning
186
+ * candidate is not a structurally valid locale identifier.
187
+ * @returns Reactive locale metadata and cached formatter factories.
155
188
  */
156
189
  function useLocale(source, options = {}) {
157
190
  const locale = computed(() => {
@@ -205,9 +238,18 @@ function serializeOptions(options) {
205
238
  /**
206
239
  * Evaluate a reactive media query without requiring browser globals.
207
240
  *
241
+ * During server rendering (or whenever no capability host resolves) the ref
242
+ * holds the configured server value and no subscription is created. The
243
+ * change subscription follows the reactive query and host: each
244
+ * re-evaluation removes the previous listener, and the final listener is
245
+ * removed when the owning reactive scope stops. Call inside an active scope
246
+ * so the subscription is released. A host whose matcher throws propagates
247
+ * the error to the active effect run; the browser default never throws.
248
+ *
208
249
  * @param query Reactive media-query source.
209
250
  * @param options Runtime capability and server-rendered fallback.
210
251
  * @default options {}
252
+ * @returns Readonly ref that is `true` while the query matches.
211
253
  */
212
254
  function useMediaQuery(query, options = {}) {
213
255
  const matches = ref(options.ssrValue ?? false);
@@ -230,8 +272,13 @@ function useMediaQuery(query, options = {}) {
230
272
  /**
231
273
  * Return the reactive user motion preference.
232
274
  *
275
+ * Shares {@link useMediaQuery} semantics: during server rendering the
276
+ * preference is `"no-preference"` unless `ssrValue` is `true`, and the
277
+ * underlying subscription is removed when the owning reactive scope stops.
278
+ *
233
279
  * @param options Runtime capability and server-rendered fallback.
234
280
  * @default options {}
281
+ * @returns Computed preference for `(prefers-reduced-motion: reduce)`.
235
282
  */
236
283
  function useReducedMotion(options = {}) {
237
284
  const reduced = useMediaQuery("(prefers-reduced-motion: reduce)", options);
@@ -241,4 +288,460 @@ function browserMediaQueryHost() {
241
288
  return typeof window !== "undefined" && typeof window.matchMedia === "function" ? window : void 0;
242
289
  }
243
290
  //#endregion
244
- export { tryOnScopeDispose, useAsyncResource, useEventListener, useLocale, useMediaQuery, useReducedMotion };
291
+ //#region src/use-counter.ts
292
+ /**
293
+ * Create a clamped counter whose every transition stays inside `[min, max]`.
294
+ *
295
+ * All operations clamp instead of failing, including the initial value, so
296
+ * the count is inside the bounds at every observable moment. Only `NaN` is
297
+ * rejected — silently corrupting the count is never an option. Purely
298
+ * synchronous state: safe during server rendering (no browser globals, no
299
+ * timers) and nothing to dispose, so it works inside and outside reactive
300
+ * scopes alike. Bounds are fixed at creation and not reactive.
301
+ *
302
+ * @example
303
+ * ```ts
304
+ * const { count, increment, atMax } = useCounter(9, { min: 0, max: 10 });
305
+ * increment(); // 10
306
+ * increment(); // 10 (clamped)
307
+ * atMax.value; // true
308
+ * ```
309
+ *
310
+ * @param initial Count before any operation, clamped into the bounds.
311
+ * @default initial 0
312
+ * @param options Inclusive bounds for every value the counter takes.
313
+ * @default options {}
314
+ * @throws `RangeError` tagged `VIZE_COMPOSE_COUNTER_INVALID_RANGE` when a
315
+ * bound is `NaN` or `min` exceeds `max`.
316
+ * @throws `RangeError` tagged `VIZE_COMPOSE_COUNTER_INVALID_VALUE` when an
317
+ * initial value, operand, or arithmetic result is `NaN` (for example
318
+ * incrementing `-Infinity` by `Infinity`); the count is left unchanged.
319
+ * @returns Reactive count, bound flags, and mutation controls.
320
+ */
321
+ function useCounter(initial = 0, options = {}) {
322
+ const min = requireBound(options.min ?? Number.NEGATIVE_INFINITY, "min");
323
+ const max = requireBound(options.max ?? Number.POSITIVE_INFINITY, "max");
324
+ if (min > max) throw new RangeError(`[VIZE_COMPOSE_COUNTER_INVALID_RANGE] min must not exceed max; received min ${String(min)} and max ${String(max)}`);
325
+ const clamp = (value) => Math.min(max, Math.max(min, value));
326
+ const count = shallowRef(clamp(requireValue(initial)));
327
+ let baseline = count.value;
328
+ const setClamped = (next) => {
329
+ count.value = clamp(requireValue(next));
330
+ return count.value;
331
+ };
332
+ const reset = (value) => {
333
+ const applied = setClamped(value ?? baseline);
334
+ if (value !== void 0) baseline = applied;
335
+ return applied;
336
+ };
337
+ return {
338
+ count,
339
+ atMin: computed(() => count.value === min),
340
+ atMax: computed(() => count.value === max),
341
+ increment: (delta = 1) => setClamped(count.value + requireValue(delta)),
342
+ decrement: (delta = 1) => setClamped(count.value - requireValue(delta)),
343
+ set: setClamped,
344
+ reset
345
+ };
346
+ }
347
+ function requireBound(value, label) {
348
+ if (Number.isNaN(value)) throw new RangeError(`[VIZE_COMPOSE_COUNTER_INVALID_RANGE] ${label} must not be NaN`);
349
+ return value;
350
+ }
351
+ function requireValue(value) {
352
+ if (Number.isNaN(value)) throw new RangeError("[VIZE_COMPOSE_COUNTER_INVALID_VALUE] the value is NaN; counter state was left unchanged");
353
+ return value;
354
+ }
355
+ //#endregion
356
+ //#region src/use-debounced.ts
357
+ const defaultScheduler$1 = {
358
+ setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
359
+ clearTimeout: (handle) => {
360
+ globalThis.clearTimeout(handle);
361
+ }
362
+ };
363
+ /**
364
+ * Create a readonly debounced view of a reactive source.
365
+ *
366
+ * The view starts at the current source value. Each source change (observed
367
+ * with `flush: "sync"`, so every synchronous write counts) restarts a
368
+ * single-shot timer of `waitMs` milliseconds; when it fires, the view takes
369
+ * the source value current at that moment. `waitMs` is reactive and is read
370
+ * when a timer is scheduled; changing it does not restart an already-pending
371
+ * timer. A wait of `0` still defers to the next scheduler tick.
372
+ *
373
+ * Server rendering is explicit: without a browser `window` (and with
374
+ * {@link UseDebouncedOptions.runOnServer} disabled) no timer ever starts and
375
+ * the view mirrors the source synchronously, so server-rendered output shows
376
+ * current values and nothing leaks. `pending` stays `false` and the controls
377
+ * report `false` in that mode.
378
+ *
379
+ * Cleanup rule: the watcher and any pending timer are released when the
380
+ * owning reactive scope stops; call inside an active scope. Outside one, the
381
+ * watcher lives as long as the source and `cancel()` only clears the pending
382
+ * timer.
383
+ *
384
+ * @example
385
+ * ```ts
386
+ * const query = shallowRef("");
387
+ * const { debounced, flush } = useDebounced(query, 300);
388
+ * query.value = "vize"; // debounced.value still "" for 300ms
389
+ * flush(); // debounced.value === "vize" immediately
390
+ * ```
391
+ *
392
+ * @param source Reactive source to debounce.
393
+ * @param waitMs Reactive delay in milliseconds; must be finite and at least
394
+ * zero. Fractions are truncated.
395
+ * @param options Runtime scheduling overrides.
396
+ * @default options {}
397
+ * @throws `RangeError` tagged `VIZE_COMPOSE_DEBOUNCE_INVALID_WAIT` when the
398
+ * resolved wait is not finite or is negative, both synchronously at creation
399
+ * (even in mirror mode) and again for every scheduled delay.
400
+ * @returns Readonly debounced view, pending flag, and cancel/flush controls.
401
+ */
402
+ function useDebounced(source, waitMs, options = {}) {
403
+ const scheduler = options.scheduler ?? defaultScheduler$1;
404
+ const debounced = shallowRef(toValue(source));
405
+ const pending = shallowRef(false);
406
+ let handle;
407
+ resolveWaitMs$1(toValue(waitMs));
408
+ const apply = () => {
409
+ handle = void 0;
410
+ pending.value = false;
411
+ debounced.value = toValue(source);
412
+ };
413
+ const cancel = () => {
414
+ if (!pending.value) return false;
415
+ scheduler.clearTimeout(handle);
416
+ handle = void 0;
417
+ pending.value = false;
418
+ return true;
419
+ };
420
+ const flush = () => {
421
+ if (!pending.value) return false;
422
+ scheduler.clearTimeout(handle);
423
+ apply();
424
+ return true;
425
+ };
426
+ watch(() => toValue(source), (next) => {
427
+ if (typeof window === "undefined" && !(options.runOnServer ?? false)) {
428
+ debounced.value = next;
429
+ return;
430
+ }
431
+ const delayMs = resolveWaitMs$1(toValue(waitMs));
432
+ if (pending.value) scheduler.clearTimeout(handle);
433
+ pending.value = true;
434
+ handle = scheduler.setTimeout(apply, delayMs);
435
+ }, { flush: "sync" });
436
+ tryOnScopeDispose(() => {
437
+ cancel();
438
+ });
439
+ return {
440
+ debounced,
441
+ pending,
442
+ cancel,
443
+ flush
444
+ };
445
+ }
446
+ function resolveWaitMs$1(value) {
447
+ if (!Number.isFinite(value) || value < 0) throw new RangeError(`[VIZE_COMPOSE_DEBOUNCE_INVALID_WAIT] waitMs must be finite and at least zero; received ${String(value)}`);
448
+ return Math.trunc(value);
449
+ }
450
+ //#endregion
451
+ //#region src/use-history.ts
452
+ /**
453
+ * Record bounded undo/redo history over the writes of a ref.
454
+ *
455
+ * Recording is shallow and identity-based, matching Vue's own change
456
+ * detection: assignments to `source.value` are recorded (observed with
457
+ * `flush: "sync"`, so every synchronous write counts), writes that are
458
+ * `Object.is`-equal to the current value are not changes, and in-place
459
+ * mutations of object values are invisible — pair mutable values with
460
+ * {@link UseHistoryOptions.clone} and reassign. Undoing and redoing restore
461
+ * values through `clone` as well, so snapshots never share identity with the
462
+ * live value unless the default identity clone is kept. When a
463
+ * user-provided `clone` throws, the failed operation leaves history
464
+ * unchanged and the error propagates.
465
+ *
466
+ * Safe during server rendering: no browser globals are read and no timers
467
+ * start. Cleanup rule: when the owning reactive scope stops, recording stops
468
+ * and every retained snapshot is released, so `undo`/`redo` return `false`
469
+ * afterwards; call inside an active scope, or the watcher lives as long as
470
+ * the source.
471
+ *
472
+ * @example
473
+ * ```ts
474
+ * const text = shallowRef("");
475
+ * const { undo, redo, batch } = useHistory(text);
476
+ * text.value = "a";
477
+ * batch(() => {
478
+ * text.value = "ab";
479
+ * text.value = "abc";
480
+ * });
481
+ * undo(); // text.value === "a" (the batch is one step)
482
+ * redo(); // text.value === "abc"
483
+ * ```
484
+ *
485
+ * @param source Ref whose writes are recorded.
486
+ * @param options Retention bound and snapshot cloning.
487
+ * @default options {}
488
+ * @throws `RangeError` tagged `VIZE_COMPOSE_HISTORY_INVALID_CAPACITY` when
489
+ * the capacity is not an integer greater than zero.
490
+ * @throws `Error` tagged `VIZE_COMPOSE_HISTORY_IN_BATCH` when `undo`,
491
+ * `redo`, or `clear` is called inside {@link HistoryControls.batch}, where
492
+ * stack movement would corrupt the pending group.
493
+ * @returns Reactive undo/redo state and controls.
494
+ */
495
+ function useHistory(source, options = {}) {
496
+ const capacity = options.capacity ?? 100;
497
+ if (!Number.isInteger(capacity) || capacity < 1) throw new RangeError(`[VIZE_COMPOSE_HISTORY_INVALID_CAPACITY] capacity must be an integer greater than zero; received ${String(capacity)}`);
498
+ const clone = options.clone ?? ((value) => value);
499
+ const undoStack = shallowRef([]);
500
+ const redoStack = shallowRef([]);
501
+ let restoring = false;
502
+ let batchDepth = 0;
503
+ let activeBatch;
504
+ const pushUndo = (entry) => {
505
+ const next = [...undoStack.value, entry];
506
+ undoStack.value = next.length > capacity ? next.slice(next.length - capacity) : next;
507
+ redoStack.value = [];
508
+ };
509
+ const writeSilently = (value) => {
510
+ restoring = true;
511
+ try {
512
+ source.value = value;
513
+ } finally {
514
+ restoring = false;
515
+ }
516
+ };
517
+ const requireOutsideBatch = (operation) => {
518
+ if (batchDepth > 0) throw new Error(`[VIZE_COMPOSE_HISTORY_IN_BATCH] ${operation}() is not available inside batch()`);
519
+ };
520
+ const undo = () => {
521
+ requireOutsideBatch("undo");
522
+ const entry = undoStack.value.at(-1);
523
+ if (entry === void 0) return false;
524
+ const restored = clone(entry.value);
525
+ const recorded = { value: clone(source.value) };
526
+ undoStack.value = undoStack.value.slice(0, -1);
527
+ redoStack.value = [...redoStack.value, recorded];
528
+ writeSilently(restored);
529
+ return true;
530
+ };
531
+ const redo = () => {
532
+ requireOutsideBatch("redo");
533
+ const entry = redoStack.value.at(-1);
534
+ if (entry === void 0) return false;
535
+ const restored = clone(entry.value);
536
+ const recorded = { value: clone(source.value) };
537
+ redoStack.value = redoStack.value.slice(0, -1);
538
+ undoStack.value = [...undoStack.value, recorded];
539
+ writeSilently(restored);
540
+ return true;
541
+ };
542
+ const batch = (run) => {
543
+ if (batchDepth === 0) activeBatch = {
544
+ raw: source.value,
545
+ entry: { value: clone(source.value) }
546
+ };
547
+ batchDepth += 1;
548
+ try {
549
+ return run();
550
+ } finally {
551
+ batchDepth -= 1;
552
+ if (batchDepth === 0 && activeBatch !== void 0) {
553
+ const finished = activeBatch;
554
+ activeBatch = void 0;
555
+ if (!Object.is(finished.raw, source.value)) pushUndo(finished.entry);
556
+ }
557
+ }
558
+ };
559
+ const clear = () => {
560
+ requireOutsideBatch("clear");
561
+ undoStack.value = [];
562
+ redoStack.value = [];
563
+ };
564
+ const handle = watch(source, (_next, replaced) => {
565
+ if (restoring || batchDepth > 0) return;
566
+ pushUndo({ value: clone(replaced) });
567
+ }, { flush: "sync" });
568
+ tryOnScopeDispose(() => {
569
+ handle.stop();
570
+ undoStack.value = [];
571
+ redoStack.value = [];
572
+ });
573
+ return {
574
+ canUndo: computed(() => undoStack.value.length > 0),
575
+ canRedo: computed(() => redoStack.value.length > 0),
576
+ undoCount: computed(() => undoStack.value.length),
577
+ redoCount: computed(() => redoStack.value.length),
578
+ undo,
579
+ redo,
580
+ batch,
581
+ clear
582
+ };
583
+ }
584
+ //#endregion
585
+ //#region src/use-previous.ts
586
+ function usePrevious(source, ...initial) {
587
+ const previous = shallowRef(initial.length === 1 ? initial[0] : void 0);
588
+ watch(() => toValue(source), (_next, replaced) => {
589
+ previous.value = replaced;
590
+ }, { flush: "sync" });
591
+ return previous;
592
+ }
593
+ //#endregion
594
+ //#region src/use-throttled.ts
595
+ const defaultScheduler = {
596
+ setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
597
+ clearTimeout: (handle) => {
598
+ globalThis.clearTimeout(handle);
599
+ }
600
+ };
601
+ /**
602
+ * Create a readonly throttled view of a reactive source.
603
+ *
604
+ * Changes are observed with `flush: "sync"`, so every synchronous write
605
+ * counts. Outside a cooldown window, a change applies immediately when
606
+ * {@link UseThrottledOptions.leading} is enabled (otherwise it waits as a
607
+ * trailing update) and opens a window of `waitMs` milliseconds. Changes
608
+ * inside a window are collected as the trailing candidate; when the window
609
+ * ends with a candidate waiting, the source value current at that moment is
610
+ * applied and the next window opens back to back, keeping applications
611
+ * spaced by `waitMs`. A window that ends without a candidate closes silently.
612
+ * `waitMs` is reactive and is read each time a window opens; changing it
613
+ * never disturbs an already-open window. A wait of `0` still defers trailing
614
+ * updates to the next scheduler tick.
615
+ *
616
+ * Server rendering is explicit: without a browser `window` (and with
617
+ * {@link UseThrottledOptions.runOnServer} disabled) no timer ever starts and
618
+ * the view mirrors the source synchronously, so server-rendered output shows
619
+ * current values and nothing leaks. `pending` stays `false` and the controls
620
+ * report `false` in that mode.
621
+ *
622
+ * Cleanup rule: the watcher and any open window timer are released when the
623
+ * owning reactive scope stops; call inside an active scope. Outside one, the
624
+ * watcher lives as long as the source and `cancel()` only clears the window.
625
+ *
626
+ * @example
627
+ * ```ts
628
+ * const scrollY = shallowRef(0);
629
+ * const { throttled } = useThrottled(scrollY, 100);
630
+ * scrollY.value = 40; // applied immediately (leading edge)
631
+ * scrollY.value = 80; // applied when the 100ms window ends
632
+ * ```
633
+ *
634
+ * @param source Reactive source to throttle.
635
+ * @param waitMs Reactive cooldown in milliseconds; must be finite and at
636
+ * least zero. Fractions are truncated.
637
+ * @param options Edge policy and runtime scheduling overrides.
638
+ * @default options {}
639
+ * @throws `RangeError` tagged `VIZE_COMPOSE_THROTTLE_INVALID_WAIT` when the
640
+ * resolved wait is not finite or is negative, both synchronously at creation
641
+ * (even in mirror mode) and again each time a window opens.
642
+ * @throws `TypeError` tagged `VIZE_COMPOSE_THROTTLE_INVALID_EDGES` when both
643
+ * `leading` and `trailing` are disabled, because updates could then never
644
+ * propagate.
645
+ * @returns Readonly throttled view, pending flag, and cancel/flush controls.
646
+ */
647
+ function useThrottled(source, waitMs, options = {}) {
648
+ const leading = options.leading ?? true;
649
+ const trailing = options.trailing ?? true;
650
+ if (!leading && !trailing) throw new TypeError("[VIZE_COMPOSE_THROTTLE_INVALID_EDGES] at least one of leading or trailing must be enabled");
651
+ const scheduler = options.scheduler ?? defaultScheduler;
652
+ const throttled = shallowRef(toValue(source));
653
+ const pending = shallowRef(false);
654
+ let windowHandle;
655
+ let windowOpen = false;
656
+ resolveWaitMs(toValue(waitMs));
657
+ const openWindow = () => {
658
+ windowOpen = true;
659
+ windowHandle = scheduler.setTimeout(() => {
660
+ windowHandle = void 0;
661
+ if (!pending.value) {
662
+ windowOpen = false;
663
+ return;
664
+ }
665
+ pending.value = false;
666
+ throttled.value = toValue(source);
667
+ openWindow();
668
+ }, resolveWaitMs(toValue(waitMs)));
669
+ };
670
+ const closeWindow = () => {
671
+ if (windowOpen) scheduler.clearTimeout(windowHandle);
672
+ windowHandle = void 0;
673
+ windowOpen = false;
674
+ pending.value = false;
675
+ };
676
+ const cancel = () => {
677
+ const hadTrailing = pending.value;
678
+ closeWindow();
679
+ return hadTrailing;
680
+ };
681
+ const flush = () => {
682
+ if (!pending.value) return false;
683
+ closeWindow();
684
+ throttled.value = toValue(source);
685
+ return true;
686
+ };
687
+ watch(() => toValue(source), (next) => {
688
+ if (typeof window === "undefined" && !(options.runOnServer ?? false)) {
689
+ throttled.value = next;
690
+ return;
691
+ }
692
+ if (windowOpen) {
693
+ if (trailing) pending.value = true;
694
+ return;
695
+ }
696
+ if (leading) throttled.value = next;
697
+ else pending.value = true;
698
+ openWindow();
699
+ }, { flush: "sync" });
700
+ tryOnScopeDispose(() => {
701
+ cancel();
702
+ });
703
+ return {
704
+ throttled,
705
+ pending,
706
+ cancel,
707
+ flush
708
+ };
709
+ }
710
+ function resolveWaitMs(value) {
711
+ if (!Number.isFinite(value) || value < 0) throw new RangeError(`[VIZE_COMPOSE_THROTTLE_INVALID_WAIT] waitMs must be finite and at least zero; received ${String(value)}`);
712
+ return Math.trunc(value);
713
+ }
714
+ //#endregion
715
+ //#region src/use-toggle.ts
716
+ /**
717
+ * Create owned boolean state with an inverting control.
718
+ *
719
+ * Purely synchronous state: safe during server rendering (no browser
720
+ * globals, no timers) and nothing to dispose, so it works inside and
721
+ * outside reactive scopes alike.
722
+ *
723
+ * @example
724
+ * ```ts
725
+ * const { state: open, toggle } = useToggle();
726
+ * toggle(); // true
727
+ * toggle(false); // false
728
+ * open.value; // false
729
+ * ```
730
+ *
731
+ * @param initial State before the first toggle.
732
+ * @default initial false
733
+ * @returns The writable state and its toggle control.
734
+ */
735
+ function useToggle(initial = false) {
736
+ const state = shallowRef(initial);
737
+ const toggle = (force) => {
738
+ state.value = force ?? !state.value;
739
+ return state.value;
740
+ };
741
+ return {
742
+ state,
743
+ toggle
744
+ };
745
+ }
746
+ //#endregion
747
+ export { tryOnScopeDispose, useAsyncResource, useCounter, useDebounced, useEventListener, useHistory, useLocale, useMediaQuery, usePrevious, useReducedMotion, useThrottled, useToggle };
@@ -75,15 +75,31 @@ interface UseTemporalZonedDateTimeOptions extends UseTemporalNowOptions {
75
75
  * effect scope.
76
76
  *
77
77
  * The timer is replaced when reactive options change and is always cancelled
78
- * when the owning scope stops. During server rendering, no timer starts unless
78
+ * when the owning scope stops. Call inside an active effect scope; without
79
+ * one, there is no automatic timer cleanup. During
80
+ * server rendering, no timer starts unless
79
81
  * {@link UseTemporalNowOptions.runOnServer} is explicitly enabled.
82
+ *
83
+ * @param options Clock scheduling, pause, and runtime overrides.
84
+ * @default options {}
85
+ * @throws `RangeError` tagged `VIZE_COMPOSE_TEMPORAL_INVALID_INTERVAL` when
86
+ * the resolved interval is not finite or not greater than zero, both
87
+ * synchronously at creation and on invalid reactive updates.
88
+ * @returns Reactive clock controls.
80
89
  */
81
90
  declare function useTemporalNow(options?: UseTemporalNowOptions): TemporalClock;
82
91
  /**
83
92
  * Creates a reactive zoned date-time derived from a scoped Temporal clock.
84
93
  *
85
94
  * Changes to {@link UseTemporalZonedDateTimeOptions.timeZone} are reflected
86
- * without replacing the underlying timer.
95
+ * without replacing the underlying timer. Scheduling, server rendering, and
96
+ * scope-cleanup rules are shared with {@link useTemporalNow}.
97
+ *
98
+ * @param options Clock scheduling plus the reactive time zone.
99
+ * @default options {}
100
+ * @throws `RangeError` for invalid intervals (see {@link useTemporalNow}),
101
+ * and on read when the reactive time zone cannot be resolved by Temporal.
102
+ * @returns Computed zoned date-time in the reactive time zone.
87
103
  */
88
104
  declare function useTemporalZonedDateTime(options?: UseTemporalZonedDateTimeOptions): ComputedRef<Temporal.ZonedDateTime>;
89
105
  //#endregion
package/dist/temporal.mjs CHANGED
@@ -20,8 +20,17 @@ function shouldSchedule(options) {
20
20
  * effect scope.
21
21
  *
22
22
  * The timer is replaced when reactive options change and is always cancelled
23
- * when the owning scope stops. During server rendering, no timer starts unless
23
+ * when the owning scope stops. Call inside an active effect scope; without
24
+ * one, there is no automatic timer cleanup. During
25
+ * server rendering, no timer starts unless
24
26
  * {@link UseTemporalNowOptions.runOnServer} is explicitly enabled.
27
+ *
28
+ * @param options Clock scheduling, pause, and runtime overrides.
29
+ * @default options {}
30
+ * @throws `RangeError` tagged `VIZE_COMPOSE_TEMPORAL_INVALID_INTERVAL` when
31
+ * the resolved interval is not finite or not greater than zero, both
32
+ * synchronously at creation and on invalid reactive updates.
33
+ * @returns Reactive clock controls.
25
34
  */
26
35
  function useTemporalNow(options = {}) {
27
36
  const readNow = options.now ?? Temporal.Now.instant;
@@ -48,7 +57,14 @@ function useTemporalNow(options = {}) {
48
57
  * Creates a reactive zoned date-time derived from a scoped Temporal clock.
49
58
  *
50
59
  * Changes to {@link UseTemporalZonedDateTimeOptions.timeZone} are reflected
51
- * without replacing the underlying timer.
60
+ * without replacing the underlying timer. Scheduling, server rendering, and
61
+ * scope-cleanup rules are shared with {@link useTemporalNow}.
62
+ *
63
+ * @param options Clock scheduling plus the reactive time zone.
64
+ * @default options {}
65
+ * @throws `RangeError` for invalid intervals (see {@link useTemporalNow}),
66
+ * and on read when the reactive time zone cannot be resolved by Temporal.
67
+ * @returns Computed zoned date-time in the reactive time zone.
52
68
  */
53
69
  function useTemporalZonedDateTime(options = {}) {
54
70
  const clock = useTemporalNow(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vizejs/composable",
3
- "version": "0.302.0",
3
+ "version": "0.303.0",
4
4
  "description": "Lifecycle-safe composable foundations for Vize applications",
5
5
  "keywords": [
6
6
  "composables",
@@ -41,29 +41,29 @@
41
41
  "publishConfig": {
42
42
  "access": "public"
43
43
  },
44
- "scripts": {
45
- "build": "vp pack",
46
- "dev": "vp pack --watch",
47
- "pretest": "vp pack && pnpm check:size",
48
- "test": "vp exec node --test 'src/**/*.test.ts'",
49
- "check": "vp check src scripts vite.config.ts",
50
- "check:fix": "vp check --fix src scripts vite.config.ts",
51
- "check:size": "node scripts/check-size.mjs",
52
- "fmt": "vp fmt --write src scripts vite.config.ts"
53
- },
54
44
  "dependencies": {
55
45
  "temporal-polyfill-lite": "0.4.2"
56
46
  },
57
47
  "devDependencies": {
58
- "@types/node": "catalog:typescript",
59
- "typescript": "catalog:typescript",
60
- "vite-plus": "catalog:vite-stack",
61
- "vue": "catalog:vue-stable"
48
+ "@types/node": "25.9.2",
49
+ "typescript": "6.0.3",
50
+ "vite-plus": "0.1.21",
51
+ "vue": "3.5.35"
62
52
  },
63
53
  "peerDependencies": {
64
54
  "vue": "^3.5.0"
65
55
  },
66
56
  "engines": {
67
57
  "node": ">=24"
58
+ },
59
+ "scripts": {
60
+ "build": "vp pack",
61
+ "dev": "vp pack --watch",
62
+ "pretest": "vp pack && pnpm check:size",
63
+ "test": "vp exec node --test 'src/**/*.test.ts'",
64
+ "check": "vp check src scripts vite.config.ts",
65
+ "check:fix": "vp check --fix src scripts vite.config.ts",
66
+ "check:size": "node scripts/check-size.mjs",
67
+ "fmt": "vp fmt --write src scripts vite.config.ts"
68
68
  }
69
- }
69
+ }