@opentf/web-form 1.0.0 → 1.1.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 CHANGED
@@ -1,5 +1,15 @@
1
1
  # @opentf/web-form
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.1.0] - 2026-07-01
6
+
7
+ - **Engine Rebuild:** Rebuilt the form engine on the `reactive()` store primitive.
8
+ - **Dependency Fix:** Normalized the peer dependency range for `@opentf/web` to `*` to improve compatibility.
9
+ - **Documentation:** Updated the forms documentation to reflect the new `reactive()` engine, including working examples.
10
+ - **Testing:** Restructured the test suite to adopt a fixture-per-component convention.
11
+
12
+
3
13
  ## 1.0.0
4
14
 
5
15
  ### Patch Changes
package/README.md CHANGED
@@ -1,139 +1,220 @@
1
1
  # @opentf/web-form
2
2
 
3
- A reactive form management library for **OTF Web**.
3
+ A high-performance, **path-based reactive forms engine** for [OTF Web](https://github.com/Open-Tech-Foundation/Web-App-Framework).
4
+
5
+ Fields bind directly to a `reactive()` store, so updates are surgical — typing in
6
+ one field only re-runs the effects that read that field. No virtual DOM, no
7
+ re-render, no memoization.
4
8
 
5
9
  ## Features
6
10
 
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.
11
+ - 🌿 **Reactive values** read `form.values.user.name` / `form.errors.email`
12
+ straight in your JSX; they update fine-grained.
13
+ - 🌲 **Nested state & arrays** address any depth by dot-path (`"user.profile.firstName"`, `"tags.0"`).
14
+ - 🛡️ **Validation** a plain function or a schema library (Zod, Valibot, …), with `onChange` / `onBlur` / `onSubmit` modes.
15
+ - 🚀 **Fine-grained** per-keystroke work is `O(path)`, not `O(fields)`.
16
+ - 🛠️ **`register` pattern** — spread one call onto an input to wire it up.
12
17
 
13
18
  ## Installation
14
19
 
15
20
  ```bash
16
21
  npm install @opentf/web-form
22
+ # or
23
+ bun add @opentf/web-form
17
24
  ```
18
25
 
19
- ## Basic Usage
26
+ ## Basic usage
27
+
28
+ `createForm` returns a stable form object. Spread `register(path)` onto an input
29
+ to bind it, and wrap your submit handler with `handleSubmit`.
30
+
31
+ ```jsx
32
+ import { createForm } from "@opentf/web-form";
33
+
34
+ export function SignupForm() {
35
+ const form = createForm({
36
+ initialValues: { username: "", email: "" },
37
+ validate: (values) => {
38
+ const errors = {};
39
+ if (!values.username) errors.username = "Username required";
40
+ if (!values.email.includes("@")) errors.email = "Invalid email";
41
+ return errors;
42
+ },
43
+ });
44
+
45
+ const onSubmit = (values) => console.log("Submitted:", values);
20
46
 
21
- ### Using the `register` Pattern
47
+ return (
48
+ <form onsubmit={form.handleSubmit(onSubmit)}>
49
+ <label for="username">Username</label>
50
+ <input {...form.register("username")} id="username" />
51
+ {form.errors.username && <span class="error">{form.errors.username}</span>}
22
52
 
23
- The `register` function returns all necessary props (`name`, `value`, `oninput`, `onblur`) to bind a field to an input element.
53
+ <label for="email">Email</label>
54
+ <input {...form.register("email")} id="email" />
55
+ {form.errors.email && <span class="error">{form.errors.email}</span>}
56
+
57
+ <button type="submit">Sign up</button>
58
+ </form>
59
+ );
60
+ }
61
+ ```
62
+
63
+ `register(path)` returns everything a field needs — `name`, `value`, `checked`
64
+ (for checkboxes/radios), `error`, `isTouched`, `oninput`, and `onblur` — so a
65
+ controlled input is one line.
66
+
67
+ ## Reactive values
68
+
69
+ `form.values`, `form.errors`, `form.touched`, and `form.changed` are reactive
70
+ stores — read them directly in your template and only the nodes that read a
71
+ changed path update. There is no provider to thread through.
24
72
 
25
73
  ```jsx
26
- import { createForm } from '@opentf/web-form';
74
+ <p>Hello, {form.values.username || "stranger"}</p>
75
+ ```
27
76
 
77
+ ## Nested state & arrays
78
+
79
+ Address objects and arrays by dot-path; arrays use the index as a segment.
80
+
81
+ ```jsx
28
82
  const form = createForm({
29
- initialValues: { username: '' }
83
+ initialValues: {
84
+ user: {
85
+ profile: { firstName: "John" },
86
+ settings: { notifications: true },
87
+ },
88
+ },
30
89
  });
31
90
 
32
- // In your component
33
- <input {...form.register('username')} />
91
+ <input {...form.register("user.profile.firstName")} />
92
+ <input {...form.register("user.settings.notifications")} type="checkbox" />
93
+ ```
94
+
95
+ Read nested values and errors with ordinary property access:
96
+
97
+ ```jsx
98
+ <p>{form.values.user.profile.firstName}</p>
99
+ {form.errors.user?.firstName && <span class="error">{form.errors.user.firstName}</span>}
100
+ ```
101
+
102
+ ### Dynamic arrays
103
+
104
+ The values store is a real reactive object — mutate it directly:
105
+
106
+ ```jsx
107
+ const form = createForm({ initialValues: { skills: ["JavaScript"] } });
108
+
109
+ // Add / remove
110
+ const addSkill = () => form.values.skills.push("");
111
+ const removeSkill = (i) => form.values.skills.splice(i, 1);
112
+
113
+ // Render a row per item; editing one input never re-renders the others
114
+ {form.values.skills.map((skill, i) => (
115
+ <input {...form.register(`skills.${i}`)} />
116
+ ))}
117
+ ```
118
+
119
+ ## Validation
120
+
121
+ A validator receives the current `values` and returns an errors object keyed by
122
+ field path. Use `validate` to return the errors object directly, or `validator`
123
+ to return `{ errors }` (handy for schema resolvers).
124
+
125
+ ```jsx
126
+ const form = createForm({
127
+ mode: "onChange",
128
+ initialValues: { age: 0 },
129
+ validate: (values) => {
130
+ const errors = {};
131
+ if (values.age < 18) errors.age = "Too young";
132
+ return errors;
133
+ },
134
+ });
34
135
  ```
35
136
 
36
- ## Validation with Zod
137
+ ### Schema validation (Zod)
37
138
 
38
- You can easily integrate **Zod** for schema-based validation by providing a `validator` function.
139
+ Wrap a schema in a resolver that maps issues to the path-keyed shape. The
140
+ resolver closes over the schema and takes the form `values`:
39
141
 
40
- ```javascript
41
- import { createForm } from '@opentf/web-form';
42
- import { z } from 'zod';
142
+ ```jsx
143
+ import { z } from "zod";
43
144
 
44
145
  const schema = z.object({
45
- username: z.string().min(3, 'Too short'),
46
- email: z.string().email('Invalid email')
146
+ username: z.string().min(3, "Too short"),
147
+ email: z.string().email("Invalid email"),
47
148
  });
48
149
 
49
- // A simple Zod validator wrapper
50
- const zodValidator = (values, schema) => {
150
+ const zodResolver = (schema) => (values) => {
51
151
  const result = schema.safeParse(values);
52
- if (result.success) return {};
152
+ if (result.success) return { errors: {} };
53
153
  const errors = {};
54
- result.error.errors.forEach(e => {
55
- errors[e.path.join('.')] = e.message;
56
- });
154
+ for (const issue of result.error.issues) {
155
+ errors[issue.path.join(".")] = issue.message;
156
+ }
57
157
  return { errors };
58
158
  };
59
159
 
60
160
  const form = createForm({
61
- initialValues: { username: '', email: '' },
62
- schema,
63
- validator: zodValidator // Can return { errors: ... } or direct errors object
161
+ initialValues: { username: "", email: "" },
162
+ validator: zodResolver(schema),
64
163
  });
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
164
  ```
85
165
 
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`. |
166
+ A validator may be **async** (return a Promise); `form.isValidating` is `true`
167
+ while it resolves — useful for server-side uniqueness checks.
99
168
 
100
- ## Validation Modes
169
+ ### Validation modes
101
170
 
102
- You can customize when validation triggers using `mode` and `reValidateMode`.
171
+ | Option | Values | Default | When it validates |
172
+ | :--- | :--- | :--- | :--- |
173
+ | `mode` | `"onChange"` `"onBlur"` `"onSubmit"` | `"onBlur"` | `onChange`: every edit + on mount · `onBlur`: on blur · `onSubmit`: only on submit |
174
+ | `reValidateMode` | `"onChange"` `"onBlur"` | `"onChange"` | After the first error exists, when to re-validate |
103
175
 
104
- ```javascript
105
- const form = createForm({
106
- mode: 'onBlur', // Initial validation: 'onSubmit', 'onBlur', 'onTouched', 'onChange'
107
- reValidateMode: 'onChange' // Re-validation: 'onChange', 'onBlur', 'onSubmit'
108
- });
176
+ ```js
177
+ createForm({ mode: "onChange", reValidateMode: "onBlur" });
109
178
  ```
110
179
 
111
- - **Default mode**: `onBlur`
112
- - **Default reValidateMode**: `onChange`
180
+ `handleSubmit` always runs the validator first and only calls your handler when
181
+ the form is valid, so a final check is guaranteed regardless of `mode`.
113
182
 
114
- ### Resetting the Form
183
+ ## Status flags
115
184
 
116
- You can restore the form to its `initialValues` and clear all status flags using the `reset()` method.
185
+ `createForm` exposes derived flags read them directly (they are reactive in JSX):
117
186
 
118
- ```javascript
119
- <button type="button" onclick={() => form.reset()}>
120
- Reset Form
187
+ | Flag | Description |
188
+ | :--- | :--- |
189
+ | `isValid` | `true` when there are no errors. |
190
+ | `isChanged` | `true` when values differ from `initialValues`. |
191
+ | `isTouched` | `true` when any field has been blurred. |
192
+ | `isSubmitting` | `true` while an async `onSubmit` handler is running. |
193
+ | `isValidating` | `true` while an async validator is running. |
194
+ | `isSubmitted` | `true` after a successful submit. |
195
+ | `submitCount` | Number of submit attempts. |
196
+
197
+ ```jsx
198
+ <button type="submit" disabled={form.isSubmitting || !form.isValid}>
199
+ {form.isSubmitting ? "Saving…" : "Save"}
121
200
  </button>
122
201
  ```
123
202
 
124
- ## Custom Validation
203
+ Per-field `touched` and `changed` state is on the matching store:
204
+ `form.touched.email`, `form.changed.user`.
125
205
 
126
- For simpler cases, you can use the `validate` function which expects you to return the errors object directly.
206
+ ## Resetting
127
207
 
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
- });
208
+ `reset()` restores `initialValues` and clears status. Pass an object to reset to
209
+ new baseline values (which become the new "initial" for `isChanged`).
210
+
211
+ ```jsx
212
+ <button type="button" onclick={() => form.reset()}>Reset</button>
213
+ ```
214
+
215
+ ```js
216
+ form.reset(); // back to initial
217
+ form.reset({ username: "" }); // new baseline
137
218
  ```
