release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/reactive",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { signal, type Signal } from "./signal.ts";
|
||||
|
||||
export interface HistorySignal<T> extends Signal<T> {
|
||||
undo(): boolean;
|
||||
redo(): boolean;
|
||||
canUndo(): boolean;
|
||||
canRedo(): boolean;
|
||||
clearHistory(): void;
|
||||
}
|
||||
export function historySignal<T>(
|
||||
initial: T,
|
||||
options: { limit?: number; equals?: (left: T, right: T) => boolean } = {},
|
||||
): HistorySignal<T> {
|
||||
const limit = options.limit ?? 100;
|
||||
if (!Number.isInteger(limit) || limit < 1) throw new RangeError("history limit must be positive");
|
||||
const current = signal(initial);
|
||||
const past: T[] = [];
|
||||
const future: T[] = [];
|
||||
const equals = options.equals ?? Object.is;
|
||||
const set = current.set.bind(current);
|
||||
current.set = (value) => {
|
||||
const next = typeof value === "function" ? (value as (previous: T) => T)(current.get()) : value;
|
||||
const previous = current.get();
|
||||
if (equals(previous, next)) return;
|
||||
past.push(previous);
|
||||
if (past.length > limit) past.shift();
|
||||
future.length = 0;
|
||||
set(next);
|
||||
};
|
||||
return Object.assign(current, {
|
||||
undo() {
|
||||
const value = past.pop();
|
||||
if (value === undefined) return false;
|
||||
future.push(current.get());
|
||||
set(value);
|
||||
return true;
|
||||
},
|
||||
redo() {
|
||||
const value = future.pop();
|
||||
if (value === undefined) return false;
|
||||
past.push(current.get());
|
||||
set(value);
|
||||
return true;
|
||||
},
|
||||
canUndo: () => past.length > 0,
|
||||
canRedo: () => future.length > 0,
|
||||
clearHistory() {
|
||||
past.length = 0;
|
||||
future.length = 0;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface UrlStateOptions<T> {
|
||||
url?: URL;
|
||||
parameter: string;
|
||||
parse?: (value: string | null) => T;
|
||||
serialize?: (value: T) => string | null;
|
||||
replace?: (url: URL) => void;
|
||||
}
|
||||
export function urlSignal<T>(initial: T, options: UrlStateOptions<T>): Signal<T> {
|
||||
const url = options.url ?? new URL(globalThis.location?.href ?? "http://localhost/");
|
||||
const parsed = options.parse?.(url.searchParams.get(options.parameter));
|
||||
const state = signal(parsed ?? initial);
|
||||
const original = state.set.bind(state);
|
||||
state.set = (value) => {
|
||||
original(value);
|
||||
const serialized = options.serialize ? options.serialize(state.get()) : String(state.get());
|
||||
if (serialized === null) url.searchParams.delete(options.parameter);
|
||||
else url.searchParams.set(options.parameter, serialized);
|
||||
if (options.replace) options.replace(new URL(url));
|
||||
else globalThis.history?.replaceState?.(null, "", url);
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
export interface ReactiveContext<T> {
|
||||
provide<R>(value: T, run: () => R): R;
|
||||
use(): T;
|
||||
}
|
||||
export function createContextProvider<T>(defaultValue?: T): ReactiveContext<T> {
|
||||
const stack: T[] = [];
|
||||
return {
|
||||
provide(value, run) {
|
||||
stack.push(value);
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
stack.pop();
|
||||
}
|
||||
},
|
||||
use() {
|
||||
if (stack.length) return stack[stack.length - 1]!;
|
||||
if (defaultValue !== undefined) return defaultValue;
|
||||
throw new Error("WRN-REACTIVE-CONTEXT: no provider is active.");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function mountPortal(content: Node | string, target: Element): () => void {
|
||||
const marker = target.ownerDocument.createComment("wrnexus-portal");
|
||||
const node = typeof content === "string" ? target.ownerDocument.createTextNode(content) : content;
|
||||
target.append(marker, node);
|
||||
return () => {
|
||||
marker.remove();
|
||||
node.parentNode?.removeChild(node);
|
||||
};
|
||||
}
|
||||
|
||||
export async function transition(
|
||||
update: () => void,
|
||||
options: { className?: string; target?: Element; durationMs?: number; signal?: AbortSignal } = {},
|
||||
): Promise<void> {
|
||||
if (options.signal?.aborted) throw options.signal.reason;
|
||||
const target = options.target;
|
||||
const className = options.className ?? "wrn-transition";
|
||||
target?.classList.add(className);
|
||||
update();
|
||||
const duration = options.durationMs ?? 0;
|
||||
if (duration > 0)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, duration);
|
||||
options.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(options.signal!.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
target?.classList.remove(className);
|
||||
}
|
||||
|
||||
export interface TimelineStep {
|
||||
durationMs: number;
|
||||
delayMs?: number;
|
||||
easing?: (progress: number) => number;
|
||||
update(progress: number): void;
|
||||
}
|
||||
export interface AnimationTimeline {
|
||||
play(options?: { reverse?: boolean; signal?: AbortSignal }): Promise<void>;
|
||||
cancel(reason?: unknown): void;
|
||||
readonly running: boolean;
|
||||
}
|
||||
|
||||
export function createTimeline(
|
||||
steps: TimelineStep[],
|
||||
options: { now?: () => number; frame?: (callback: () => void) => unknown } = {},
|
||||
): AnimationTimeline {
|
||||
for (const step of steps) {
|
||||
if (
|
||||
!Number.isFinite(step.durationMs) ||
|
||||
step.durationMs < 0 ||
|
||||
!Number.isFinite(step.delayMs ?? 0) ||
|
||||
(step.delayMs ?? 0) < 0
|
||||
)
|
||||
throw new RangeError("Timeline durations and delays must be non-negative");
|
||||
}
|
||||
const now = options.now ?? (() => performance.now());
|
||||
const frame = options.frame ?? ((callback) => requestAnimationFrame(callback));
|
||||
let controller: AbortController | null = null;
|
||||
const timeline: AnimationTimeline = {
|
||||
get running() {
|
||||
return controller !== null;
|
||||
},
|
||||
cancel(reason = new Error("Timeline cancelled")) {
|
||||
controller?.abort(reason);
|
||||
},
|
||||
async play(playOptions = {}) {
|
||||
controller?.abort(new Error("Timeline restarted"));
|
||||
controller = new AbortController();
|
||||
const local = controller;
|
||||
playOptions.signal?.addEventListener("abort", () => local.abort(playOptions.signal!.reason), {
|
||||
once: true,
|
||||
});
|
||||
const ordered = playOptions.reverse ? [...steps].reverse() : steps;
|
||||
try {
|
||||
for (const step of ordered) {
|
||||
const delay = step.delayMs ?? 0;
|
||||
if (delay)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delay);
|
||||
local.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(local.signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
const started = now();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tick = () => {
|
||||
if (local.signal.aborted) {
|
||||
reject(local.signal.reason);
|
||||
return;
|
||||
}
|
||||
const raw =
|
||||
step.durationMs === 0 ? 1 : Math.min(1, (now() - started) / step.durationMs);
|
||||
const progress = playOptions.reverse ? 1 - raw : raw;
|
||||
step.update(step.easing?.(progress) ?? progress);
|
||||
if (raw >= 1) resolve();
|
||||
else frame(tick);
|
||||
};
|
||||
tick();
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (controller === local) controller = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
return timeline;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** @wrnexus/reactive — fine-grained reactive primitives. */
|
||||
/** @wrnexus/reactive — fine-grained reactive primitives, history, URL state, context and portals. */
|
||||
export type { Cleanup, ReadonlySignal, Signal, Subscriber, Unsubscribe } from "./signal.ts";
|
||||
export { batch, computed, effect, signal, untrack } from "./signal.ts";
|
||||
export { watch, resource, createScope } from "./resource.ts";
|
||||
@@ -9,3 +9,18 @@ export type {
|
||||
ResourceStatus,
|
||||
ReactiveScope,
|
||||
} from "./resource.ts";
|
||||
export {
|
||||
createContextProvider,
|
||||
historySignal,
|
||||
mountPortal,
|
||||
transition,
|
||||
createTimeline,
|
||||
urlSignal,
|
||||
} from "./advanced.ts";
|
||||
export type {
|
||||
HistorySignal,
|
||||
ReactiveContext,
|
||||
UrlStateOptions,
|
||||
TimelineStep,
|
||||
AnimationTimeline,
|
||||
} from "./advanced.ts";
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import {
|
||||
createContextProvider,
|
||||
historySignal,
|
||||
mountPortal,
|
||||
transition,
|
||||
createTimeline,
|
||||
urlSignal,
|
||||
} from "../src/index.ts";
|
||||
|
||||
describe("advanced reactive primitives", () => {
|
||||
test("supports bounded undo and redo", () => {
|
||||
const value = historySignal(0, { limit: 2 });
|
||||
value.set(1);
|
||||
value.set(2);
|
||||
value.set(3);
|
||||
expect(value.undo()).toBe(true);
|
||||
expect(value.get()).toBe(2);
|
||||
expect(value.undo()).toBe(true);
|
||||
expect(value.get()).toBe(1);
|
||||
expect(value.undo()).toBe(false);
|
||||
expect(value.redo()).toBe(true);
|
||||
expect(value.get()).toBe(2);
|
||||
});
|
||||
test("synchronizes URL state through an explicit adapter", () => {
|
||||
let changed = "";
|
||||
const value = urlSignal("all", {
|
||||
url: new URL("https://example.test/?filter=open"),
|
||||
parameter: "filter",
|
||||
parse: (input) => input ?? "all",
|
||||
replace: (url) => {
|
||||
changed = url.href;
|
||||
},
|
||||
});
|
||||
expect(value.get()).toBe("open");
|
||||
value.set("closed");
|
||||
expect(changed).toBe("https://example.test/?filter=closed");
|
||||
});
|
||||
test("provides scoped context and cleans up portals and transitions", async () => {
|
||||
const context = createContextProvider("default");
|
||||
expect(context.provide("tenant", () => context.use())).toBe("tenant");
|
||||
expect(context.use()).toBe("default");
|
||||
const window = new Window();
|
||||
const target = window.document.createElement("div");
|
||||
const cleanup = mountPortal("hello", target as unknown as Element);
|
||||
expect(target.textContent).toBe("hello");
|
||||
cleanup();
|
||||
expect(target.textContent).toBe("");
|
||||
await transition(() => target.setAttribute("ready", ""), {
|
||||
target: target as unknown as Element,
|
||||
className: "enter",
|
||||
});
|
||||
expect(target.classList.contains("enter")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("animation timelines sequence bounded forward and reverse steps", async () => {
|
||||
let clock = 0;
|
||||
const values: number[] = [];
|
||||
const timeline = createTimeline(
|
||||
[{ durationMs: 10, update: (progress) => values.push(progress) }],
|
||||
{
|
||||
now: () => clock,
|
||||
frame(callback) {
|
||||
clock += 5;
|
||||
queueMicrotask(callback);
|
||||
},
|
||||
},
|
||||
);
|
||||
await timeline.play();
|
||||
expect(values.at(-1)).toBe(1);
|
||||
values.length = 0;
|
||||
clock = 0;
|
||||
await timeline.play({ reverse: true });
|
||||
expect(values[0]).toBe(1);
|
||||
expect(values.at(-1)).toBe(0);
|
||||
expect(timeline.running).toBeFalse();
|
||||
});
|
||||
Reference in New Issue
Block a user