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,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kayforms/core",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Framework-agnostic reactive form engine built on signals",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsup",
|
|
26
|
+
"dev": "tsup --watch",
|
|
27
|
+
"clean": "rimraf dist",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"tsup": "^8.0.0",
|
|
32
|
+
"typescript": "^5.5.0",
|
|
33
|
+
"rimraf": "^5.0.0"
|
|
34
|
+
},
|
|
35
|
+
"sideEffects": false,
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — Smart Batching Scheduler
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Provides intelligent scheduling for different types of form operations:
|
|
5
|
+
// - Sync validations → run immediately (microtask)
|
|
6
|
+
// - Async validations → debounced (configurable delay)
|
|
7
|
+
// - Derived/computed fields → lazy (on read)
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export interface SchedulerOptions {
|
|
11
|
+
/** Debounce delay for async validators in ms (default: 300) */
|
|
12
|
+
asyncDebounce?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface Scheduler {
|
|
16
|
+
/** Schedule a synchronous task to run in the current microtask */
|
|
17
|
+
immediate(fn: () => void): void;
|
|
18
|
+
/** Schedule an async task with debouncing by key */
|
|
19
|
+
debounced(key: string, fn: () => void | Promise<void>, delay?: number): void;
|
|
20
|
+
/** Cancel a pending debounced task by key */
|
|
21
|
+
cancel(key: string): void;
|
|
22
|
+
/** Cancel all pending tasks */
|
|
23
|
+
cancelAll(): void;
|
|
24
|
+
/** Flush all pending tasks immediately */
|
|
25
|
+
flush(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a smart batching scheduler for form operations.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const scheduler = createScheduler({ asyncDebounce: 300 });
|
|
34
|
+
* scheduler.immediate(() => runSyncValidation(field));
|
|
35
|
+
* scheduler.debounced('email', () => checkEmailExists(email), 500);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function createScheduler(options: SchedulerOptions = {}): Scheduler {
|
|
39
|
+
const defaultDelay = options.asyncDebounce ?? 300;
|
|
40
|
+
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
41
|
+
const pendingMicrotasks: (() => void)[] = [];
|
|
42
|
+
let microtaskScheduled = false;
|
|
43
|
+
|
|
44
|
+
function flushMicrotasks(): void {
|
|
45
|
+
microtaskScheduled = false;
|
|
46
|
+
const tasks = pendingMicrotasks.splice(0, pendingMicrotasks.length);
|
|
47
|
+
for (const task of tasks) {
|
|
48
|
+
task();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
immediate(fn: () => void): void {
|
|
54
|
+
pendingMicrotasks.push(fn);
|
|
55
|
+
if (!microtaskScheduled) {
|
|
56
|
+
microtaskScheduled = true;
|
|
57
|
+
queueMicrotask(flushMicrotasks);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
debounced(
|
|
62
|
+
key: string,
|
|
63
|
+
fn: () => void | Promise<void>,
|
|
64
|
+
delay?: number
|
|
65
|
+
): void {
|
|
66
|
+
// Cancel any existing timer for this key
|
|
67
|
+
const existing = pendingTimers.get(key);
|
|
68
|
+
if (existing !== undefined) {
|
|
69
|
+
clearTimeout(existing);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const timer = setTimeout(() => {
|
|
73
|
+
pendingTimers.delete(key);
|
|
74
|
+
fn();
|
|
75
|
+
}, delay ?? defaultDelay);
|
|
76
|
+
|
|
77
|
+
pendingTimers.set(key, timer);
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
cancel(key: string): void {
|
|
81
|
+
const timer = pendingTimers.get(key);
|
|
82
|
+
if (timer !== undefined) {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
pendingTimers.delete(key);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
cancelAll(): void {
|
|
89
|
+
for (const timer of pendingTimers.values()) {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
pendingTimers.clear();
|
|
93
|
+
pendingMicrotasks.length = 0;
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
flush(): void {
|
|
97
|
+
// Flush microtasks
|
|
98
|
+
flushMicrotasks();
|
|
99
|
+
// Flush debounced
|
|
100
|
+
for (const [key, timer] of pendingTimers) {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
pendingTimers.delete(key);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — Time-Travel DevTools Bridge
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Records every form mutation and enables time-travel debugging:
|
|
5
|
+
// - Snapshot + delta history
|
|
6
|
+
// - Undo / redo / jumpTo
|
|
7
|
+
// - Subscribable for external consumers (in-page panel, Chrome extension)
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
import { createSignal, type Signal } from "./signal";
|
|
11
|
+
import { type FormStore } from "./form";
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Types
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
export interface HistoryEntry {
|
|
18
|
+
/** Monotonically increasing entry index */
|
|
19
|
+
index: number;
|
|
20
|
+
/** Unix timestamp in ms */
|
|
21
|
+
timestamp: number;
|
|
22
|
+
/** Action type */
|
|
23
|
+
action: string;
|
|
24
|
+
/** Field path (undefined for form-level actions like RESET, SUBMIT) */
|
|
25
|
+
path?: string;
|
|
26
|
+
/** Value before the action */
|
|
27
|
+
prevValue: unknown;
|
|
28
|
+
/** Value after the action */
|
|
29
|
+
nextValue: unknown;
|
|
30
|
+
/** Form ID (if registered) */
|
|
31
|
+
formId?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Full form snapshot at this point.
|
|
34
|
+
* Only stored every N entries (configurable) to save memory.
|
|
35
|
+
* `undefined` for delta-only entries.
|
|
36
|
+
*/
|
|
37
|
+
snapshot?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DevToolsBridge {
|
|
41
|
+
/** All recorded history entries */
|
|
42
|
+
readonly history: Signal<HistoryEntry[]>;
|
|
43
|
+
/** Current cursor position in the timeline (-1 = latest) */
|
|
44
|
+
readonly cursor: Signal<number>;
|
|
45
|
+
/** Whether time-travel is active (cursor !== -1) */
|
|
46
|
+
readonly isTimeTraveling: Signal<boolean>;
|
|
47
|
+
|
|
48
|
+
/** Jump to a specific history index */
|
|
49
|
+
jumpTo(index: number): void;
|
|
50
|
+
/** Undo the last action */
|
|
51
|
+
undo(): void;
|
|
52
|
+
/** Redo the next action */
|
|
53
|
+
redo(): void;
|
|
54
|
+
/** Resume live mode (cursor = latest) */
|
|
55
|
+
resume(): void;
|
|
56
|
+
/** Clear all history */
|
|
57
|
+
clear(): void;
|
|
58
|
+
/** Get the full form snapshot at a given history index */
|
|
59
|
+
getSnapshotAt(index: number): Record<string, unknown> | undefined;
|
|
60
|
+
|
|
61
|
+
/** Subscribe to new history entries */
|
|
62
|
+
subscribe(listener: (entry: HistoryEntry) => void): () => void;
|
|
63
|
+
/** Attach to a form store (starts recording) */
|
|
64
|
+
attach(form: FormStore): () => void;
|
|
65
|
+
/** Detach from all forms */
|
|
66
|
+
detach(): void;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface DevToolsConfig {
|
|
70
|
+
/** Store a full snapshot every N entries (default: 10) */
|
|
71
|
+
snapshotInterval?: number;
|
|
72
|
+
/** Maximum history entries to keep (default: 500) */
|
|
73
|
+
maxEntries?: number;
|
|
74
|
+
/** Enable in production? (default: false) */
|
|
75
|
+
enableInProduction?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Implementation
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create a DevTools bridge for time-travel debugging.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* import { createDevTools } from '@kayforms/core';
|
|
88
|
+
*
|
|
89
|
+
* const devtools = createDevTools();
|
|
90
|
+
* const form = createForm({ id: 'login', initialValues: { email: '' } });
|
|
91
|
+
* devtools.attach(form);
|
|
92
|
+
*
|
|
93
|
+
* // In your debug panel:
|
|
94
|
+
* devtools.history.value; // all recorded actions
|
|
95
|
+
* devtools.jumpTo(5); // rewind to entry 5
|
|
96
|
+
* devtools.undo(); // undo last action
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
export function createDevTools(config: DevToolsConfig = {}): DevToolsBridge {
|
|
100
|
+
const {
|
|
101
|
+
snapshotInterval = 10,
|
|
102
|
+
maxEntries = 500,
|
|
103
|
+
} = config;
|
|
104
|
+
|
|
105
|
+
// Skip in production unless explicitly enabled
|
|
106
|
+
const isEnabled =
|
|
107
|
+
config.enableInProduction ||
|
|
108
|
+
typeof process === "undefined" ||
|
|
109
|
+
(typeof process !== "undefined" &&
|
|
110
|
+
(process as unknown as Record<string, Record<string, string>>).env?.NODE_ENV !== "production");
|
|
111
|
+
|
|
112
|
+
const history = createSignal<HistoryEntry[]>([]);
|
|
113
|
+
const cursor = createSignal(-1);
|
|
114
|
+
const isTimeTraveling = createSignal(false);
|
|
115
|
+
|
|
116
|
+
let entryCounter = 0;
|
|
117
|
+
const listeners = new Set<(entry: HistoryEntry) => void>();
|
|
118
|
+
const attachedForms = new Map<string | undefined, () => void>();
|
|
119
|
+
const formStores = new Map<string | undefined, FormStore>();
|
|
120
|
+
|
|
121
|
+
function record(
|
|
122
|
+
action: string,
|
|
123
|
+
path: string | undefined,
|
|
124
|
+
prevValue: unknown,
|
|
125
|
+
nextValue: unknown,
|
|
126
|
+
formId: string | undefined,
|
|
127
|
+
form: FormStore
|
|
128
|
+
): void {
|
|
129
|
+
if (!isEnabled || isTimeTraveling.peek()) return;
|
|
130
|
+
|
|
131
|
+
const index = entryCounter++;
|
|
132
|
+
const entry: HistoryEntry = {
|
|
133
|
+
index,
|
|
134
|
+
timestamp: Date.now(),
|
|
135
|
+
action,
|
|
136
|
+
path,
|
|
137
|
+
prevValue,
|
|
138
|
+
nextValue,
|
|
139
|
+
formId,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Store full snapshot at intervals
|
|
143
|
+
if (index % snapshotInterval === 0) {
|
|
144
|
+
entry.snapshot = structuredClone(form.values.peek()) as Record<string, unknown>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const current = history.peek();
|
|
148
|
+
let next: HistoryEntry[];
|
|
149
|
+
|
|
150
|
+
// If we were time-traveling and new actions come in, truncate future
|
|
151
|
+
if (cursor.peek() >= 0 && cursor.peek() < current.length - 1) {
|
|
152
|
+
next = [...current.slice(0, cursor.peek() + 1), entry];
|
|
153
|
+
cursor.set(-1);
|
|
154
|
+
} else {
|
|
155
|
+
next = [...current, entry];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Evict oldest entries if over limit
|
|
159
|
+
if (next.length > maxEntries) {
|
|
160
|
+
next = next.slice(next.length - maxEntries);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
history.set(next);
|
|
164
|
+
|
|
165
|
+
// Notify listeners
|
|
166
|
+
for (const listener of listeners) {
|
|
167
|
+
listener(entry);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function reconstructFormState(formId: string | undefined, targetIndex: number) {
|
|
172
|
+
const entries = history.peek();
|
|
173
|
+
let values: Record<string, unknown> = {};
|
|
174
|
+
const touchedStates: Record<string, boolean> = {};
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i <= targetIndex; i++) {
|
|
177
|
+
const entry = entries[i];
|
|
178
|
+
if (entry.formId !== formId) continue;
|
|
179
|
+
|
|
180
|
+
if (entry.action === "INIT" || entry.action === "RESET") {
|
|
181
|
+
values = structuredClone(entry.nextValue) as Record<string, unknown>;
|
|
182
|
+
for (const k of Object.keys(touchedStates)) {
|
|
183
|
+
touchedStates[k] = false;
|
|
184
|
+
}
|
|
185
|
+
} else if (entry.action === "SUBMIT") {
|
|
186
|
+
values = structuredClone(entry.nextValue) as Record<string, unknown>;
|
|
187
|
+
for (const k of Object.keys(touchedStates)) {
|
|
188
|
+
touchedStates[k] = true;
|
|
189
|
+
}
|
|
190
|
+
} else if (entry.action === "SET_VALUE") {
|
|
191
|
+
if (entry.path) {
|
|
192
|
+
setNestedValue(values, entry.path, entry.nextValue);
|
|
193
|
+
}
|
|
194
|
+
} else if (entry.action === "SET_TOUCHED") {
|
|
195
|
+
if (entry.path) {
|
|
196
|
+
touchedStates[entry.path] = !!entry.nextValue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { values, touchedStates };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getSnapshotAt(index: number): Record<string, unknown> | undefined {
|
|
205
|
+
const firstFormId = formStores.keys().next().value;
|
|
206
|
+
return reconstructFormState(firstFormId, index).values;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function setNestedValue(
|
|
210
|
+
obj: Record<string, unknown>,
|
|
211
|
+
path: string,
|
|
212
|
+
value: unknown
|
|
213
|
+
): void {
|
|
214
|
+
const keys = path.split(".");
|
|
215
|
+
let current = obj;
|
|
216
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
217
|
+
if (!current[keys[i]] || typeof current[keys[i]] !== "object") {
|
|
218
|
+
current[keys[i]] = {};
|
|
219
|
+
}
|
|
220
|
+
current = current[keys[i]] as Record<string, unknown>;
|
|
221
|
+
}
|
|
222
|
+
current[keys[keys.length - 1]] = value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const bridge: DevToolsBridge = {
|
|
226
|
+
history,
|
|
227
|
+
cursor,
|
|
228
|
+
isTimeTraveling,
|
|
229
|
+
|
|
230
|
+
jumpTo(index: number): void {
|
|
231
|
+
const entries = history.peek();
|
|
232
|
+
if (index < 0 || index >= entries.length) return;
|
|
233
|
+
|
|
234
|
+
isTimeTraveling.set(true);
|
|
235
|
+
cursor.set(index);
|
|
236
|
+
|
|
237
|
+
// Restore form state for all attached forms
|
|
238
|
+
for (const [formId, form] of formStores) {
|
|
239
|
+
const { values: restoredValues, touchedStates } = reconstructFormState(formId, index);
|
|
240
|
+
|
|
241
|
+
// Reset form values to snapshot
|
|
242
|
+
form.reset(restoredValues);
|
|
243
|
+
|
|
244
|
+
// Re-apply touched states
|
|
245
|
+
for (const [path, isTouched] of Object.entries(touchedStates)) {
|
|
246
|
+
if (isTouched) {
|
|
247
|
+
form.getField(path).touched.set(true);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
isTimeTraveling.set(false);
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
undo(): void {
|
|
256
|
+
const entries = history.peek();
|
|
257
|
+
if (entries.length === 0) return;
|
|
258
|
+
|
|
259
|
+
const currentCursor = cursor.peek();
|
|
260
|
+
const target =
|
|
261
|
+
currentCursor === -1 ? entries.length - 2 : currentCursor - 1;
|
|
262
|
+
|
|
263
|
+
if (target >= 0) {
|
|
264
|
+
bridge.jumpTo(target);
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
redo(): void {
|
|
269
|
+
const entries = history.peek();
|
|
270
|
+
const currentCursor = cursor.peek();
|
|
271
|
+
|
|
272
|
+
if (currentCursor === -1 || currentCursor >= entries.length - 1) return;
|
|
273
|
+
bridge.jumpTo(currentCursor + 1);
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
resume(): void {
|
|
277
|
+
cursor.set(-1);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
clear(): void {
|
|
281
|
+
history.set([]);
|
|
282
|
+
cursor.set(-1);
|
|
283
|
+
entryCounter = 0;
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
getSnapshotAt,
|
|
287
|
+
|
|
288
|
+
subscribe(listener: (entry: HistoryEntry) => void): () => void {
|
|
289
|
+
listeners.add(listener);
|
|
290
|
+
return () => {
|
|
291
|
+
listeners.delete(listener);
|
|
292
|
+
};
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
attach(form: FormStore): () => void {
|
|
296
|
+
const formId = form.id;
|
|
297
|
+
formStores.set(formId, form);
|
|
298
|
+
|
|
299
|
+
// Wire up the form's action listener to record history
|
|
300
|
+
const prevOnAction = form._onAction;
|
|
301
|
+
form._onAction = (action, path, prevValue, nextValue) => {
|
|
302
|
+
prevOnAction?.(action, path, prevValue, nextValue);
|
|
303
|
+
record(action, path, prevValue, nextValue, formId, form);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Record initial state
|
|
307
|
+
record("INIT", undefined, undefined, form.values.peek(), formId, form);
|
|
308
|
+
|
|
309
|
+
const detach = () => {
|
|
310
|
+
form._onAction = prevOnAction;
|
|
311
|
+
attachedForms.delete(formId);
|
|
312
|
+
formStores.delete(formId);
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
attachedForms.set(formId, detach);
|
|
316
|
+
return detach;
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
detach(): void {
|
|
320
|
+
for (const [, detachFn] of attachedForms) {
|
|
321
|
+
detachFn();
|
|
322
|
+
}
|
|
323
|
+
attachedForms.clear();
|
|
324
|
+
formStores.clear();
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
return bridge;
|
|
329
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — FieldNode
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Per-field reactive state. Each FieldNode is an isolated reactive island:
|
|
5
|
+
// changing field A does NOT trigger subscribers of field B.
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
import { createSignal, createComputed, type Signal, type Computed } from "./signal";
|
|
9
|
+
import {
|
|
10
|
+
type FieldValidator,
|
|
11
|
+
type FieldValidationConfig,
|
|
12
|
+
runFieldValidatorsSync,
|
|
13
|
+
runFieldValidators,
|
|
14
|
+
} from "./validation";
|
|
15
|
+
import { type Scheduler } from "./batch";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface FieldNode<V = unknown> {
|
|
22
|
+
/** Dot-separated path (e.g., 'address.city') */
|
|
23
|
+
readonly path: string;
|
|
24
|
+
/** Current value signal */
|
|
25
|
+
readonly value: Signal<V>;
|
|
26
|
+
/** Current error message (undefined = valid) */
|
|
27
|
+
readonly error: Signal<string | undefined>;
|
|
28
|
+
/** Whether the field has been touched (blurred) */
|
|
29
|
+
readonly touched: Signal<boolean>;
|
|
30
|
+
/** Whether the value differs from initial */
|
|
31
|
+
readonly dirty: Computed<boolean>;
|
|
32
|
+
/** Whether the field is currently validating (async) */
|
|
33
|
+
readonly validating: Signal<boolean>;
|
|
34
|
+
|
|
35
|
+
/** Update the value (triggers validation per config) */
|
|
36
|
+
onChange(value: V): void;
|
|
37
|
+
/** Mark as touched and trigger blur validation */
|
|
38
|
+
onBlur(): void;
|
|
39
|
+
/** Reset field to its initial value */
|
|
40
|
+
reset(value?: V): void;
|
|
41
|
+
/** Run validation manually */
|
|
42
|
+
validate(): Promise<string | undefined>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FieldNodeConfig<V = unknown> extends FieldValidationConfig<V> {
|
|
46
|
+
path: string;
|
|
47
|
+
initialValue: V;
|
|
48
|
+
scheduler: Scheduler;
|
|
49
|
+
validateOn: "change" | "blur" | "submit";
|
|
50
|
+
/** Callback when value changes (used by FormStore to update the values object) */
|
|
51
|
+
onValueChange?: (path: string, value: V) => void;
|
|
52
|
+
/** Callback when an action occurs (used by DevTools bridge) */
|
|
53
|
+
onAction?: (action: string, path: string, prevValue: V, nextValue: V) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Implementation
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
export function createFieldNode<V = unknown>(
|
|
61
|
+
config: FieldNodeConfig<V>
|
|
62
|
+
): FieldNode<V> {
|
|
63
|
+
const {
|
|
64
|
+
path,
|
|
65
|
+
initialValue,
|
|
66
|
+
validators: fieldValidators = [],
|
|
67
|
+
scheduler,
|
|
68
|
+
validateOn,
|
|
69
|
+
onValueChange,
|
|
70
|
+
onAction,
|
|
71
|
+
} = config;
|
|
72
|
+
|
|
73
|
+
let _initialValue = initialValue;
|
|
74
|
+
|
|
75
|
+
const value = createSignal<V>(initialValue);
|
|
76
|
+
const error = createSignal<string | undefined>(undefined);
|
|
77
|
+
const touched = createSignal(false);
|
|
78
|
+
const validating = createSignal(false);
|
|
79
|
+
|
|
80
|
+
const dirty = createComputed(() => !Object.is(value.value, _initialValue));
|
|
81
|
+
|
|
82
|
+
// Run sync validators immediately, schedule async validators
|
|
83
|
+
function runValidation(val: V): void {
|
|
84
|
+
// Immediate sync validation
|
|
85
|
+
const syncError = runFieldValidatorsSync(val, fieldValidators as FieldValidator<V>[]);
|
|
86
|
+
error.set(syncError);
|
|
87
|
+
|
|
88
|
+
// Check if there are async validators (functions that return promises)
|
|
89
|
+
const hasAsync = fieldValidators.some((v) => {
|
|
90
|
+
const result = v(val);
|
|
91
|
+
if (result instanceof Promise) {
|
|
92
|
+
// Clean up the test promise
|
|
93
|
+
result.catch(() => {});
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (hasAsync && !syncError) {
|
|
100
|
+
validating.set(true);
|
|
101
|
+
scheduler.debounced(`validate:${path}`, async () => {
|
|
102
|
+
const asyncError = await runFieldValidators(
|
|
103
|
+
val,
|
|
104
|
+
fieldValidators as FieldValidator<V>[]
|
|
105
|
+
);
|
|
106
|
+
error.set(asyncError);
|
|
107
|
+
validating.set(false);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const field: FieldNode<V> = {
|
|
113
|
+
path,
|
|
114
|
+
value,
|
|
115
|
+
error,
|
|
116
|
+
touched,
|
|
117
|
+
dirty,
|
|
118
|
+
validating,
|
|
119
|
+
|
|
120
|
+
onChange(next: V): void {
|
|
121
|
+
const prev = value.peek();
|
|
122
|
+
if (Object.is(prev, next)) return;
|
|
123
|
+
|
|
124
|
+
value.set(next);
|
|
125
|
+
onValueChange?.(path, next);
|
|
126
|
+
onAction?.("SET_VALUE", path, prev, next);
|
|
127
|
+
|
|
128
|
+
if (validateOn === "change") {
|
|
129
|
+
runValidation(next);
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
onBlur(): void {
|
|
134
|
+
if (!touched.peek()) {
|
|
135
|
+
touched.set(true);
|
|
136
|
+
onAction?.("SET_TOUCHED", path, false as unknown as V, true as unknown as V);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (validateOn === "blur" || validateOn === "change") {
|
|
140
|
+
runValidation(value.peek());
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
reset(newValue?: V): void {
|
|
145
|
+
const resetValue = newValue ?? _initialValue;
|
|
146
|
+
_initialValue = resetValue;
|
|
147
|
+
value.set(resetValue);
|
|
148
|
+
error.set(undefined);
|
|
149
|
+
touched.set(false);
|
|
150
|
+
validating.set(false);
|
|
151
|
+
scheduler.cancel(`validate:${path}`);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
async validate(): Promise<string | undefined> {
|
|
155
|
+
validating.set(true);
|
|
156
|
+
const result = await runFieldValidators(
|
|
157
|
+
value.peek(),
|
|
158
|
+
fieldValidators as FieldValidator<V>[]
|
|
159
|
+
);
|
|
160
|
+
error.set(result);
|
|
161
|
+
validating.set(false);
|
|
162
|
+
return result;
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return field;
|
|
167
|
+
}
|