138
219
 
139
220
  ## License
package/index.js CHANGED
@@ -1,223 +1,229 @@
1
-
2
1
  import {
3
2
  signal,
4
3
  computed,
5
- batch,
6
4
  effect,
5
+ batch,
7
6
  untracked,
8
7
  getCurrentInstance,
8
+ reactive,
9
+ toRawValue,
10
+ snapshot,
9
11
  } from "@opentf/web";
10
12
 
11
- export { signal, computed, batch, effect, untracked, getCurrentInstance };
13
+ export { signal, computed, batch, effect, untracked, reactive, getCurrentInstance };
12
14
 
13
- import { set, get, unset, clone, isEql } from "@opentf/std";
15
+ import { clone, isEql } from "@opentf/std";
14
16
 
15
17
  /**
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
18
+ * Path-based reactive forms engine for OTF Web.
19
+ *
20
+ * The form's values/errors/touched/changed trees are `reactive()` stores
21
+ * (@opentf/web), so `form.values.user.name` and `form.errors.email` are read
22
+ * directly in JSX with fine-grained reactivity — no provider object, no bespoke
23
+ * proxy. The engine layers field registration, validation modes, and submit
24
+ * orchestration on top.
22
25
  */
23
26
 
24
27
  const INST_FORMS_REGISTRY = new WeakMap();
