@opentf/web-form 0.1.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 ADDED
@@ -0,0 +1,18 @@
1
+ # @opentf/web-form
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
+
13
+ ## 1.0.0
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies [bb1c71b]
18
+ - @opentf/web@0.5.0
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # @opentf/web-form
2
+
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.
8
+
9
+ ## Features
10
+
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.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install @opentf/web-form
22
+ # or
23
+ bun add @opentf/web-form
24
+ ```
25
+
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);
46
+
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>}
52
+
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.
72
+
73
+ ```jsx
74
+ <p>Hello, {form.values.username || "stranger"}</p>
75
+ ```
76
+
77
+ ## Nested state & arrays
78
+
79
+ Address objects and arrays by dot-path; arrays use the index as a segment.
80
+
81
+ ```jsx
82
+ const form = createForm({
83
+ initialValues: {
84
+ user: {
85
+ profile: { firstName: "John" },
86
+ settings: { notifications: true },
87
+ },
88
+ },
89
+ });
90
+
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
+ });
135
+ ```
136
+
137
+ ### Schema validation (Zod)
138
+
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`:
141
+
142
+ ```jsx
143
+ import { z } from "zod";
144
+
145
+ const schema = z.object({
146
+ username: z.string().min(3, "Too short"),
147
+ email: z.string().email("Invalid email"),
148
+ });
149
+
150
+ const zodResolver = (schema) => (values) => {
151
+ const result = schema.safeParse(values);
152
+ if (result.success) return { errors: {} };
153
+ const errors = {};
154
+ for (const issue of result.error.issues) {
155
+ errors[issue.path.join(".")] = issue.message;
156
+ }
157
+ return { errors };
158
+ };
159
+
160
+ const form = createForm({
161
+ initialValues: { username: "", email: "" },
162
+ validator: zodResolver(schema),
163
+ });
164
+ ```
165
+
166
+ A validator may be **async** (return a Promise); `form.isValidating` is `true`
167
+ while it resolves β€” useful for server-side uniqueness checks.
168
+
169
+ ### Validation modes
170
+
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 |
175
+
176
+ ```js
177
+ createForm({ mode: "onChange", reValidateMode: "onBlur" });
178
+ ```
179
+
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`.
182
+
183
+ ## Status flags
184
+
185
+ `createForm` exposes derived flags β€” read them directly (they are reactive in JSX):
186
+
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"}
200
+ </button>
201
+ ```
202
+
203
+ Per-field `touched` and `changed` state is on the matching store:
204
+ `form.touched.email`, `form.changed.user`.
205
+
206
+ ## Resetting
207
+
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
218
+ ```
219
+
220
+ ## License
221
+
222
+ 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"