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,448 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — FormStore
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// The main entry point for creating and managing forms. Creates a reactive
|
|
5
|
+
// FormStore with per-field granularity, form-level validation, and integration
|
|
6
|
+
// hooks for DevTools and cross-form registry.
|
|
7
|
+
// ============================================================================
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createSignal,
|
|
11
|
+
createComputed,
|
|
12
|
+
batch,
|
|
13
|
+
type Signal,
|
|
14
|
+
type Computed,
|
|
15
|
+
} from "./signal";
|
|
16
|
+
import { createFieldNode, type FieldNode, type FieldNodeConfig } from "./field";
|
|
17
|
+
import {
|
|
18
|
+
type ValidatorFn,
|
|
19
|
+
type ValidationResult,
|
|
20
|
+
type FieldValidator,
|
|
21
|
+
} from "./validation";
|
|
22
|
+
import { createScheduler, type Scheduler, type SchedulerOptions } from "./batch";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Types
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Nested error type: mirrors the form values shape with string errors */
|
|
29
|
+
export type FormErrors<T> = {
|
|
30
|
+
[K in keyof T]?: T[K] extends Record<string, unknown>
|
|
31
|
+
? FormErrors<T[K]>
|
|
32
|
+
: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Configuration for per-field validators */
|
|
36
|
+
export type FieldValidators<T> = {
|
|
37
|
+
[K in keyof T]?: FieldValidator<T[K]>[];
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Form configuration passed to createForm() */
|
|
41
|
+
export interface FormConfig<T extends Record<string, unknown>> {
|
|
42
|
+
/** Unique form ID (required for cross-form signals and DevTools) */
|
|
43
|
+
id?: string;
|
|
44
|
+
/** Initial values for all fields */
|
|
45
|
+
initialValues: T;
|
|
46
|
+
/** Form-level validation function */
|
|
47
|
+
validate?: ValidatorFn<T>;
|
|
48
|
+
/** Per-field validators */
|
|
49
|
+
fieldValidators?: FieldValidators<T>;
|
|
50
|
+
/** When to trigger validation (default: 'blur') */
|
|
51
|
+
validateOn?: "change" | "blur" | "submit";
|
|
52
|
+
/** Submit handler */
|
|
53
|
+
onSubmit?: (values: T) => void | Promise<void>;
|
|
54
|
+
/** Scheduler options */
|
|
55
|
+
scheduler?: SchedulerOptions;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The reactive form store returned by createForm() */
|
|
59
|
+
export interface FormStore<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
60
|
+
/** Unique form ID */
|
|
61
|
+
readonly id: string | undefined;
|
|
62
|
+
/** Reactive object containing all current values */
|
|
63
|
+
readonly values: Signal<T>;
|
|
64
|
+
/** Computed form-level errors */
|
|
65
|
+
readonly errors: Computed<FormErrors<T>>;
|
|
66
|
+
/** Whether any field has been touched */
|
|
67
|
+
readonly touched: Computed<boolean>;
|
|
68
|
+
/** Whether any field value differs from initial */
|
|
69
|
+
readonly dirty: Computed<boolean>;
|
|
70
|
+
/** Whether all fields are valid (no errors) */
|
|
71
|
+
readonly valid: Computed<boolean>;
|
|
72
|
+
/** Whether the form is currently submitting */
|
|
73
|
+
readonly submitting: Signal<boolean>;
|
|
74
|
+
/** Number of fields currently async-validating */
|
|
75
|
+
readonly validating: Computed<boolean>;
|
|
76
|
+
|
|
77
|
+
/** Get or create a FieldNode by dot-path */
|
|
78
|
+
getField<V = unknown>(path: string): FieldNode<V>;
|
|
79
|
+
/** Set a field value programmatically */
|
|
80
|
+
setFieldValue(path: string, value: unknown): void;
|
|
81
|
+
/** Set a field as touched */
|
|
82
|
+
setFieldTouched(path: string, isTouched?: boolean): void;
|
|
83
|
+
/** Reset the entire form (optionally with new initial values) */
|
|
84
|
+
reset(values?: Partial<T>): void;
|
|
85
|
+
/** Submit the form */
|
|
86
|
+
submit(): Promise<void>;
|
|
87
|
+
/** Validate all fields and return errors */
|
|
88
|
+
validateAll(): Promise<FormErrors<T>>;
|
|
89
|
+
/** Dispose the form and all subscriptions */
|
|
90
|
+
dispose(): void;
|
|
91
|
+
|
|
92
|
+
/** Access the internal scheduler */
|
|
93
|
+
readonly _scheduler: Scheduler;
|
|
94
|
+
/** Internal map of field nodes (for DevTools/time-travel) */
|
|
95
|
+
readonly _fields?: Map<string, FieldNode<any>>;
|
|
96
|
+
/** Internal form-level errors signal (for DevTools/time-travel) */
|
|
97
|
+
readonly _formLevelErrors?: Signal<Record<string, string | undefined>>;
|
|
98
|
+
/** DevTools action listener */
|
|
99
|
+
_onAction?: (
|
|
100
|
+
action: string,
|
|
101
|
+
path: string | undefined,
|
|
102
|
+
prevValue: unknown,
|
|
103
|
+
nextValue: unknown
|
|
104
|
+
) => void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Utilities
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** Get a value from a nested object by dot path */
|
|
112
|
+
function getByPath(obj: Record<string, unknown>, path: string): unknown {
|
|
113
|
+
const keys = path.split(".");
|
|
114
|
+
let current: unknown = obj;
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (current === null || current === undefined) return undefined;
|
|
117
|
+
current = (current as Record<string, unknown>)[key];
|
|
118
|
+
}
|
|
119
|
+
return current;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Set a value in a nested object by dot path (immutable — returns new object) */
|
|
123
|
+
function setByPath<T extends Record<string, unknown>>(
|
|
124
|
+
obj: T,
|
|
125
|
+
path: string,
|
|
126
|
+
value: unknown
|
|
127
|
+
): T {
|
|
128
|
+
const keys = path.split(".");
|
|
129
|
+
if (keys.length === 1) {
|
|
130
|
+
return { ...obj, [keys[0]]: value } as T;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const [head, ...rest] = keys;
|
|
134
|
+
const child = (obj[head] ?? {}) as Record<string, unknown>;
|
|
135
|
+
return {
|
|
136
|
+
...obj,
|
|
137
|
+
[head]: setByPath(child, rest.join("."), value),
|
|
138
|
+
} as T;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Flatten a nested object into dot-path keys */
|
|
142
|
+
function flattenKeys(
|
|
143
|
+
obj: Record<string, unknown>,
|
|
144
|
+
prefix = ""
|
|
145
|
+
): string[] {
|
|
146
|
+
const keys: string[] = [];
|
|
147
|
+
for (const key of Object.keys(obj)) {
|
|
148
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
149
|
+
const value = obj[key];
|
|
150
|
+
if (
|
|
151
|
+
value !== null &&
|
|
152
|
+
typeof value === "object" &&
|
|
153
|
+
!Array.isArray(value)
|
|
154
|
+
) {
|
|
155
|
+
keys.push(...flattenKeys(value as Record<string, unknown>, fullPath));
|
|
156
|
+
} else {
|
|
157
|
+
keys.push(fullPath);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return keys;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// createForm()
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create a reactive form store.
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```ts
|
|
172
|
+
* const form = createForm({
|
|
173
|
+
* id: 'login',
|
|
174
|
+
* initialValues: { email: '', password: '' },
|
|
175
|
+
* fieldValidators: {
|
|
176
|
+
* email: [validators.required(), validators.email()],
|
|
177
|
+
* password: [validators.required(), validators.minLength(8)],
|
|
178
|
+
* },
|
|
179
|
+
* validateOn: 'blur',
|
|
180
|
+
* onSubmit: async (values) => {
|
|
181
|
+
* await api.login(values);
|
|
182
|
+
* },
|
|
183
|
+
* });
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function createForm<T extends Record<string, unknown>>(
|
|
187
|
+
config: FormConfig<T>
|
|
188
|
+
): FormStore<T> {
|
|
189
|
+
const {
|
|
190
|
+
id,
|
|
191
|
+
initialValues,
|
|
192
|
+
validate: formValidator,
|
|
193
|
+
fieldValidators: fieldValidatorsMap = {} as FieldValidators<T>,
|
|
194
|
+
validateOn = "blur",
|
|
195
|
+
onSubmit,
|
|
196
|
+
scheduler: schedulerOptions,
|
|
197
|
+
} = config;
|
|
198
|
+
|
|
199
|
+
const scheduler = createScheduler(schedulerOptions);
|
|
200
|
+
const fieldNodes = new Map<string, FieldNode>();
|
|
201
|
+
let _initialValues = { ...initialValues };
|
|
202
|
+
|
|
203
|
+
// --- Core signals ---
|
|
204
|
+
const values = createSignal<T>({ ...initialValues });
|
|
205
|
+
const submitting = createSignal(false);
|
|
206
|
+
const formLevelErrors = createSignal<ValidationResult>({});
|
|
207
|
+
|
|
208
|
+
// DevTools action listener (set by registry/devtools)
|
|
209
|
+
let _onAction:
|
|
210
|
+
| ((
|
|
211
|
+
action: string,
|
|
212
|
+
path: string | undefined,
|
|
213
|
+
prevValue: unknown,
|
|
214
|
+
nextValue: unknown
|
|
215
|
+
) => void)
|
|
216
|
+
| undefined;
|
|
217
|
+
|
|
218
|
+
// --- Computed signals ---
|
|
219
|
+
const errors = createComputed<FormErrors<T>>(() => {
|
|
220
|
+
const allErrors: Record<string, string | undefined> = {
|
|
221
|
+
...formLevelErrors.value,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Collect field-level errors
|
|
225
|
+
for (const [path, field] of fieldNodes) {
|
|
226
|
+
const fieldError = field.error.value;
|
|
227
|
+
if (fieldError) {
|
|
228
|
+
allErrors[path] = fieldError;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Convert flat errors to nested object matching T shape
|
|
233
|
+
const nested: Record<string, unknown> = {};
|
|
234
|
+
for (const [path, error] of Object.entries(allErrors)) {
|
|
235
|
+
if (error) {
|
|
236
|
+
const keys = path.split(".");
|
|
237
|
+
let current = nested;
|
|
238
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
239
|
+
if (!current[keys[i]] || typeof current[keys[i]] !== "object") {
|
|
240
|
+
current[keys[i]] = {};
|
|
241
|
+
}
|
|
242
|
+
current = current[keys[i]] as Record<string, unknown>;
|
|
243
|
+
}
|
|
244
|
+
current[keys[keys.length - 1]] = error;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return nested as FormErrors<T>;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const touched = createComputed(() => {
|
|
252
|
+
for (const field of fieldNodes.values()) {
|
|
253
|
+
if (field.touched.value) return true;
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const dirty = createComputed(() => {
|
|
259
|
+
for (const field of fieldNodes.values()) {
|
|
260
|
+
if (field.dirty.value) return true;
|
|
261
|
+
}
|
|
262
|
+
return false;
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const valid = createComputed(() => {
|
|
266
|
+
// Check form-level errors
|
|
267
|
+
const fErrors = formLevelErrors.value;
|
|
268
|
+
for (const key of Object.keys(fErrors)) {
|
|
269
|
+
if (fErrors[key]) return false;
|
|
270
|
+
}
|
|
271
|
+
// Check field-level errors
|
|
272
|
+
for (const field of fieldNodes.values()) {
|
|
273
|
+
if (field.error.value) return false;
|
|
274
|
+
}
|
|
275
|
+
return true;
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const validating = createComputed(() => {
|
|
279
|
+
for (const field of fieldNodes.values()) {
|
|
280
|
+
if (field.validating.value) return true;
|
|
281
|
+
}
|
|
282
|
+
return false;
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// --- Field management ---
|
|
286
|
+
function getField<V = unknown>(path: string): FieldNode<V> {
|
|
287
|
+
let field = fieldNodes.get(path);
|
|
288
|
+
if (!field) {
|
|
289
|
+
const initialValue = getByPath(_initialValues, path);
|
|
290
|
+
const fieldConfig: FieldNodeConfig = {
|
|
291
|
+
path,
|
|
292
|
+
initialValue,
|
|
293
|
+
validators: (fieldValidatorsMap as Record<string, FieldValidator[]>)[path] ?? [],
|
|
294
|
+
scheduler,
|
|
295
|
+
validateOn,
|
|
296
|
+
onValueChange: (p: string, v: unknown) => {
|
|
297
|
+
const current = values.peek();
|
|
298
|
+
values.set(setByPath(current, p, v));
|
|
299
|
+
|
|
300
|
+
// Run form-level validation if configured
|
|
301
|
+
if (formValidator) {
|
|
302
|
+
scheduler.debounced("form-validate", () => {
|
|
303
|
+
const result = formValidator(values.peek());
|
|
304
|
+
if (result instanceof Promise) {
|
|
305
|
+
result.then((r) => formLevelErrors.set(r));
|
|
306
|
+
} else {
|
|
307
|
+
formLevelErrors.set(result);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
onAction: (action, p, prev, next) => {
|
|
313
|
+
_onAction?.(action, p, prev, next);
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
field = createFieldNode(fieldConfig);
|
|
317
|
+
fieldNodes.set(path, field);
|
|
318
|
+
}
|
|
319
|
+
return field as FieldNode<V>;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Pre-create field nodes for all initial value paths
|
|
323
|
+
const initialPaths = flattenKeys(initialValues as Record<string, unknown>);
|
|
324
|
+
for (const path of initialPaths) {
|
|
325
|
+
getField(path);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// --- Form store ---
|
|
329
|
+
const store: FormStore<T> = {
|
|
330
|
+
id,
|
|
331
|
+
values,
|
|
332
|
+
errors,
|
|
333
|
+
touched,
|
|
334
|
+
dirty,
|
|
335
|
+
valid,
|
|
336
|
+
submitting,
|
|
337
|
+
validating,
|
|
338
|
+
_scheduler: scheduler,
|
|
339
|
+
_fields: fieldNodes,
|
|
340
|
+
_formLevelErrors: formLevelErrors,
|
|
341
|
+
|
|
342
|
+
get _onAction() {
|
|
343
|
+
return _onAction;
|
|
344
|
+
},
|
|
345
|
+
set _onAction(
|
|
346
|
+
fn:
|
|
347
|
+
| ((
|
|
348
|
+
action: string,
|
|
349
|
+
path: string | undefined,
|
|
350
|
+
prevValue: unknown,
|
|
351
|
+
nextValue: unknown
|
|
352
|
+
) => void)
|
|
353
|
+
| undefined
|
|
354
|
+
) {
|
|
355
|
+
_onAction = fn;
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
getField,
|
|
359
|
+
|
|
360
|
+
setFieldValue(path: string, value: unknown): void {
|
|
361
|
+
const field = getField(path);
|
|
362
|
+
field.onChange(value);
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
setFieldTouched(path: string, isTouched = true): void {
|
|
366
|
+
const field = getField(path);
|
|
367
|
+
if (isTouched) {
|
|
368
|
+
field.onBlur();
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
reset(newValues?: Partial<T>): void {
|
|
373
|
+
const resetValues = { ..._initialValues, ...newValues } as T;
|
|
374
|
+
_initialValues = { ...resetValues };
|
|
375
|
+
|
|
376
|
+
batch(() => {
|
|
377
|
+
values.set({ ...resetValues });
|
|
378
|
+
formLevelErrors.set({});
|
|
379
|
+
submitting.set(false);
|
|
380
|
+
|
|
381
|
+
// Reset all field nodes
|
|
382
|
+
for (const [path, field] of fieldNodes) {
|
|
383
|
+
const fieldValue = getByPath(resetValues, path);
|
|
384
|
+
field.reset(fieldValue);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
_onAction?.("RESET", undefined, undefined, resetValues);
|
|
389
|
+
},
|
|
390
|
+
|
|
391
|
+
async submit(): Promise<void> {
|
|
392
|
+
// Touch all fields
|
|
393
|
+
for (const field of fieldNodes.values()) {
|
|
394
|
+
field.onBlur();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Validate all fields
|
|
398
|
+
const allErrors = await store.validateAll();
|
|
399
|
+
const hasErrors = Object.keys(allErrors).length > 0;
|
|
400
|
+
|
|
401
|
+
if (hasErrors) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
submitting.set(true);
|
|
406
|
+
_onAction?.("SUBMIT", undefined, undefined, values.peek());
|
|
407
|
+
|
|
408
|
+
try {
|
|
409
|
+
await onSubmit?.(values.peek());
|
|
410
|
+
} finally {
|
|
411
|
+
submitting.set(false);
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
|
|
415
|
+
async validateAll(): Promise<FormErrors<T>> {
|
|
416
|
+
const fieldErrors: Record<string, string | undefined> = {};
|
|
417
|
+
|
|
418
|
+
// Validate all fields
|
|
419
|
+
const validationPromises = [...fieldNodes.entries()].map(
|
|
420
|
+
async ([path, field]) => {
|
|
421
|
+
const error = await field.validate();
|
|
422
|
+
if (error) {
|
|
423
|
+
fieldErrors[path] = error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
);
|
|
427
|
+
await Promise.all(validationPromises);
|
|
428
|
+
|
|
429
|
+
// Run form-level validation
|
|
430
|
+
if (formValidator) {
|
|
431
|
+
const result = formValidator(values.peek());
|
|
432
|
+
const formErrors =
|
|
433
|
+
result instanceof Promise ? await result : result;
|
|
434
|
+
formLevelErrors.set(formErrors);
|
|
435
|
+
Object.assign(fieldErrors, formErrors);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return fieldErrors as FormErrors<T>;
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
dispose(): void {
|
|
442
|
+
scheduler.cancelAll();
|
|
443
|
+
fieldNodes.clear();
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
return store;
|
|
448
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — Public API
|
|
3
|
+
// ============================================================================
|
|
4
|
+
|
|
5
|
+
// Signal primitives
|
|
6
|
+
export {
|
|
7
|
+
createSignal,
|
|
8
|
+
createComputed,
|
|
9
|
+
createEffect,
|
|
10
|
+
batch,
|
|
11
|
+
untrack,
|
|
12
|
+
type Signal,
|
|
13
|
+
type Computed,
|
|
14
|
+
type EffectCleanup,
|
|
15
|
+
} from "./signal";
|
|
16
|
+
|
|
17
|
+
// Form engine
|
|
18
|
+
export {
|
|
19
|
+
createForm,
|
|
20
|
+
type FormConfig,
|
|
21
|
+
type FormStore,
|
|
22
|
+
type FormErrors,
|
|
23
|
+
type FieldValidators,
|
|
24
|
+
} from "./form";
|
|
25
|
+
|
|
26
|
+
// Field nodes
|
|
27
|
+
export { createFieldNode, type FieldNode, type FieldNodeConfig } from "./field";
|
|
28
|
+
|
|
29
|
+
// Validation
|
|
30
|
+
export {
|
|
31
|
+
validators,
|
|
32
|
+
withSchema,
|
|
33
|
+
runFieldValidators,
|
|
34
|
+
runFieldValidatorsSync,
|
|
35
|
+
type ValidatorFn,
|
|
36
|
+
type FieldValidator,
|
|
37
|
+
type FieldValidationConfig,
|
|
38
|
+
type ValidationResult,
|
|
39
|
+
} from "./validation";
|
|
40
|
+
|
|
41
|
+
// Batching / Scheduling
|
|
42
|
+
export {
|
|
43
|
+
createScheduler,
|
|
44
|
+
type Scheduler,
|
|
45
|
+
type SchedulerOptions,
|
|
46
|
+
} from "./batch";
|
|
47
|
+
|
|
48
|
+
// Cross-form registry
|
|
49
|
+
export {
|
|
50
|
+
getFormRegistry,
|
|
51
|
+
createFormRegistry,
|
|
52
|
+
resetGlobalRegistry,
|
|
53
|
+
type FormRegistry,
|
|
54
|
+
} from "./registry";
|
|
55
|
+
|
|
56
|
+
// DevTools
|
|
57
|
+
export {
|
|
58
|
+
createDevTools,
|
|
59
|
+
type DevToolsBridge,
|
|
60
|
+
type DevToolsConfig,
|
|
61
|
+
type HistoryEntry,
|
|
62
|
+
} from "./devtools";
|
|
63
|
+
|
|
64
|
+
// Time-Travel
|
|
65
|
+
export {
|
|
66
|
+
enableTimeTravel,
|
|
67
|
+
type TimeTravelEntry,
|
|
68
|
+
type TimeTravelOptions,
|
|
69
|
+
type TimeTravelMethods,
|
|
70
|
+
} from "./time-travel";
|
|
71
|
+
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// @kayforms/core — Cross-Form Signal Registry
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Enables reactive cross-form communication. Form A can react to Form B's
|
|
5
|
+
// values, validity, or any derived state — all through the same signal graph.
|
|
6
|
+
// ============================================================================
|
|
7
|
+
|
|
8
|
+
import { createSignal, type Signal } from "./signal";
|
|
9
|
+
import { type FormStore } from "./form";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export interface FormRegistry {
|
|
16
|
+
/** Register a form in the registry */
|
|
17
|
+
register<T extends Record<string, unknown>>(form: FormStore<T>): void;
|
|
18
|
+
/** Unregister a form */
|
|
19
|
+
unregister(id: string): void;
|
|
20
|
+
/** Get a registered form by ID */
|
|
21
|
+
get<T extends Record<string, unknown>>(id: string): FormStore<T> | undefined;
|
|
22
|
+
/** Get all registered form IDs */
|
|
23
|
+
getIds(): string[];
|
|
24
|
+
/** Signal that fires when the registry changes (form added/removed) */
|
|
25
|
+
readonly changed: Signal<number>;
|
|
26
|
+
/** Dispose the registry */
|
|
27
|
+
dispose(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Singleton registry
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
let _globalRegistry: FormRegistry | undefined;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get or create the global form registry.
|
|
38
|
+
* Forms with an `id` auto-register here on creation.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* const registry = getFormRegistry();
|
|
43
|
+
*
|
|
44
|
+
* const profileForm = createForm({ id: 'profile', initialValues: { name: '' } });
|
|
45
|
+
* registry.register(profileForm);
|
|
46
|
+
*
|
|
47
|
+
* const paymentForm = createForm({ id: 'payment', initialValues: { card: '' } });
|
|
48
|
+
* registry.register(paymentForm);
|
|
49
|
+
*
|
|
50
|
+
* // Cross-form computed
|
|
51
|
+
* const canCheckout = createComputed(() => {
|
|
52
|
+
* const profile = registry.get('profile');
|
|
53
|
+
* const payment = registry.get('payment');
|
|
54
|
+
* return (profile?.valid.value ?? false) && (payment?.valid.value ?? false);
|
|
55
|
+
* });
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function getFormRegistry(): FormRegistry {
|
|
59
|
+
if (!_globalRegistry) {
|
|
60
|
+
_globalRegistry = createFormRegistry();
|
|
61
|
+
}
|
|
62
|
+
return _globalRegistry;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Create a new isolated form registry (useful for testing or SSR).
|
|
67
|
+
*/
|
|
68
|
+
export function createFormRegistry(): FormRegistry {
|
|
69
|
+
const forms = new Map<string, FormStore>();
|
|
70
|
+
const changed = createSignal(0);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
register<T extends Record<string, unknown>>(form: FormStore<T>): void {
|
|
74
|
+
if (!form.id) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
"[kayforms] Cannot register a form without an id. Pass `id` to createForm()."
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (forms.has(form.id)) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`[kayforms] Form with id "${form.id}" is already registered. Overwriting.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
forms.set(form.id, form as FormStore);
|
|
85
|
+
changed.set(changed.peek() + 1);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
unregister(id: string): void {
|
|
89
|
+
if (forms.delete(id)) {
|
|
90
|
+
changed.set(changed.peek() + 1);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
get<T extends Record<string, unknown>>(
|
|
95
|
+
id: string
|
|
96
|
+
): FormStore<T> | undefined {
|
|
97
|
+
// Reading `changed` signal creates a dependency so computeds
|
|
98
|
+
// that access the registry will re-evaluate when forms are added/removed
|
|
99
|
+
const _version = changed.value;
|
|
100
|
+
void _version;
|
|
101
|
+
return forms.get(id) as FormStore<T> | undefined;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
getIds(): string[] {
|
|
105
|
+
return [...forms.keys()];
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
changed,
|
|
109
|
+
|
|
110
|
+
dispose(): void {
|
|
111
|
+
for (const form of forms.values()) {
|
|
112
|
+
form.dispose();
|
|
113
|
+
}
|
|
114
|
+
forms.clear();
|
|
115
|
+
changed.set(0);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reset the global registry (useful for testing).
|
|
122
|
+
*/
|
|
123
|
+
export function resetGlobalRegistry(): void {
|
|
124
|
+
_globalRegistry?.dispose();
|
|
125
|
+
_globalRegistry = undefined;
|
|
126
|
+
}
|