25
- let isSetup = false;
26
28
 
27
29
  export function createForm(options = {}) {
30
+ // Scope the form to the mounting component so re-entrant `createForm` calls
31
+ // (e.g. the component body running again) return the same instance. Outside a
32
+ // component (module scope, tests) there is no instance — just build one.
28
33
  const inst = getCurrentInstance();
29
34
  if (!inst) return _createForm(options);
30
35
 
31
- if (!INST_FORMS_REGISTRY.has(inst)) {
32
- INST_FORMS_REGISTRY.set(inst, new Map());
33
- }
34
- const forms = INST_FORMS_REGISTRY.get(inst);
36
+ let forms = INST_FORMS_REGISTRY.get(inst);
37
+ if (!forms) INST_FORMS_REGISTRY.set(inst, (forms = new Map()));
35
38
 
36
- const key = options.key || JSON.stringify({
37
- initialValues: options.initialValues
38
- });
39
-
39
+ const key = options.key || JSON.stringify({ initialValues: options.initialValues });
40
40
  if (forms.has(key)) {
41
41
  const form = forms.get(key);
42
- if (form._updateConfig) {
43
- form._updateConfig(options.mode, options.reValidateMode);
44
- }
42
+ form._updateConfig?.(options.mode, options.reValidateMode);
45
43
  return form;
46
44
  }
47
-
48
- isSetup = true;
49
- try {
50
- const form = _createForm(options);
51
- forms.set(key, form);
52
- return form;
53
- } finally {
54
- isSetup = false;
55
- }
45
+ const form = _createForm(options);
46
+ forms.set(key, form);
47
+ return form;
56
48
  }
