@opentf/web-form 0.1.0-alpha.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # @opentf/web-form
2
+
3
+ ## 1.0.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [bb1c71b]
8
+ - @opentf/web@0.5.0
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @opentf/web-form
2
+
3
+ A reactive form management library for **OTF Web**.
4
+
5
+ ## Features
6
+
7
+ - ✅ **Reactive Binding**: Seamlessly bind form values to signals.
8
+ - 🌲 **Nested State**: Support for complex, nested form structures.
9
+ - 🛡️ **Validation**: Flexible validation system with Zod or custom logic.
10
+ - 🚀 **High Performance**: Direct DOM updates without VDOM overhead.
11
+ - 🛠️ **Simple API**: Featuring a `register` pattern for clean boilerplate-free code.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @opentf/web-form
17
+ ```
18
+
19
+ ## Basic Usage
20
+
21
+ ### Using the `register` Pattern
22
+
23
+ The `register` function returns all necessary props (`name`, `value`, `oninput`, `onblur`) to bind a field to an input element.
24
+
25
+ ```jsx
26
+ import { createForm } from '@opentf/web-form';
27
+
28
+ const form = createForm({
29
+ initialValues: { username: '' }
30
+ });
31
+
32
+ // In your component
33
+ <input {...form.register('username')} />
34
+ ```
35
+
36
+ ## Validation with Zod
37
+
38
+ You can easily integrate **Zod** for schema-based validation by providing a `validator` function.
39
+
40
+ ```javascript
41
+ import { createForm } from '@opentf/web-form';
42
+ import { z } from 'zod';
43
+
44
+ const schema = z.object({
45
+ username: z.string().min(3, 'Too short'),
46
+ email: z.string().email('Invalid email')
47
+ });
48
+
49
+ // A simple Zod validator wrapper
50
+ const zodValidator = (values, schema) => {
51
+ const result = schema.safeParse(values);
52
+ if (result.success) return {};
53
+ const errors = {};
54
+ result.error.errors.forEach(e => {
55
+ errors[e.path.join('.')] = e.message;
56
+ });
57
+ return { errors };
58
+ };
59
+
60
+ const form = createForm({
61
+ initialValues: { username: '', email: '' },
62
+ schema,
63
+ validator: zodValidator // Can return { errors: ... } or direct errors object
64
+ });
65
+
66
+ const onSubmit = (values) => {
67
+ console.log('Form Submitted:', values);
68
+ };
69
+
70
+ // In your component
71
+ <form onsubmit={form.handleSubmit(onSubmit)}>
72
+ <div>
73
+ <input {...form.register('username')} placeholder="Username" />
74
+ <span className="error">{form.errors.username}</span>
75
+ </div>
76
+
77
+ <div>
78
+ <input {...form.register('email')} placeholder="Email" />
79
+ <span className="error">{form.errors.email}</span>
80
+ </div>
81
+
82
+ <button type="submit">Submit</button>
83
+ </form>
84
+ ```
85
+
86
+ ## Form State Helpers
87
+
88
+ `createForm` returns several reactive signals and proxies to track the form lifecycle:
89
+
90
+ | Helper | Type | Description |
91
+ | :--- | :--- | :--- |
92
+ | `isValid` | `Signal<boolean>` | `true` if there are no validation errors. |
93
+ | `isChanged` | `Signal<boolean>` | `true` if the form values differ from `initialValues`. |
94
+ | `isTouched` | `Signal<boolean>` | `true` if any field has been blurred. |
95
+ | `isSubmitting` | `Signal<boolean>` | `true` while the `onSubmit` handler is running. |
96
+ | `isSubmitted` | `Signal<boolean>` | `true` if the form has been successfully submitted. |
97
+ | `touched` | `Proxy<boolean>` | Access per-field touched state: `form.touched.email`. |
98
+ | `changed` | `Proxy<boolean>` | Access per-field changed state: `form.changed.email`. |
99
+
100
+ ## Validation Modes
101
+
102
+ You can customize when validation triggers using `mode` and `reValidateMode`.
103
+
104
+ ```javascript
105
+ const form = createForm({
106
+ mode: 'onBlur', // Initial validation: 'onSubmit', 'onBlur', 'onTouched', 'onChange'
107
+ reValidateMode: 'onChange' // Re-validation: 'onChange', 'onBlur', 'onSubmit'
108
+ });
109
+ ```
110
+
111
+ - **Default mode**: `onBlur`
112
+ - **Default reValidateMode**: `onChange`
113
+
114
+ ### Resetting the Form
115
+
116
+ You can restore the form to its `initialValues` and clear all status flags using the `reset()` method.
117
+
118
+ ```javascript
119
+ <button type="button" onclick={() => form.reset()}>
120
+ Reset Form
121
+ </button>
122
+ ```
123
+
124
+ ## Custom Validation
125
+
126
+ For simpler cases, you can use the `validate` function which expects you to return the errors object directly.
127
+
128
+ ```javascript
129
+ const form = createForm({
130
+ initialValues: { age: 0 },
131
+ validate: (values) => {
132
+ const errors = {};
133
+ if (values.age < 18) errors.age = 'Too young';
134
+ return errors;
135
+ }
136
+ });
137
+ ```
138
+
139
+ ## License
140
+
141
+ MIT © [Open Tech Foundation](https://github.com/Open-Tech-Foundation)
package/bunfig.toml ADDED
@@ -0,0 +1,6 @@
1
+ [test]
2
+ env = "happy-dom"
3
+ preload = ["../web-test/setup.js"]
4
+
5
+ [jsx]
6
+ runtime = "classic"
package/index.js CHANGED
@@ -1,163 +1,377 @@
1
- import { signal, effect, computed, batch } from "@preact/signals-core";
1
+
2
+ import {
3
+ signal,
4
+ computed,
5
+ batch,
6
+ effect,
7
+ untracked,
8
+ getCurrentInstance,
9
+ } from "@opentf/web";
10
+
11
+ export { signal, computed, batch, effect, untracked, getCurrentInstance };
12
+
13
+ import { set, get, unset, clone, isEql } from "@opentf/std";
2
14
 
3
15
  /**
4
- * Helper to set a nested property in an object.
16
+ * Enterprise Form Reactivity Engine
17
+ *
18
+ * Features:
19
+ * - Mount-Aware Stability Cache
20
+ * - Setup-Phase Subscription Isolation
21
+ * - Deep Reactive Proxy with Array Mutation Support
5
22
  */
6
- const setPath = (obj, path, value) => {
7
- const parts = path.split('.');
8
- let current = obj;
9
- for (let i = 0; i < parts.length - 1; i++) {
10
- const part = parts[i];
11
- const next = parts[i + 1];
12
- if (!current[part]) {
13
- current[part] = !isNaN(next) ? [] : {};
14
- }
15
- current = current[part];
23
+
24
+ const INST_FORMS_REGISTRY = new WeakMap();
25
+ let isSetup = false;
26
+
27
+ export function createForm(options = {}) {
28
+ const inst = getCurrentInstance();
29
+ if (!inst) return _createForm(options);
30
+
31
+ if (!INST_FORMS_REGISTRY.has(inst)) {
32
+ INST_FORMS_REGISTRY.set(inst, new Map());
16
33
  }
17
- current[parts[parts.length - 1]] = value;
18
- };
34
+ const forms = INST_FORMS_REGISTRY.get(inst);
19
35
 
20
- /**
21
- * Creates a reactive form state manager.
22
- */
23
- export function createForm({ initialValues = {}, validate, schema, validator } = {}) {
24
- const fields = {}; // Stores field signals by path
25
- const touched = {}; // Stores touched signals by path
26
- const fieldPaths = signal([]); // Track list of paths for computed to depend on
27
-
28
- function ensureField(path, initVal) {
29
- if (!fields[path]) {
30
- fields[path] = signal(initVal === undefined ? "" : initVal);
31
- touched[path] = signal(false);
32
- }
33
- if (!fieldPaths.value.includes(path)) {
34
- fieldPaths.value = [...fieldPaths.value, path];
35
- }
36
- if (initVal !== undefined) {
37
- fields[path].value = initVal;
36
+ const key = options.key || JSON.stringify({
37
+ initialValues: options.initialValues
38
+ });
39
+
40
+ if (forms.has(key)) {
41
+ const form = forms.get(key);
42
+ if (form._updateConfig) {
43
+ form._updateConfig(options.mode, options.reValidateMode);
38
44
  }
39
- return fields[path];
45
+ return form;
40
46
  }
41
47
 
42
- const walk = (obj, path = "") => {
43
- if (obj && typeof obj === "object" && obj !== null) {
44
- Object.keys(obj).forEach(key => {
45
- const fullPath = path ? `${path}.${key}` : key;
46
- walk(obj[key], fullPath);
47
- });
48
- } else if (path) {
49
- ensureField(path, obj);
48
+ isSetup = true;
49
+ try {
50
+ const form = _createForm(options);
51
+ forms.set(key, form);
52
+ return form;
53
+ } finally {
54
+ isSetup = false;
55
+ }
56
+ }
57
+
58
+ function _createForm(options = {}) {
59
+ const initialValuesSig = signal(clone(options.initialValues || {}));
60
+ const valuesSig = signal(clone(initialValuesSig.value));
61
+ const errorsSig = signal({});
62
+ const touchedSig = signal({});
63
+ const changedSig = signal({});
64
+ const isSubmittingSig = signal(false);
65
+ const isValidatingSig = signal(false);
66
+ const isSubmittedSig = signal(false);
67
+ const submitCountSig = signal(0);
68
+ const modeSig = signal(options.mode);
69
+ const reValidateModeSig = signal(options.reValidateMode);
70
+
71
+ const signalsCache = new Map();
72
+
73
+ function getSignal(sigType, path, defaultValue) {
74
+ const key = `${sigType}:${path}`;
75
+ if (signalsCache.has(key)) return signalsCache.get(key);
76
+ const initialVal = get(
77
+ sigType === "v" ? valuesSig.peek() : sigType === "e" ? errorsSig.peek() : sigType === "t" ? touchedSig.peek() : changedSig.peek(),
78
+ path
79
+ );
80
+ const sig = signal(initialVal ?? defaultValue);
81
+ signalsCache.set(key, sig);
82
+ return sig;
83
+ }
84
+
85
+ function notifySignals(sigType) {
86
+ const prefix = `${sigType}:`;
87
+ const fullData = sigType === "v" ? valuesSig.peek() : sigType === "e" ? errorsSig.peek() : sigType === "t" ? touchedSig.peek() : changedSig.peek();
88
+
89
+ for (const [cacheKey, sig] of signalsCache) {
90
+ if (!cacheKey.startsWith(prefix)) continue;
91
+ const path = cacheKey.slice(prefix.length);
92
+ const newVal = path === "" ? fullData : get(fullData, path);
93
+ sig.value = newVal;
50
94
  }
51
- };
95
+ }
52
96
 
53
- // Initial population
54
- batch(() => walk(initialValues));
97
+ const getMode = () => {
98
+ const m = modeSig.value;
99
+ return (m instanceof Object && 'value' in m ? m.value : m) || "onBlur";
100
+ };
101
+ const getReValidateMode = () => {
102
+ const rm = reValidateModeSig.value;
103
+ return (rm instanceof Object && 'value' in rm ? rm.value : rm) || "onChange";
104
+ };
105
+ const activeValidator = options.validator || options.validate;
55
106
 
56
- // Computed state object
57
- const values = computed(() => {
58
- const res = {};
59
- fieldPaths.value.forEach(path => {
60
- setPath(res, path, fields[path].value);
107
+ function hasErrors(obj) {
108
+ if (!obj || typeof obj !== "object") return false;
109
+ return Object.values(obj).some((v) => {
110
+ if (v === undefined || v === null) return false;
111
+ if (typeof v === "object") return hasErrors(v);
112
+ return true;
61
113
  });
62
- return res;
63
- });
114
+ }
64
115
 
65
- const errors = signal({});
116
+ const isValidSig = computed(() => !hasErrors(errorsSig.value));
117
+ const isTouchedSig = computed(() => hasErrors(touchedSig.value));
118
+ const isDirtySig = computed(() => !isEql(valuesSig.value, initialValuesSig.value));
66
119
 
67
- // Reactive validation
68
- effect(() => {
69
- const currentValues = values.value;
70
- if (validator) {
71
- const result = validator(currentValues, schema);
72
- errors.value = result.errors || {};
73
- } else if (validate) {
74
- errors.value = validate(currentValues) || {};
120
+ function updateErrors(results, fieldPath) {
121
+ const currentErrors = results && results.errors ? results.errors : results || {};
122
+ if (fieldPath) {
123
+ const fieldError = get(currentErrors, fieldPath);
124
+ const newErrors = clone(errorsSig.value || {});
125
+ if (fieldError) set(newErrors, fieldPath, fieldError);
126
+ else unset(newErrors, fieldPath);
127
+ errorsSig.value = newErrors;
128
+ } else {
129
+ errorsSig.value = currentErrors || {};
75
130
  }
76
- });
131
+ notifySignals("e");
132
+ }
133
+
134
+ function runValidation(fieldPath) {
135
+ if (!activeValidator) return;
136
+
137
+ untracked(() => {
138
+ const results = activeValidator(valuesSig.peek());
139
+ if (results instanceof Promise) {
140
+ isValidatingSig.value = true;
141
+ return results.then((res) => updateErrors(res, fieldPath)).finally(() => isValidatingSig.value = false);
142
+ }
143
+ updateErrors(results, fieldPath);
144
+ });
145
+ }
77
146
 
78
- function register(name) {
79
- const s = ensureField(name);
147
+ function updateValue(path, val) {
148
+ batch(() => {
149
+ const newState = clone(valuesSig.peek());
150
+ if (path === "") {
151
+ valuesSig.value = clone(val);
152
+ } else {
153
+ set(newState, path, val);
154
+ valuesSig.value = newState;
155
+ }
156
+ notifySignals("v");
157
+
158
+ const isFieldChanged = !isEql(val, path === "" ? initialValuesSig.peek() : get(initialValuesSig.peek(), path));
159
+ const newChanged = clone(changedSig.peek() || {});
160
+ if (path === "") {
161
+ // Root changed?
162
+ } else {
163
+ set(newChanged, path, isFieldChanged);
164
+ changedSig.value = newChanged;
165
+ }
166
+ notifySignals("c");
167
+
168
+ const mode = getMode();
169
+ const hasAnyErrors = hasErrors(errorsSig.peek());
170
+ const reValidateMode = getReValidateMode();
171
+
172
+ if (mode === "onChange") runValidation(path);
173
+ else if (hasAnyErrors && reValidateMode === "onChange") runValidation(path);
174
+ });
175
+ }
176
+ const register = (path) => {
177
+ const valSig = getSignal("v", path);
178
+ const errSig = getSignal("e", path);
179
+ const touchSig = getSignal("t", path, false);
180
+
80
181
  return {
81
- name,
82
- value: s,
182
+ name: path,
183
+ value: valSig,
184
+ checked: valSig,
185
+ error: errSig,
186
+ isTouched: touchSig,
83
187
  oninput: (e) => {
84
- s.value = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
188
+ const val = e.target.type === "checkbox" ? e.target.checked : e.target.value;
189
+ updateValue(path, val);
85
190
  },
86
191
  onblur: () => {
87
- if (touched[name]) touched[name].value = true;
88
- }
192
+ const newTouched = clone(touchedSig.value || {});
193
+ set(newTouched, path, true);
194
+ touchedSig.value = newTouched;
195
+ notifySignals("t", path, touchedSig.value);
196
+ const mode = getMode();
197
+ if (mode === "onBlur") runValidation(path);
198
+ },
89
199
  };
90
- }
200
+ };
91
201
 
92
- function handleSubmit(onSubmit) {
93
- return (e) => {
94
- if (e && e.preventDefault) e.preventDefault();
95
-
96
- // Mark all fields as touched on submit
97
- batch(() => {
98
- fieldPaths.value.forEach(path => {
99
- if (touched[path]) touched[path].value = true;
100
- });
101
- });
202
+ function markAllTouched(obj) {
203
+ if (!obj || typeof obj !== "object") return true;
204
+ const res = Array.isArray(obj) ? [] : {};
205
+ Object.keys(obj).forEach((key) => {
206
+ res[key] = markAllTouched(obj[key]);
207
+ });
208
+ return res;
209
+ }
102
210
 
103
- const currentValues = values.value;
104
- let currentErrors = {};
105
- if (validator) {
106
- currentErrors = validator(currentValues, schema).errors || {};
107
- } else if (validate) {
108
- currentErrors = validate(currentValues) || {};
211
+ const handleSubmit = (fn) => async (e) => {
212
+ if (e && e.preventDefault) e.preventDefault();
213
+ touchedSig.value = markAllTouched(valuesSig.value);
214
+ notifySignals("t");
215
+ isSubmittingSig.value = true;
216
+ submitCountSig.value++;
217
+ await runValidation();
218
+ if (isValidSig.value) {
219
+ try {
220
+ await fn(valuesSig.value);
221
+ isSubmittedSig.value = true;
222
+ } finally {
223
+ isSubmittingSig.value = false;
109
224
  }
110
-
111
- errors.value = currentErrors;
112
-
113
- if (Object.keys(currentErrors).length === 0) {
114
- onSubmit(currentValues);
115
- }
116
- };
117
- }
225
+ } else {
226
+ isSubmittingSig.value = false;
227
+ }
228
+ };
118
229
 
119
- function setValues(newValues) {
230
+ const reset = (newValues) => {
120
231
  batch(() => {
121
- const keysToUpdate = Object.keys(newValues);
122
- const remainingPaths = fieldPaths.value.filter(path => {
123
- return !keysToUpdate.some(k => path === k || path.startsWith(k + "."));
124
- });
125
- fieldPaths.value = remainingPaths;
126
- walk(newValues);
232
+ if (newValues !== undefined) initialValuesSig.value = clone(newValues);
233
+ valuesSig.value = clone(initialValuesSig.value);
234
+ errorsSig.value = {};
235
+ touchedSig.value = {};
236
+ changedSig.value = {};
237
+ isSubmittedSig.value = false;
238
+ notifySignals("v");
239
+ notifySignals("e");
240
+ notifySignals("t");
241
+ notifySignals("c");
127
242
  });
128
- }
243
+ };
129
244
 
130
- function array(path) {
131
- return computed(() => values.value[path] || []);
132
- }
245
+ const proxyCache = new Map();
133
246
 
134
- return {
135
- register,
136
- handleSubmit,
137
- setValues,
138
- getValues: () => values.value,
139
- array,
140
- values: new Proxy({}, {
141
- get: (_, key) => values.value[key],
142
- set: (_, key, val) => {
143
- setValues({ [key]: val });
247
+ function createDeepProxy(sig, path = [], defaultValue = undefined) {
248
+
249
+ const pathStr = path.join(".");
250
+ // Use signal identity in cache key to avoid collisions between values/errors/etc.
251
+ const sigKey = sig === valuesSig ? "v" : sig === errorsSig ? "e" : sig === touchedSig ? "t" : "c";
252
+ const cacheKey = `${sigKey}:${pathStr}:${defaultValue}`;
253
+ if (proxyCache.has(cacheKey)) return proxyCache.get(cacheKey);
254
+
255
+ const val = untracked(() => (path.length === 0 ? sig.value : get(sig.value, pathStr)));
256
+
257
+ const proxy = new Proxy(val || {}, {
258
+ get(_, prop) {
259
+ const latestVal = untracked(() => (path.length === 0 ? sig.value : get(sig.value, pathStr)));
260
+
261
+ if (prop === Symbol.toStringTag) {
262
+ return Array.isArray(latestVal) ? "Array" : "Object";
263
+ }
264
+ if (prop === "toJSON") {
265
+ return () => (path.length === 0 ? sig.value : get(sig.value, pathStr));
266
+ }
267
+ if (prop === Symbol.iterator) {
268
+ return latestVal[Symbol.iterator].bind(latestVal);
269
+ }
270
+ if (typeof prop === "symbol") return undefined;
271
+ if (["__proto__", "constructor", "prototype"].includes(prop)) return undefined;
272
+
273
+ const currentPathStr = pathStr ? `${pathStr}.${prop}` : prop;
274
+ const value = latestVal ? latestVal[prop] : undefined;
275
+
276
+ if (Array.isArray(latestVal) && ["push", "pop", "shift", "unshift", "splice", "reverse", "sort"].includes(prop)) {
277
+ return (...args) => {
278
+ const nextArr = [...latestVal];
279
+ const result = nextArr[prop](...args);
280
+ updateValue(pathStr, nextArr);
281
+ return result;
282
+ };
283
+ }
284
+
285
+ if (typeof value === "function") {
286
+ return value.bind(latestVal);
287
+ }
288
+
289
+ // Subscribe to path-specific signal
290
+ const subSig = getSignal(sigKey, currentPathStr, value ?? defaultValue);
291
+ if (!isSetup) subSig.value;
292
+
293
+ if (value && typeof value === "object") {
294
+ return createDeepProxy(sig, [...path, prop], defaultValue);
295
+ }
296
+
297
+ return value ?? defaultValue;
298
+ },
299
+ set(_, prop, val) {
300
+
301
+ const currentPathStr = [...path, prop].join(".");
302
+ if (sig === valuesSig) updateValue(currentPathStr, val);
303
+ else {
304
+ const newState = clone(sig.value);
305
+ set(newState, currentPathStr, val);
306
+ sig.value = newState;
307
+ }
144
308
  return true;
145
309
  },
146
- ownKeys: (_) => Object.keys(values.value),
147
- getOwnPropertyDescriptor: (_, key) => {
148
- const val = values.value[key];
149
- return val !== undefined ? {
150
- enumerable: true,
151
- configurable: true,
152
- value: val,
153
- writable: true
154
- } : undefined;
310
+ ownKeys() {
311
+ const val = untracked(() => (path.length === 0 ? sig.value : get(sig.value, pathStr)));
312
+ if (!isSetup) {
313
+ const subSig = getSignal(sigKey, pathStr, val);
314
+ subSig.value;
315
+ }
316
+ const keys = Object.keys(val || {});
317
+ if (Array.isArray(val) && !keys.includes("length")) {
318
+ keys.push("length");
319
+ }
320
+ return keys;
321
+ },
322
+ getOwnPropertyDescriptor(_, prop) {
323
+ const latestVal = untracked(() => (path.length === 0 ? sig.value : get(sig.value, pathStr)));
324
+ if (latestVal && typeof latestVal === "object" && prop in latestVal) {
325
+ return Object.getOwnPropertyDescriptor(latestVal, prop);
326
+ }
327
+ return undefined;
155
328
  }
156
- }),
157
- errors,
158
- touched: new Proxy({}, {
159
- get: (_, key) => touched[key]
160
- }),
161
- fields // Also expose the raw signals map for advanced usage
329
+ });
330
+
331
+ proxyCache.set(cacheKey, proxy);
332
+ return proxy;
333
+ }
334
+
335
+ const form = {
336
+ register,
337
+ handleSubmit,
338
+ reset,
339
+ _updateConfig(newMode, newReValidateMode) {
340
+ batch(() => {
341
+ modeSig.value = newMode;
342
+ reValidateModeSig.value = newReValidateMode;
343
+ });
344
+ },
345
+ _notifySignals: notifySignals,
346
+ get values() { return createDeepProxy(valuesSig); },
347
+ get errors() { return createDeepProxy(errorsSig); },
348
+ get touched() { return createDeepProxy(touchedSig, [], false); },
349
+ get changed() { return createDeepProxy(changedSig, [], false); },
350
+ _signals: {
351
+ values: valuesSig,
352
+ errors: errorsSig,
353
+ touched: touchedSig,
354
+ changed: changedSig,
355
+ initialValues: initialValuesSig,
356
+ }
162
357
  };
358
+
359
+ const getSafe = (sig) => (isSetup ? untracked(() => sig.value) : sig.value);
360
+
361
+ Object.defineProperties(form, {
362
+ isValid: { get: () => getSafe(isValidSig), enumerable: true },
363
+ isChanged: { get: () => getSafe(isDirtySig), enumerable: true },
364
+ isTouched: { get: () => getSafe(isTouchedSig), enumerable: true },
365
+ isSubmitting: { get: () => getSafe(isSubmittingSig), enumerable: true },
366
+ isValidating: { get: () => getSafe(isValidatingSig), enumerable: true },
367
+ isSubmitted: { get: () => getSafe(isSubmittedSig), enumerable: true },
368
+ submitCount: { get: () => getSafe(submitCountSig), enumerable: true },
369
+ });
370
+
371
+ // Initial validation (only if onChange mode)
372
+ if (getMode() === "onChange") {
373
+ runValidation();
374
+ }
375
+
376
+ return form;
163
377
  }