@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 +18 -0
- package/README.md +222 -0
- package/bunfig.toml +6 -0
- package/index.js +317 -130
- package/package.json +12 -5
- package/tests/Basic.test.js +37 -0
- package/tests/BasicForm.jsx +29 -0
- package/tests/Component.test.js +46 -0
- package/tests/DynamicArrayForm.jsx +32 -0
- package/tests/DynamicArrays.test.js +33 -0
- package/tests/DynamicListForm.jsx +20 -0
- package/tests/Logic.test.js +153 -0
- package/tests/NestedFields.test.js +28 -0
- package/tests/NestedForm.jsx +32 -0
- package/tests/ProfileForm.jsx +34 -0
- package/tests/Reactivity.test.js +26 -0
- package/tests/RegisterMetadata.test.js +83 -0
- package/tests/State.test.js +93 -0
- package/tests/StateComponent.test.js +58 -0
- package/tests/StateForm.jsx +29 -0
- package/tests/ValidationModes.test.js +84 -0
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)
|