57
49
 
58
50
  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({});
51
+ let initialValues = clone(options.initialValues || {});
52
+
53
+ const valuesStore = reactive(clone(initialValues));
54
+ const errorsStore = reactive({});
55
+ // touched mirrors the value tree with `false` leaves, so a field reads `false`
56
+ // (not undefined) before it is ever blurred. `changed` is filled by the
57
+ // creation effect below (top-level booleans vs. initial values).
58
+ const touchedStore = reactive(mirrorLeaves(initialValues, false));
59
+ const changedStore = reactive({});
60
+
61
+ const modeSig = signal(options.mode);
62
+ const reValidateModeSig = signal(options.reValidateMode);
64
63
  const isSubmittingSig = signal(false);
65
64
  const isValidatingSig = signal(false);
66
65
  const isSubmittedSig = signal(false);
67
66
  const submitCountSig = signal(0);
68
- const modeSig = signal(options.mode);
69
- const reValidateModeSig = signal(options.reValidateMode);
70
67
 
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;
68
+ const normMode = (m) =>
69
+ ((m && typeof m === "object" && "value" in m ? m.value : m) || "onBlur");
70
+ const normReValidate = (m) =>
71
+ ((m && typeof m === "object" && "value" in m ? m.value : m) || "onChange");
72
+
73
+ const activeValidator = options.validator || options.validate;
74
+
75
+ // --- path helpers: walk the reactive store so reads subscribe & writes notify
76
+ function readPath(store, path) {
77
+ if (!path) return store;
78
+ let node = store;
79
+ for (const p of path.split(".")) {
80
+ if (node == null) return undefined;
81
+ node = node[p];
82
+ }
83
+ return node;
83
84
  }
84
85
 
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;
86
+ function writePath(store, path, value) {
87
+ const parts = path.split(".");
88
+ let node = store;
89
+ for (let i = 0; i < parts.length - 1; i++) {
90
+ const p = parts[i];
91
+ let next = node[p];
92
+ if (next == null || typeof next !== "object") {
93
+ node[p] = {};
94
+ next = node[p];
95
+ }
96
+ node = next;
94
97
  }
98
+ node[parts[parts.length - 1]] = value;
95
99
  }
96
100
 
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;
101
+ function deletePath(store, path) {
102
+ const parts = path.split(".");
103
+ let node = store;
104
+ for (let i = 0; i < parts.length - 1; i++) {
105
+ node = node[parts[i]];
106
+ if (node == null || typeof node !== "object") return;
107
+ }
108
+ delete node[parts[parts.length - 1]];
109
+ }
106
110
 
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;
113
- });
111
+ // Reconcile a reactive store in place to match a plain object, touching only
112
+ // the leaves that actually differ (fine-grained no wholesale replace).
113
+ function syncStore(store, next) {
114
+ next = next || {};
115
+ const raw = toRawValue(store);
116
+ for (const k of Object.keys(raw)) {
117
+ if (!(k in next)) delete store[k];
118
+ }
119
+ for (const k of Object.keys(next)) {
120
+ const nv = next[k];
121
+ const cur = raw[k];
122
+ if (nv && typeof nv === "object" && cur && typeof cur === "object") {
123
+ syncStore(store[k], nv);
124
+ } else if (!isEql(cur, nv)) {
125
+ store[k] = nv;
126
+ }
127
+ }
114
128
  }
115
129
 
116
- const isValidSig = computed(() => !hasErrors(errorsSig.value));
117
- const isTouchedSig = computed(() => hasErrors(touchedSig.value));
118
- const isDirtySig = computed(() => !isEql(valuesSig.value, initialValuesSig.value));
130
+ // --- validation
131
+ const normalizeErrors = (r) => (r && r.errors !== undefined ? r.errors : r) || {};
119
132
 
