@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 +10 -0
- package/README.md +167 -86
- package/index.js +253 -280
- package/package.json +4 -4
- package/tests/{Component.test.jsx → Component.test.js} +3 -36
- package/tests/ProfileForm.jsx +34 -0
- package/tests/{StateComponent.test.jsx → StateComponent.test.js} +2 -28
- package/tests/StateForm.jsx +29 -0
- /package/tests/{Basic.test.jsx → Basic.test.js} +0 -0
- /package/tests/{DynamicArrays.test.jsx → DynamicArrays.test.js} +0 -0
- /package/tests/{NestedFields.test.jsx → NestedFields.test.js} +0 -0
- /package/tests/{Reactivity.test.jsx → Reactivity.test.js} +0 -0
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
|
|
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
|
-
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
83
|
+
initialValues: {
|
|
84
|
+
user: {
|
|
85
|
+
profile: { firstName: "John" },
|
|
86
|
+
settings: { notifications: true },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
30
89
|
});
|
|
31
90
|
|
|
32
|
-
|
|
33
|
-
<input {...form.register(
|
|
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
|
-
|
|
137
|
+
### Schema validation (Zod)
|
|
37
138
|
|
|
38
|
-
|
|
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
|
-
```
|
|
41
|
-
import {
|
|
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,
|
|
46
|
-
email: z.string().email(
|
|
146
|
+
username: z.string().min(3, "Too short"),
|
|
147
|
+
email: z.string().email("Invalid email"),
|
|
47
148
|
});
|
|
48
149
|
|
|
49
|
-
|
|
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.
|
|
55
|
-
errors[
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
169
|
+
### Validation modes
|
|
101
170
|
|
|
102
|
-
|
|
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
|
-
```
|
|
105
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
183
|
+
## Status flags
|
|
115
184
|
|
|
116
|
-
|
|
185
|
+
`createForm` exposes derived flags — read them directly (they are reactive in JSX):
|
|
117
186
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
203
|
+
Per-field `touched` and `changed` state is on the matching store:
|
|
204
|
+
`form.touched.email`, `form.changed.user`.
|
|
125
205
|
|
|
126
|
-
|
|
206
|
+
## Resetting
|
|
127
207
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 {
|
|
15
|
+
import { clone, isEql } from "@opentf/std";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
43
|
-
form._updateConfig(options.mode, options.reValidateMode);
|
|
44
|
-
}
|
|
42
|
+
form._updateConfig?.(options.mode, options.reValidateMode);
|
|
45
43
|
return form;
|
|
46
44
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
const
|
|
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
|
|
121
|
-
const currentErrors = results && results.errors ? results.errors : results || {};
|
|
133
|
+
function applyErrors(errorsObj, fieldPath) {
|
|
122
134
|
if (fieldPath) {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
if (
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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 (
|
|
224
|
+
if (!hasTruthy(toRawValue(errorsStore))) {
|
|
219
225
|
try {
|
|
220
|
-
await fn(
|
|
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)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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:
|
|
346
|
-
get values() { return
|
|
347
|
-
get errors() { return
|
|
348
|
-
get touched() { return
|
|
349
|
-
get changed() { return
|
|
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:
|
|
352
|
-
errors:
|
|
353
|
-
touched:
|
|
354
|
-
changed:
|
|
355
|
-
|
|
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: () =>
|
|
363
|
-
isChanged: { get: () =>
|
|
364
|
-
isTouched: { get: () =>
|
|
365
|
-
isSubmitting: { get: () =>
|
|
366
|
-
isValidating: { get: () =>
|
|
367
|
-
isSubmitted: { get: () =>
|
|
368
|
-
submitCount: { get: () =>
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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": "
|
|
25
|
-
"@opentf/web-test": "
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|