kayforms 0.1.1
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 +21 -0
- package/README.md +337 -0
- package/examples/react-demo/README.md +337 -0
- package/examples/react-demo/eslint.config.js +22 -0
- package/examples/react-demo/index.html +13 -0
- package/examples/react-demo/package.json +33 -0
- package/examples/react-demo/public/apple-touch-icon.png +0 -0
- package/examples/react-demo/public/favicon-96x96.png +0 -0
- package/examples/react-demo/public/favicon.ico +0 -0
- package/examples/react-demo/public/favicon.svg +17 -0
- package/examples/react-demo/public/icons.svg +24 -0
- package/examples/react-demo/public/site.webmanifest +21 -0
- package/examples/react-demo/public/web-app-manifest-192x192.png +0 -0
- package/examples/react-demo/public/web-app-manifest-512x512.png +0 -0
- package/examples/react-demo/src/App.css +184 -0
- package/examples/react-demo/src/App.tsx +825 -0
- package/examples/react-demo/src/assets/hero.png +0 -0
- package/examples/react-demo/src/assets/react.svg +1 -0
- package/examples/react-demo/src/assets/vite.svg +1 -0
- package/examples/react-demo/src/index.css +627 -0
- package/examples/react-demo/src/main.tsx +10 -0
- package/examples/react-demo/tsconfig.app.json +25 -0
- package/examples/react-demo/tsconfig.json +7 -0
- package/examples/react-demo/tsconfig.node.json +24 -0
- package/examples/react-demo/vite.config.ts +7 -0
- package/kayforms.jpg +0 -0
- package/package.json +26 -0
- package/packages/angular/package.json +43 -0
- package/packages/angular/src/index.ts +198 -0
- package/packages/angular/tsconfig.json +8 -0
- package/packages/angular/tsup.config.ts +17 -0
- package/packages/core/README.md +337 -0
- package/packages/core/package.json +37 -0
- package/packages/core/src/batch.ts +106 -0
- package/packages/core/src/devtools.ts +329 -0
- package/packages/core/src/field.ts +167 -0
- package/packages/core/src/form.ts +448 -0
- package/packages/core/src/index.ts +71 -0
- package/packages/core/src/registry.ts +126 -0
- package/packages/core/src/signal.ts +399 -0
- package/packages/core/src/time-travel.ts +275 -0
- package/packages/core/src/validation.ts +243 -0
- package/packages/core/tsconfig.json +8 -0
- package/packages/core/tsup.config.ts +16 -0
- package/packages/devtools/extension/background.js +35 -0
- package/packages/devtools/extension/content-script.js +10 -0
- package/packages/devtools/extension/devtools.html +9 -0
- package/packages/devtools/extension/devtools.js +8 -0
- package/packages/devtools/extension/manifest.json +19 -0
- package/packages/devtools/extension/panel.css +505 -0
- package/packages/devtools/extension/panel.html +108 -0
- package/packages/devtools/extension/panel.js +354 -0
- package/packages/devtools/package.json +38 -0
- package/packages/devtools/src/index.ts +95 -0
- package/packages/devtools/src/panel.ts +226 -0
- package/packages/devtools/src/styles.ts +422 -0
- package/packages/devtools/src/timeline.ts +283 -0
- package/packages/devtools/tsconfig.json +8 -0
- package/packages/devtools/tsup.config.ts +17 -0
- package/packages/react/package.json +46 -0
- package/packages/react/src/index.ts +279 -0
- package/packages/react/tsconfig.json +8 -0
- package/packages/react/tsup.config.ts +17 -0
- package/packages/solid/package.json +42 -0
- package/packages/solid/src/index.ts +206 -0
- package/packages/solid/tsconfig.json +8 -0
- package/packages/solid/tsup.config.ts +17 -0
- package/packages/svelte/package.json +42 -0
- package/packages/svelte/src/index.ts +199 -0
- package/packages/svelte/tsconfig.json +8 -0
- package/packages/svelte/tsup.config.ts +17 -0
- package/packages/vanilla/package.json +38 -0
- package/packages/vanilla/src/index.ts +254 -0
- package/packages/vanilla/tsconfig.json +8 -0
- package/packages/vanilla/tsup.config.ts +17 -0
- package/packages/vue/package.json +42 -0
- package/packages/vue/src/index.ts +217 -0
- package/packages/vue/tsconfig.json +8 -0
- package/packages/vue/tsup.config.ts +17 -0
- package/pnpm-workspace.yaml +3 -0
- package/tsconfig.base.json +21 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — Signal Engine
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Minimal, high-performance reactive primitives with automatic dependency
|
|
5
|
+
// tracking. Inspired by Solid, Preact Signals, and the TC39 Signals proposal.
|
|
6
|
+
//
|
|
7
|
+
// Architecture:
|
|
8
|
+
// Signal<T> — writable reactive value
|
|
9
|
+
// Computed<T> — read-only derived value (lazy, cached, glitch-free)
|
|
10
|
+
// Effect — side-effect that auto-tracks dependencies
|
|
11
|
+
// batch() — defer notifications until all writes complete
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Types
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** A reactive value that can be read and written */
|
|
19
|
+
export interface Signal<T> {
|
|
20
|
+
/** Read the current value (tracks dependency if inside a computed/effect) */
|
|
21
|
+
readonly value: T;
|
|
22
|
+
/** Write a new value and notify subscribers */
|
|
23
|
+
set(next: T): void;
|
|
24
|
+
/** Read without tracking (useful in event handlers) */
|
|
25
|
+
peek(): T;
|
|
26
|
+
/** Subscribe to changes (returns unsubscribe function) */
|
|
27
|
+
subscribe(listener: (value: T) => void): () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A read-only derived value that recomputes when dependencies change */
|
|
31
|
+
export interface Computed<T> {
|
|
32
|
+
/** Read the current value (recomputes if stale, tracks dependency) */
|
|
33
|
+
readonly value: T;
|
|
34
|
+
/** Read without tracking */
|
|
35
|
+
peek(): T;
|
|
36
|
+
/** Subscribe to changes */
|
|
37
|
+
subscribe(listener: (value: T) => void): () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Cleanup function returned by effects */
|
|
41
|
+
export type EffectCleanup = () => void;
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Internals
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/** Base class for all reactive nodes in the dependency graph */
|
|
48
|
+
interface ReactiveNode {
|
|
49
|
+
/** Nodes that depend on this node */
|
|
50
|
+
subscribers: Set<ReactiveNode>;
|
|
51
|
+
/** Nodes this node depends on */
|
|
52
|
+
dependencies: Set<ReactiveNode>;
|
|
53
|
+
/** Called when a dependency changes */
|
|
54
|
+
notify(): void;
|
|
55
|
+
/** Unique ID for debugging */
|
|
56
|
+
_id: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let nodeIdCounter = 0;
|
|
60
|
+
|
|
61
|
+
/** Stack of currently-evaluating subscribers (for automatic tracking) */
|
|
62
|
+
const subscriberStack: ReactiveNode[] = [];
|
|
63
|
+
|
|
64
|
+
/** Current batch depth (0 = not batching) */
|
|
65
|
+
let batchDepth = 0;
|
|
66
|
+
|
|
67
|
+
/** Pending notifications deferred during a batch */
|
|
68
|
+
const batchQueue = new Set<ReactiveNode>();
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Dependency Tracking
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function getCurrentSubscriber(): ReactiveNode | undefined {
|
|
75
|
+
return subscriberStack[subscriberStack.length - 1];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function trackDependency(source: ReactiveNode): void {
|
|
79
|
+
const subscriber = getCurrentSubscriber();
|
|
80
|
+
if (subscriber) {
|
|
81
|
+
source.subscribers.add(subscriber);
|
|
82
|
+
subscriber.dependencies.add(source);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function notifySubscribers(source: ReactiveNode): void {
|
|
87
|
+
// Copy to avoid mutation during iteration
|
|
88
|
+
const subs = [...source.subscribers];
|
|
89
|
+
for (const sub of subs) {
|
|
90
|
+
if (batchDepth > 0) {
|
|
91
|
+
batchQueue.add(sub);
|
|
92
|
+
} else {
|
|
93
|
+
sub.notify();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function cleanupDependencies(node: ReactiveNode): void {
|
|
99
|
+
for (const dep of node.dependencies) {
|
|
100
|
+
dep.subscribers.delete(node);
|
|
101
|
+
}
|
|
102
|
+
node.dependencies.clear();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Signal — Writable reactive value
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
class SignalImpl<T> implements ReactiveNode {
|
|
110
|
+
_id: number;
|
|
111
|
+
subscribers = new Set<ReactiveNode>();
|
|
112
|
+
dependencies = new Set<ReactiveNode>(); // Always empty for signals
|
|
113
|
+
private _value: T;
|
|
114
|
+
private _listeners = new Set<(value: T) => void>();
|
|
115
|
+
|
|
116
|
+
constructor(initial: T) {
|
|
117
|
+
this._id = nodeIdCounter++;
|
|
118
|
+
this._value = initial;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
get value(): T {
|
|
122
|
+
trackDependency(this);
|
|
123
|
+
return this._value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
set(next: T): void {
|
|
127
|
+
if (Object.is(this._value, next)) return;
|
|
128
|
+
this._value = next;
|
|
129
|
+
notifySubscribers(this);
|
|
130
|
+
// Notify plain listeners
|
|
131
|
+
for (const listener of this._listeners) {
|
|
132
|
+
listener(next);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
peek(): T {
|
|
137
|
+
return this._value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
subscribe(listener: (value: T) => void): () => void {
|
|
141
|
+
this._listeners.add(listener);
|
|
142
|
+
return () => {
|
|
143
|
+
this._listeners.delete(listener);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
notify(): void {
|
|
148
|
+
// Signals don't get notified — they are leaf sources
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Computed — Derived reactive value (lazy + cached)
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
const enum ComputedState {
|
|
157
|
+
Clean,
|
|
158
|
+
Dirty,
|
|
159
|
+
Computing,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
class ComputedImpl<T> implements ReactiveNode {
|
|
163
|
+
_id: number;
|
|
164
|
+
subscribers = new Set<ReactiveNode>();
|
|
165
|
+
dependencies = new Set<ReactiveNode>();
|
|
166
|
+
private _value: T | undefined = undefined;
|
|
167
|
+
private _fn: () => T;
|
|
168
|
+
private _state: ComputedState = ComputedState.Dirty;
|
|
169
|
+
private _listeners = new Set<(value: T) => void>();
|
|
170
|
+
|
|
171
|
+
constructor(fn: () => T) {
|
|
172
|
+
this._id = nodeIdCounter++;
|
|
173
|
+
this._fn = fn;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
get value(): T {
|
|
177
|
+
trackDependency(this);
|
|
178
|
+
if (this._state !== ComputedState.Clean) {
|
|
179
|
+
this._recompute();
|
|
180
|
+
}
|
|
181
|
+
return this._value as T;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
peek(): T {
|
|
185
|
+
if (this._state !== ComputedState.Clean) {
|
|
186
|
+
this._recompute();
|
|
187
|
+
}
|
|
188
|
+
return this._value as T;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
subscribe(listener: (value: T) => void): () => void {
|
|
192
|
+
this._listeners.add(listener);
|
|
193
|
+
return () => {
|
|
194
|
+
this._listeners.delete(listener);
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
notify(): void {
|
|
199
|
+
const oldState = this._state;
|
|
200
|
+
this._state = ComputedState.Dirty;
|
|
201
|
+
|
|
202
|
+
// Only propagate if we were clean (avoid cascading re-notifications)
|
|
203
|
+
if (oldState === ComputedState.Clean) {
|
|
204
|
+
// Eagerly recompute to check if value actually changed
|
|
205
|
+
const oldValue = this._value;
|
|
206
|
+
this._recompute();
|
|
207
|
+
if (!Object.is(oldValue, this._value)) {
|
|
208
|
+
notifySubscribers(this);
|
|
209
|
+
for (const listener of this._listeners) {
|
|
210
|
+
listener(this._value as T);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private _recompute(): void {
|
|
217
|
+
if (this._state === ComputedState.Computing) {
|
|
218
|
+
throw new Error(
|
|
219
|
+
`[kayforms] Circular dependency detected in computed (node #${this._id})`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
this._state = ComputedState.Computing;
|
|
223
|
+
cleanupDependencies(this);
|
|
224
|
+
|
|
225
|
+
subscriberStack.push(this);
|
|
226
|
+
try {
|
|
227
|
+
this._value = this._fn();
|
|
228
|
+
} finally {
|
|
229
|
+
subscriberStack.pop();
|
|
230
|
+
this._state = ComputedState.Clean;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Effect — Side effect with automatic dependency tracking
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
class EffectImpl implements ReactiveNode {
|
|
240
|
+
_id: number;
|
|
241
|
+
subscribers = new Set<ReactiveNode>();
|
|
242
|
+
dependencies = new Set<ReactiveNode>();
|
|
243
|
+
private _fn: () => void | EffectCleanup;
|
|
244
|
+
private _cleanup: EffectCleanup | undefined;
|
|
245
|
+
private _disposed = false;
|
|
246
|
+
|
|
247
|
+
constructor(fn: () => void | EffectCleanup) {
|
|
248
|
+
this._id = nodeIdCounter++;
|
|
249
|
+
this._fn = fn;
|
|
250
|
+
// Run immediately to establish initial dependencies
|
|
251
|
+
this._run();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
notify(): void {
|
|
255
|
+
if (!this._disposed) {
|
|
256
|
+
this._run();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private _run(): void {
|
|
261
|
+
// Run previous cleanup
|
|
262
|
+
if (this._cleanup) {
|
|
263
|
+
this._cleanup();
|
|
264
|
+
this._cleanup = undefined;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
cleanupDependencies(this);
|
|
268
|
+
|
|
269
|
+
subscriberStack.push(this);
|
|
270
|
+
try {
|
|
271
|
+
const result = this._fn();
|
|
272
|
+
if (typeof result === "function") {
|
|
273
|
+
this._cleanup = result;
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
subscriberStack.pop();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
dispose(): void {
|
|
281
|
+
this._disposed = true;
|
|
282
|
+
if (this._cleanup) {
|
|
283
|
+
this._cleanup();
|
|
284
|
+
this._cleanup = undefined;
|
|
285
|
+
}
|
|
286
|
+
cleanupDependencies(this);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Public API
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Create a writable reactive signal.
|
|
296
|
+
*
|
|
297
|
+
* @example
|
|
298
|
+
* ```ts
|
|
299
|
+
* const count = createSignal(0);
|
|
300
|
+
* console.log(count.value); // 0
|
|
301
|
+
* count.set(1);
|
|
302
|
+
* console.log(count.value); // 1
|
|
303
|
+
* ```
|
|
304
|
+
*/
|
|
305
|
+
export function createSignal<T>(initial: T): Signal<T> {
|
|
306
|
+
return new SignalImpl(initial);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Create a read-only computed signal that derives its value from other signals.
|
|
311
|
+
* Recomputes lazily only when dependencies change.
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* ```ts
|
|
315
|
+
* const firstName = createSignal('Kay');
|
|
316
|
+
* const lastName = createSignal('Forms');
|
|
317
|
+
* const fullName = createComputed(() => `${firstName.value} ${lastName.value}`);
|
|
318
|
+
* console.log(fullName.value); // 'Kay Forms'
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
export function createComputed<T>(fn: () => T): Computed<T> {
|
|
322
|
+
return new ComputedImpl(fn);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Create a side effect that automatically re-runs when its dependencies change.
|
|
327
|
+
* Returns a dispose function to stop the effect.
|
|
328
|
+
*
|
|
329
|
+
* @example
|
|
330
|
+
* ```ts
|
|
331
|
+
* const name = createSignal('World');
|
|
332
|
+
* const dispose = createEffect(() => {
|
|
333
|
+
* console.log(`Hello, ${name.value}!`);
|
|
334
|
+
* return () => console.log('cleanup');
|
|
335
|
+
* });
|
|
336
|
+
* name.set('Kayforms'); // logs cleanup, then 'Hello, Kayforms!'
|
|
337
|
+
* dispose(); // stops tracking
|
|
338
|
+
* ```
|
|
339
|
+
*/
|
|
340
|
+
export function createEffect(fn: () => void | EffectCleanup): EffectCleanup {
|
|
341
|
+
const effect = new EffectImpl(fn);
|
|
342
|
+
return () => effect.dispose();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Batch multiple signal writes into a single notification flush.
|
|
347
|
+
* Subscribers are only notified after the batch completes.
|
|
348
|
+
*
|
|
349
|
+
* @example
|
|
350
|
+
* ```ts
|
|
351
|
+
* const a = createSignal(1);
|
|
352
|
+
* const b = createSignal(2);
|
|
353
|
+
* batch(() => {
|
|
354
|
+
* a.set(10);
|
|
355
|
+
* b.set(20);
|
|
356
|
+
* // No notifications fired yet
|
|
357
|
+
* });
|
|
358
|
+
* // All subscribers notified once here
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
export function batch(fn: () => void): void {
|
|
362
|
+
batchDepth++;
|
|
363
|
+
try {
|
|
364
|
+
fn();
|
|
365
|
+
} finally {
|
|
366
|
+
batchDepth--;
|
|
367
|
+
if (batchDepth === 0) {
|
|
368
|
+
// Flush all pending notifications
|
|
369
|
+
const pending = [...batchQueue];
|
|
370
|
+
batchQueue.clear();
|
|
371
|
+
for (const node of pending) {
|
|
372
|
+
node.notify();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Run a function without tracking any signal reads as dependencies.
|
|
380
|
+
* Useful for reading signals inside event handlers or effects without
|
|
381
|
+
* creating unwanted subscriptions.
|
|
382
|
+
*
|
|
383
|
+
* @example
|
|
384
|
+
* ```ts
|
|
385
|
+
* createEffect(() => {
|
|
386
|
+
* const tracked = name.value; // tracked
|
|
387
|
+
* const untracked = untrack(() => other.value); // NOT tracked
|
|
388
|
+
* });
|
|
389
|
+
* ```
|
|
390
|
+
*/
|
|
391
|
+
export function untrack<T>(fn: () => T): T {
|
|
392
|
+
// Temporarily clear the subscriber stack so no dependencies are tracked
|
|
393
|
+
const saved = subscriberStack.splice(0, subscriberStack.length);
|
|
394
|
+
try {
|
|
395
|
+
return fn();
|
|
396
|
+
} finally {
|
|
397
|
+
subscriberStack.push(...saved);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — Time-Travel Debugging
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Records form history and exposes undo/redo/jumpTo APIs.
|
|
5
|
+
// Fits within a <1KB bundle budget.
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
import { createEffect, batch } from "./signal";
|
|
9
|
+
import { type FormStore } from "./form";
|
|
10
|
+
|
|
11
|
+
export interface TimeTravelEntry {
|
|
12
|
+
timestamp: number;
|
|
13
|
+
values: any;
|
|
14
|
+
errors: any;
|
|
15
|
+
formLevelErrors?: any;
|
|
16
|
+
changedField?: string;
|
|
17
|
+
touched?: Record<string, boolean>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TimeTravelOptions {
|
|
21
|
+
maxHistory?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TimeTravelMethods {
|
|
25
|
+
undo(): void;
|
|
26
|
+
redo(): void;
|
|
27
|
+
jumpTo(index: number): void;
|
|
28
|
+
clearHistory(): void;
|
|
29
|
+
getHistory(): TimeTravelEntry[];
|
|
30
|
+
getCursor(): number;
|
|
31
|
+
importHistory(importedHistory: TimeTravelEntry[]): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Global hook for Chrome Extension / DevTools bridge
|
|
35
|
+
if (typeof window !== "undefined") {
|
|
36
|
+
const g = window as any;
|
|
37
|
+
if (!g.__KAYFORMS_DEVTOOLS__) {
|
|
38
|
+
const listeners = new Set<() => void>();
|
|
39
|
+
g.__KAYFORMS_DEVTOOLS__ = {
|
|
40
|
+
forms: {},
|
|
41
|
+
listeners,
|
|
42
|
+
onHistoryChange(listener: () => void) {
|
|
43
|
+
listeners.add(listener);
|
|
44
|
+
return () => listeners.delete(listener);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Enable time-travel debugging on a FormStore instance.
|
|
52
|
+
* Attaches undo, redo, jumpTo, clearHistory, and getHistory methods to the form.
|
|
53
|
+
*/
|
|
54
|
+
export function enableTimeTravel<T extends Record<string, unknown>>(
|
|
55
|
+
form: FormStore<T>,
|
|
56
|
+
options: TimeTravelOptions = {}
|
|
57
|
+
): FormStore<T> & TimeTravelMethods {
|
|
58
|
+
const maxHistory = options.maxHistory ?? 100;
|
|
59
|
+
let history: TimeTravelEntry[] = [];
|
|
60
|
+
let cursor = -1;
|
|
61
|
+
let isRestoring = false;
|
|
62
|
+
|
|
63
|
+
// Tiny dot-path differences finder
|
|
64
|
+
function findDiffPath(a: any, b: any, prefix = ""): string | undefined {
|
|
65
|
+
if (a === b) return undefined;
|
|
66
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
|
67
|
+
return prefix;
|
|
68
|
+
}
|
|
69
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
70
|
+
for (const key of keys) {
|
|
71
|
+
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
|
72
|
+
const diff = findDiffPath(a[key], b[key], nextPrefix);
|
|
73
|
+
if (diff !== undefined) return diff;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Tiny nested getByPath
|
|
79
|
+
function getByPath(obj: any, path: string): any {
|
|
80
|
+
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Effect tracks changes to values, errors, and touched signals
|
|
84
|
+
const unsubscribe = createEffect(() => {
|
|
85
|
+
const vals = form.values.value;
|
|
86
|
+
const errs = form.errors.value;
|
|
87
|
+
|
|
88
|
+
const touched: Record<string, boolean> = {};
|
|
89
|
+
const fields = (form as any)._fields;
|
|
90
|
+
if (fields) {
|
|
91
|
+
for (const [path, field] of fields) {
|
|
92
|
+
touched[path] = field.touched.value;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isRestoring) return;
|
|
97
|
+
|
|
98
|
+
const clonedVals = structuredClone(vals);
|
|
99
|
+
const clonedErrs = structuredClone(errs);
|
|
100
|
+
const formLevelSignal = (form as any)._formLevelErrors;
|
|
101
|
+
const formLevelErrors = formLevelSignal ? structuredClone(formLevelSignal.value) : {};
|
|
102
|
+
|
|
103
|
+
const prevEntry = history[history.length - 1];
|
|
104
|
+
let changedField: string | undefined = undefined;
|
|
105
|
+
|
|
106
|
+
if (prevEntry) {
|
|
107
|
+
changedField = findDiffPath(prevEntry.values, clonedVals);
|
|
108
|
+
if (
|
|
109
|
+
changedField === undefined &&
|
|
110
|
+
JSON.stringify(prevEntry.errors) === JSON.stringify(clonedErrs) &&
|
|
111
|
+
JSON.stringify(prevEntry.touched) === JSON.stringify(touched)
|
|
112
|
+
) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const entry: TimeTravelEntry = {
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
values: clonedVals,
|
|
120
|
+
errors: clonedErrs,
|
|
121
|
+
formLevelErrors,
|
|
122
|
+
touched,
|
|
123
|
+
changedField,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
if (cursor >= 0 && cursor < history.length - 1) {
|
|
127
|
+
history = history.slice(0, cursor + 1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
history.push(entry);
|
|
131
|
+
if (history.length > maxHistory) {
|
|
132
|
+
history.shift();
|
|
133
|
+
}
|
|
134
|
+
cursor = history.length - 1;
|
|
135
|
+
|
|
136
|
+
// Notify listeners and DevTools Chrome Extension
|
|
137
|
+
if (typeof window !== "undefined") {
|
|
138
|
+
const g = window as any;
|
|
139
|
+
if (g.__KAYFORMS_DEVTOOLS__) {
|
|
140
|
+
for (const l of g.__KAYFORMS_DEVTOOLS__.listeners) {
|
|
141
|
+
try { l(); } catch (_) {}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
window.dispatchEvent(
|
|
145
|
+
new CustomEvent("kayforms:history-change", {
|
|
146
|
+
detail: { formId: form.id || "default", cursor, historyLength: history.length },
|
|
147
|
+
})
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function jumpTo(index: number): void {
|
|
153
|
+
if (index < 0 || index >= history.length) return;
|
|
154
|
+
cursor = index;
|
|
155
|
+
const entry = history[index];
|
|
156
|
+
|
|
157
|
+
isRestoring = true;
|
|
158
|
+
batch(() => {
|
|
159
|
+
// 1. Restore form level values
|
|
160
|
+
form.values.set(structuredClone(entry.values));
|
|
161
|
+
|
|
162
|
+
// 2. Restore form-level errors
|
|
163
|
+
const formLevelSignal = (form as any)._formLevelErrors;
|
|
164
|
+
if (formLevelSignal && entry.formLevelErrors) {
|
|
165
|
+
formLevelSignal.set(structuredClone(entry.formLevelErrors));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 3. Restore field specific values, errors, touched
|
|
169
|
+
const fields = (form as any)._fields;
|
|
170
|
+
if (fields) {
|
|
171
|
+
for (const [path, field] of fields) {
|
|
172
|
+
const val = getByPath(entry.values, path);
|
|
173
|
+
field.value.set(val);
|
|
174
|
+
|
|
175
|
+
const err = getByPath(entry.errors, path);
|
|
176
|
+
field.error.set(err);
|
|
177
|
+
|
|
178
|
+
const isTouched = entry.touched?.[path] ?? false;
|
|
179
|
+
field.touched.set(isTouched);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
isRestoring = false;
|
|
184
|
+
|
|
185
|
+
if (typeof window !== "undefined") {
|
|
186
|
+
window.dispatchEvent(
|
|
187
|
+
new CustomEvent("kayforms:history-change", {
|
|
188
|
+
detail: { formId: form.id || "default", cursor, historyLength: history.length },
|
|
189
|
+
})
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const methods: TimeTravelMethods = {
|
|
195
|
+
undo() {
|
|
196
|
+
if (cursor > 0) {
|
|
197
|
+
jumpTo(cursor - 1);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
redo() {
|
|
201
|
+
if (cursor < history.length - 1) {
|
|
202
|
+
jumpTo(cursor + 1);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
jumpTo,
|
|
206
|
+
clearHistory() {
|
|
207
|
+
history = [];
|
|
208
|
+
cursor = -1;
|
|
209
|
+
|
|
210
|
+
const fields = (form as any)._fields;
|
|
211
|
+
const touched: Record<string, boolean> = {};
|
|
212
|
+
if (fields) {
|
|
213
|
+
for (const [path, field] of fields) {
|
|
214
|
+
touched[path] = field.touched.peek();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
history.push({
|
|
219
|
+
timestamp: Date.now(),
|
|
220
|
+
values: structuredClone(form.values.peek()),
|
|
221
|
+
errors: structuredClone(form.errors.peek()),
|
|
222
|
+
formLevelErrors: (form as any)._formLevelErrors ? structuredClone((form as any)._formLevelErrors.peek()) : {},
|
|
223
|
+
touched,
|
|
224
|
+
});
|
|
225
|
+
cursor = 0;
|
|
226
|
+
|
|
227
|
+
if (typeof window !== "undefined") {
|
|
228
|
+
window.dispatchEvent(
|
|
229
|
+
new CustomEvent("kayforms:history-change", {
|
|
230
|
+
detail: { formId: form.id || "default", cursor, historyLength: history.length },
|
|
231
|
+
})
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
getHistory() {
|
|
236
|
+
return history;
|
|
237
|
+
},
|
|
238
|
+
getCursor() {
|
|
239
|
+
return cursor;
|
|
240
|
+
},
|
|
241
|
+
importHistory(importedHistory) {
|
|
242
|
+
if (!Array.isArray(importedHistory) || importedHistory.length === 0) return;
|
|
243
|
+
history = importedHistory;
|
|
244
|
+
cursor = history.length - 1;
|
|
245
|
+
jumpTo(cursor);
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// Attach methods to form instance
|
|
250
|
+
Object.assign(form, methods);
|
|
251
|
+
|
|
252
|
+
// Register with global devtools registry
|
|
253
|
+
const formId = form.id || "default";
|
|
254
|
+
if (typeof window !== "undefined") {
|
|
255
|
+
const g = window as any;
|
|
256
|
+
if (g.__KAYFORMS_DEVTOOLS__) {
|
|
257
|
+
g.__KAYFORMS_DEVTOOLS__.forms[formId] = form;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Hook form dispose
|
|
262
|
+
const originalDispose = form.dispose;
|
|
263
|
+
form.dispose = () => {
|
|
264
|
+
unsubscribe();
|
|
265
|
+
if (typeof window !== "undefined") {
|
|
266
|
+
const g = window as any;
|
|
267
|
+
if (g.__KAYFORMS_DEVTOOLS__) {
|
|
268
|
+
delete g.__KAYFORMS_DEVTOOLS__.forms[formId];
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
originalDispose.call(form);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return form as FormStore<T> & TimeTravelMethods;
|
|
275
|
+
}
|