120
- function updateErrors(results, fieldPath) {
121
- const currentErrors = results && results.errors ? results.errors : results || {};
133
+ function applyErrors(errorsObj, fieldPath) {
122
134
  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;
135
+ const fieldErr = readRaw(errorsObj, fieldPath);
136
+ if (fieldErr) writePath(errorsStore, fieldPath, fieldErr);
137
+ else deletePath(errorsStore, fieldPath);
128
138
  } else {
129
- errorsSig.value = currentErrors || {};
139
+ syncStore(errorsStore, errorsObj);
130
140
  }
131
- notifySignals("e");
132
141
  }
133
142
 
143
+ let validationToken = 0;
134
144
  function runValidation(fieldPath) {
135
145
  if (!activeValidator) return;
146
+ const token = ++validationToken;
147
+ const result = activeValidator(snapshot(valuesStore));
148
+ if (result instanceof Promise) {
149
+ isValidatingSig.value = true;
150
+ return result
151
+ .then((r) => {
152
+ if (token === validationToken) applyErrors(normalizeErrors(r), fieldPath);
153
+ })
154
+ .finally(() => {
155
+ if (token === validationToken) isValidatingSig.value = false;
156
+ });
157
+ }
158
+ applyErrors(normalizeErrors(result), fieldPath);
159
+ }
160
+
161
+ // --- changed tracking: per top-level key, deep-compared to initial values.
162
+ function recomputeChanged() {
163
+ const v = toRawValue(valuesStore);
164
+ const keys = new Set([...Object.keys(v), ...Object.keys(initialValues)]);
165
+ for (const k of keys) {
166
+ const ch = !isEql(v[k], initialValues[k]);
167
+ if (toRawValue(changedStore)[k] !== ch) changedStore[k] = ch;
168
+ }
169
+ }
136
170
 
171
+ // One effect observes every value change and (a) recomputes `changed`, and
172
+ // (b) in onChange mode, revalidates — covering inputs, direct assignment, and
173
+ // array mutation alike. Writes are untracked so it never depends on its own
174
+ // output. Validates once on creation, satisfying onChange's "validate on init".
175
+ effect(() => {
176
+ deepTrack(valuesStore);
177
+ const mode = normMode(modeSig.value);
137
178
  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);
179
+ recomputeChanged();
180
+ if (mode === "onChange") runValidation();
144
181
  });
145
- }
182
+ });
146
183
 
184
+ // --- field registration
147
185
  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
- });
186
+ writePath(valuesStore, path, val);
187
+ const mode = normMode(modeSig.peek());
188
+ if (mode === "onChange") return; // the effect above revalidates
189
+ if (hasTruthy(toRawValue(errorsStore)) && normReValidate(reValidateModeSig.peek()) === "onChange") {
190
+ runValidation(path);
191
+ }
175
192
  }
176
- const register = (path) => {
177
- const valSig = getSignal("v", path);
178
- const errSig = getSignal("e", path);
179
- const touchSig = getSignal("t", path, false);
180
-
181
- return {
182
- name: path,
183
- value: valSig,
184
- checked: valSig,
185
- error: errSig,
186
- isTouched: touchSig,
187
- oninput: (e) => {
188
- const val = e.target.type === "checkbox" ? e.target.checked : e.target.value;
189
- updateValue(path, val);
190
- },
191
- onblur: () => {
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
- },
199
- };
200
- };
201
193
 
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
- }
194
+ const register = (path) => ({
195
+ name: path,
196
+ value: readPath(valuesStore, path),
197
+ checked: readPath(valuesStore, path),
198
+ error: computed(() => readPath(errorsStore, path)),
199
+ isTouched: computed(() => readPath(touchedStore, path) ?? false),
200
+ oninput: (e) => {
201
+ const t = e.target;
202
+ updateValue(path, t.type === "checkbox" ? t.checked : t.value);
203
+ },
204
+ onblur: () => {
205
+ writePath(touchedStore, path, true);
206
+ const mode = normMode(modeSig.peek());
207
+ if (mode === "onBlur") runValidation(path);
208
+ else if (
209
+ hasTruthy(toRawValue(errorsStore)) &&
210
+ normReValidate(reValidateModeSig.peek()) === "onBlur"
211
+ ) {
212
+ runValidation(path);
213
+ }
214
+ },
215
+ });
210
216
 
217
+ // --- submit / reset
211
218
  const handleSubmit = (fn) => async (e) => {
212
219
  if (e && e.preventDefault) e.preventDefault();
213
- touchedSig.value = markAllTouched(valuesSig.value);
214
- notifySignals("t");
220
+ syncStore(touchedStore, mirrorLeaves(snapshot(valuesStore), true));
215
221
  isSubmittingSig.value = true;
216
222
  submitCountSig.value++;
217
223
  await runValidation();
218
- if (isValidSig.value) {
224
+ if (!hasTruthy(toRawValue(errorsStore))) {
219
225
  try {
220
- await fn(valuesSig.value);
226
+ await fn(snapshot(valuesStore));
221
227
  isSubmittedSig.value = true;
222
228
  } finally {
223
229
  isSubmittingSig.value = false;
@@ -229,108 +235,19 @@ function _createForm(options = {}) {
229
235
 
230
236
  const reset = (newValues) => {
231
237
  batch(() => {
232
- if (newValues !== undefined) initialValuesSig.value = clone(newValues);
233
- valuesSig.value = clone(initialValuesSig.value);
234
- errorsSig.value = {};
235
- touchedSig.value = {};
236
- changedSig.value = {};
238
+ if (newValues !== undefined) initialValues = clone(newValues);
239
+ syncStore(valuesStore, clone(initialValues));
240
+ syncStore(errorsStore, {});
241
+ syncStore(touchedStore, mirrorLeaves(initialValues, false));
242
+ syncStore(changedStore, {});
237
243
  isSubmittedSig.value = false;
238
- notifySignals("v");
239
- notifySignals("e");
240
- notifySignals("t");
241
- notifySignals("c");
242
244
  });
243
245
  };
244
246
 
245
- const proxyCache = new Map();
246
-
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
- }
308
- return true;
309
- },
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;
328
- }
329
- });
330
-
331
- proxyCache.set(cacheKey, proxy);
332
- return proxy;
333
- }
247
+ // --- derived flags
248
+ const isValidSig = computed(() => !hasTruthy(errorsStore));
249
+ const isTouchedSig = computed(() => hasTruthy(touchedStore));
250
+ const isChangedSig = computed(() => hasTruthy(changedStore));
334
251
 
335
252
  const form = {
336
253
  register,
@@ -342,36 +259,92 @@ function _createForm(options = {}) {
342
259
  reValidateModeSig.value = newReValidateMode;
343
260
  });
344
261
  },
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); },
262
+ _notifySignals() {}, // legacy no-op: reactive stores notify on write
263
+ get values() { return valuesStore; },
264
+ get errors() { return errorsStore; },
265
+ get touched() { return touchedStore; },
266
+ get changed() { return changedStore; },
350
267
  _signals: {
351
- values: valuesSig,
352
- errors: errorsSig,
353
- touched: touchedSig,
354
- changed: changedSig,
355
- initialValues: initialValuesSig,
356
- }
268
+ values: storeSignal(valuesStore),
269
+ errors: storeSignal(errorsStore),
270
+ touched: storeSignal(touchedStore),
271
+ changed: storeSignal(changedStore),
272
+ },
357
273
  };
358
274
 
359
- const getSafe = (sig) => (isSetup ? untracked(() => sig.value) : sig.value);
360
-
361
275
  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 },
276
+ isValid: { get: () => isValidSig.value, enumerable: true },
277
+ isChanged: { get: () => isChangedSig.value, enumerable: true },
278
+ isTouched: { get: () => isTouchedSig.value, enumerable: true },
279
+ isSubmitting: { get: () => isSubmittingSig.value, enumerable: true },
280
+ isValidating: { get: () => isValidatingSig.value, enumerable: true },
281
+ isSubmitted: { get: () => isSubmittedSig.value, enumerable: true },
282
+ submitCount: { get: () => submitCountSig.value, enumerable: true },
369
283
  });
370
284
 
371
- // Initial validation (only if onChange mode)
372
- if (getMode() === "onChange") {
373
- runValidation();
285
+ return form;
286
+ }
287
+
288
+ // --- shared helpers (no per-form state)
289
+
290
+ /** Raw (non-reactive) deep read by dot-path. */
291
+ function readRaw(obj, path) {
292
+ let node = obj;
293
+ for (const p of path.split(".")) {
294
+ if (node == null) return undefined;
295
+ node = node[p];
374
296
  }
297
+ return node;
298
+ }
375
299
 
376
- return form;
300
+ /** Whether any leaf is truthy — the basis of isValid/isTouched/isChanged. */
301
+ function hasTruthy(obj) {
302
+ if (obj == null || typeof obj !== "object") return !!obj;
303
+ return Object.values(obj).some(hasTruthy);
304
+ }
305
+
306
+ /** Mirror a tree's structure, replacing every leaf with `leaf`. */
307
+ function mirrorLeaves(obj, leaf) {
308
+ if (obj == null || typeof obj !== "object") return leaf;
309
+ const res = Array.isArray(obj) ? [] : {};
310
+ for (const k of Object.keys(obj)) res[k] = mirrorLeaves(obj[k], leaf);
311
+ return res;
312
+ }
313
+
314
+ /** Subscribe the active consumer to every path in a store (deep). */
315
+ function deepTrack(store) {
316
+ const raw = toRawValue(store);
317
+ if (raw == null || typeof raw !== "object") return;
318
+ for (const k of Object.keys(store)) {
319
+ const v = store[k];
320
+ if (v && typeof v === "object") deepTrack(v);
321
+ }
322
+ }
323
+
324
+ /**
325
+ * A preact-style signal facade over a reactive store for the advanced `_signals`
326
+ * API: `.value` snapshots/replaces the whole tree, `.subscribe(cb)` fires
327
+ * immediately then once per change.
328
+ */
329
+ function storeSignal(store) {
330
+ return {
331
+ get value() {
332
+ return snapshot(store);
333
+ },
334
+ set value(v) {
335
+ const next = v || {};
336
+ const raw = toRawValue(store);
337
+ for (const k of Object.keys(raw)) if (!(k in next)) delete store[k];
338
+ for (const k of Object.keys(next)) store[k] = next[k];
339
+ },
340
+ peek() {
341
+ return toRawValue(store);
342
+ },
343
+ subscribe(cb) {
344
+ return effect(() => {
345
+ deepTrack(store);
346
+ cb(snapshot(store));
347
+ });
348
+ },
349
+ };
377
350
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opentf/web-form",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "A high-performance, path-based reactive forms engine for the native-first OTF Web.",
5
5
  "main": "index.js",
6
6
  "module": "index.js",
@@ -12,7 +12,7 @@
12
12
  "@opentf/std": "^1.0.0-beta.2"
13
13
  },
14
14
  "peerDependencies": {
15
- "@opentf/web": "workspace:*"
15
+ "@opentf/web": "*"
16
16
  },
17
17
  "publishConfig": {
18
18
  "access": "public"
@@ -21,7 +21,7 @@
21
21
  "test": "bun test --env=happy-dom"
22
22
  },
23
23
  "devDependencies": {
24
- "@opentf/web": "workspace:*",
25
- "@opentf/web-test": "workspace:*"
24
+ "@opentf/web": "0.6.0",
25
+ "@opentf/web-test": "1.1.0"
26
26
  }
27
27
  }
@@ -1,45 +1,12 @@
1
1
  import { expect, test, describe } from "bun:test";
2
2
  import { render, userEvent } from "@opentf/web-test";
3
- import { createForm } from "../index.js";
4
-
5
- export function ProfileForm() {
6
- const form = createForm({
7
- initialValues: {
8
- user: { name: "John" },
9
- tags: ["js"]
10
- },
11
- validate: (v) => {
12
- const errors = {};
13
- if (v.user.name.length < 3) errors.user = { name: "Too short" };
14
- return errors;
15
- }
16
- });
17
-
18
- const addTag = () => form.values.tags.push(`tag-${form.values.tags.length}`);
19
-
20
- return (
21
- <div>
22
- <h1 data-testid="title">{form.values.user.name}</h1>
23
- <input {...form.register("user.name")} data-testid="name-input" />
24
- <div data-testid="name-error">{form.errors.user?.name}</div>
25
-
26
- <ul data-testid="tag-list">
27
- {form.values.tags.map((tag, i) => (
28
- <li data-testid={`tag-${i}`}>{tag}</li>
29
- ))}
30
- </ul>
31
- <button onclick={addTag} data-testid="add-tag">Add Tag</button>
32
-
33
- <div data-testid="status">{form.isValid ? "Valid" : "Invalid"}</div>
34
- </div>
35
- );
36
- }
3
+ import ProfileForm from "./ProfileForm.jsx";
37
4
 
38
5
  describe("Web Form Component Integration", () => {
39
6
  test("updates nested values and validation state in UI", async () => {
40
7
  const { getByTestId } = render(ProfileForm);
41
8
  const user = userEvent.setup();
42
-
9
+
43
10
  const input = getByTestId("name-input");
44
11
  const error = getByTestId("name-error");
45
12
  const status = getByTestId("status");
@@ -71,7 +38,7 @@ describe("Web Form Component Integration", () => {
71
38
  const addBtn = getByTestId("add-tag");
72
39
 
73
40
  expect(list.children.length).toBe(1);
74
-
41
+
75
42
  await user.click(addBtn);
76
43
  expect(list.children.length).toBe(2);
77
44
  expect(getByTestId("tag-1").textContent).toBe("tag-1");
@@ -0,0 +1,34 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function ProfileForm() {
4
+ const form = createForm({
5
+ initialValues: {
6
+ user: { name: "John" },
7
+ tags: ["js"]
8
+ },
9
+ validate: (v) => {
10
+ const errors = {};
11
+ if (v.user.name.length < 3) errors.user = { name: "Too short" };
12
+ return errors;
13
+ }
14
+ });
15
+
16
+ const addTag = () => form.values.tags.push(`tag-${form.values.tags.length}`);
17
+
18
+ return (
19
+ <div>
20
+ <h1 data-testid="title">{form.values.user.name}</h1>
21
+ <input {...form.register("user.name")} data-testid="name-input" />
22
+ <div data-testid="name-error">{form.errors.user?.name}</div>
23
+
24
+ <ul data-testid="tag-list">
25
+ {form.values.tags.map((tag, i) => (
26
+ <li data-testid={`tag-${i}`}>{tag}</li>
27
+ ))}
28
+ </ul>
29
+ <button onclick={addTag} data-testid="add-tag">Add Tag</button>
30
+
31
+ <div data-testid="status">{form.isValid ? "Valid" : "Invalid"}</div>
32
+ </div>
33
+ );
34
+ }
@@ -1,33 +1,7 @@
1
1
  import { expect, test, describe } from "bun:test";
2
2
  import { render, userEvent } from "@opentf/web-test";
3
- import { createForm } from "../index.js";
4
3
  import { sleep } from "@opentf/std";
5
-
6
- const StateForm = ({ onSubmit }) => {
7
- const form = createForm({
8
- mode: "onChange", // Use onChange for easier testing of reactive text
9
- initialValues: { username: "alice" },
10
- validate: (v) => v.username.length < 3 ? { username: "Too short" } : {}
11
- });
12
-
13
- return (
14
- <form onsubmit={form.handleSubmit(onSubmit)}>
15
- <input {...form.register("username")} data-testid="username" />
16
-
17
- <div data-testid="status-valid">{form.isValid ? "Valid" : "Invalid"}</div>
18
- <div data-testid="status-changed">{form.isChanged ? "Changed" : "Unchanged"}</div>
19
- <div data-testid="status-touched">{form.isTouched ? "Touched" : "Untouched"}</div>
20
-
21
- {form.errors.username && <span data-testid="error">{form.errors.username}</span>}
22
-
23
- <button type="button" onclick={() => form.reset()} data-testid="reset">Reset</button>
24
- <button type="submit" data-testid="submit">Submit</button>
25
-
26
- {form.isSubmitted && <div data-testid="success">Submitted!</div>}
27
- </form>
28
- );
29
-
30
- };
4
+ import StateForm from "./StateForm.jsx";
31
5
 
32
6
  const waitFor = async (fn, timeout = 1000) => {
33
7
  const start = Date.now();
@@ -71,7 +45,7 @@ describe("Form State UI Reactivity", () => {
71
45
  await user.clear(input);
72
46
  await user.type(input, "bob");
73
47
  await user.click(submitBtn);
74
-
48
+
75
49
  await waitFor(() => expect(submitted).toBe(true));
76
50
  await waitFor(() => expect(getByTestId("success")).toBeTruthy());
77
51
 
@@ -0,0 +1,29 @@
1
+ import { createForm } from "../index.js";
2
+
3
+ export default function StateForm({ onSubmit }) {
4
+ const form = createForm({
5
+ mode: "onChange", // Use onChange for easier testing of reactive text
6
+ initialValues: { username: "alice" },
7
+ validate: (v) => v.username.length < 3 ? { username: "Too short" } : {}
8
+ });
9
+
10
+ // Submit is driven via onclick rather than a native `type="submit"` (which, in
11
+ // happy-dom, mangles the form subtree on submission regardless of
12
+ // preventDefault — a test-env quirk, not a real-browser behaviour).
13
+ return (
14
+ <div>
15
+ <input {...form.register("username")} data-testid="username" />
16
+
17
+ <div data-testid="status-valid">{form.isValid ? "Valid" : "Invalid"}</div>
18
+ <div data-testid="status-changed">{form.isChanged ? "Changed" : "Unchanged"}</div>
19
+ <div data-testid="status-touched">{form.isTouched ? "Touched" : "Untouched"}</div>
20
+
21
+ {form.errors.username && <span data-testid="error">{form.errors.username}</span>}
22
+
23
+ <button type="button" onclick={() => form.reset()} data-testid="reset">Reset</button>
24
+ <button type="button" onclick={form.handleSubmit(onSubmit)} data-testid="submit">Submit</button>
25
+
26
+ {form.isSubmitted && <div data-testid="success">Submitted!</div>}
27
+ </div>
28
+ );
29
+ }
File without changes