feature-form 0.0.60 → 0.1.1
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/MIGRATION.md +153 -0
- package/README.md +390 -68
- package/dist/cjs/create-form-field.js +1 -0
- package/dist/cjs/create-form.js +1 -1
- package/dist/cjs/features/dirty.js +1 -0
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/lib/deep-copy.js +1 -0
- package/dist/cjs/lib/standard-schema.js +1 -0
- package/dist/cjs/lib/validation-status.js +1 -0
- package/dist/esm/create-form-field.js +1 -0
- package/dist/esm/create-form.js +1 -1
- package/dist/esm/features/dirty.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/lib/deep-copy.js +1 -0
- package/dist/esm/lib/standard-schema.js +1 -0
- package/dist/esm/lib/validation-status.js +1 -0
- package/dist/types/create-form-field.d.ts +18 -0
- package/dist/types/create-form.d.ts +48 -29
- package/dist/types/features/dirty.d.ts +33 -0
- package/dist/types/features/index.d.ts +1 -0
- package/dist/types/index.d.ts +2 -4
- package/dist/types/lib/deep-copy.d.ts +2 -0
- package/dist/types/lib/index.d.ts +3 -0
- package/dist/types/lib/standard-schema.d.ts +4 -0
- package/dist/types/lib/validation-status.d.ts +3 -0
- package/dist/types/types.d.ts +198 -0
- package/package.json +27 -12
- package/dist/cjs/form-field/create-form-field-validation-context.js +0 -1
- package/dist/cjs/form-field/create-form-field.js +0 -1
- package/dist/cjs/form-field/create-status.js +0 -1
- package/dist/cjs/form-field/is-form-field-status.js +0 -1
- package/dist/cjs/form-field/is-form-field.js +0 -1
- package/dist/cjs/helper/from-validator.js +0 -1
- package/dist/cjs/helper/has-form-changed.js +0 -1
- package/dist/cjs/helper/reset-form-submitted.js +0 -1
- package/dist/cjs/is-form-with-features.js +0 -1
- package/dist/cjs/types/form-field.js +0 -1
- package/dist/esm/form-field/create-form-field-validation-context.js +0 -1
- package/dist/esm/form-field/create-form-field.js +0 -1
- package/dist/esm/form-field/create-status.js +0 -1
- package/dist/esm/form-field/is-form-field-status.js +0 -1
- package/dist/esm/form-field/is-form-field.js +0 -1
- package/dist/esm/helper/from-validator.js +0 -1
- package/dist/esm/helper/has-form-changed.js +0 -1
- package/dist/esm/helper/reset-form-submitted.js +0 -1
- package/dist/esm/is-form-with-features.js +0 -1
- package/dist/esm/types/form-field.js +0 -1
- package/dist/types/form-field/create-form-field-validation-context.d.ts +0 -2
- package/dist/types/form-field/create-form-field.d.ts +0 -7
- package/dist/types/form-field/create-status.d.ts +0 -2
- package/dist/types/form-field/index.d.ts +0 -4
- package/dist/types/form-field/is-form-field-status.d.ts +0 -2
- package/dist/types/form-field/is-form-field.d.ts +0 -2
- package/dist/types/helper/from-validator.d.ts +0 -3
- package/dist/types/helper/has-form-changed.d.ts +0 -2
- package/dist/types/helper/index.d.ts +0 -3
- package/dist/types/helper/reset-form-submitted.d.ts +0 -2
- package/dist/types/is-form-with-features.d.ts +0 -3
- package/dist/types/types/features.d.ts +0 -0
- package/dist/types/types/form-field.d.ts +0 -71
- package/dist/types/types/form.d.ts +0 -49
- package/dist/types/types/index.d.ts +0 -2
package/MIGRATION.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Migration Guide
|
|
2
|
+
|
|
3
|
+
## 0.0.x to 0.1.0
|
|
4
|
+
|
|
5
|
+
`feature-form` now uses Standard Schema validators directly and the shared `.with(feature())` composition model from `feature-core`. The form model is still framework-agnostic, but validation setup, status values, submit callbacks, and helper exports changed.
|
|
6
|
+
|
|
7
|
+
### Use Standard Schema Directly
|
|
8
|
+
|
|
9
|
+
Validation adapters such as `zValidator`, `vValidator`, and `createValidator` are no longer used by `feature-form`.
|
|
10
|
+
|
|
11
|
+
Pass a Standard Schema-compatible schema directly:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createForm } from 'feature-form';
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
|
|
17
|
+
const form = createForm({
|
|
18
|
+
fields: {
|
|
19
|
+
email: {
|
|
20
|
+
defaultValue: '',
|
|
21
|
+
validator: z.string().email()
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Zod, Valibot, ArkType, and other Standard Schema-compatible validators can be used without wrapping.
|
|
28
|
+
|
|
29
|
+
### Replace Validation Mode Flags
|
|
30
|
+
|
|
31
|
+
The old bitwise `validateMode` and `reValidateMode` flags were removed.
|
|
32
|
+
|
|
33
|
+
Use `validateOn` and `revalidateOn` string arrays:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// old
|
|
37
|
+
createForm({
|
|
38
|
+
fields: {
|
|
39
|
+
email: {
|
|
40
|
+
defaultValue: '',
|
|
41
|
+
validator,
|
|
42
|
+
validateMode: VALIDATION_MODE.BLUR,
|
|
43
|
+
reValidateMode: VALIDATION_MODE.CHANGE
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// new
|
|
49
|
+
createForm({
|
|
50
|
+
fields: {
|
|
51
|
+
email: {
|
|
52
|
+
defaultValue: '',
|
|
53
|
+
validator,
|
|
54
|
+
validateOn: ['blur'],
|
|
55
|
+
revalidateOn: ['change']
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Form-created fields now default to `submit` validation and `change` revalidation.
|
|
62
|
+
|
|
63
|
+
### Update Status Checks
|
|
64
|
+
|
|
65
|
+
Status values are now lowercase.
|
|
66
|
+
|
|
67
|
+
| Old status | New status |
|
|
68
|
+
| ------------- | ------------- |
|
|
69
|
+
| `UNVALIDATED` | `unvalidated` |
|
|
70
|
+
| `VALID` | `valid` |
|
|
71
|
+
| `INVALID` | `invalid` |
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const status = form.fields.email.status.get();
|
|
75
|
+
|
|
76
|
+
if (status.type === 'invalid') {
|
|
77
|
+
console.error(status.errors[0]?.message);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Field errors now use `{ message, path?: PropertyKey[] }`. The old `{ code, message?, path?: string }` shape was removed.
|
|
82
|
+
|
|
83
|
+
### Use Form-Level Status Instead Of `isValid`
|
|
84
|
+
|
|
85
|
+
Form and field `isValid()` helpers were removed.
|
|
86
|
+
|
|
87
|
+
Use reactive status state instead:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
const formStatus = form.status.get();
|
|
91
|
+
|
|
92
|
+
if (formStatus.type === 'valid') {
|
|
93
|
+
// Submit or continue.
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Fields with validators start as `unvalidated`. Forms without validators start as `valid`.
|
|
98
|
+
|
|
99
|
+
### Update Submit Callbacks
|
|
100
|
+
|
|
101
|
+
Submit callbacks now return `void | Promise<void>`. The old `TSubmitCallbackResponse`, `TSubmitData`, `postSubmitCallback`, and return-object aggregation model was removed.
|
|
102
|
+
|
|
103
|
+
`assignToInitial` was replaced by `submit({ updateDefaultValues: true })`:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
await form.submit({
|
|
107
|
+
updateDefaultValues: true,
|
|
108
|
+
onValidSubmit: async (values) => {
|
|
109
|
+
await saveProfile(values);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Use form-level `isSubmitting` instead of field-level `isSubmitting`.
|
|
115
|
+
|
|
116
|
+
### Update Error Collection
|
|
117
|
+
|
|
118
|
+
`getErrors()` now returns grouped errors:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const errors = form.getErrors();
|
|
122
|
+
|
|
123
|
+
console.log(errors.fields.email);
|
|
124
|
+
console.log(errors.form);
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The old flat field-keyed object was removed. Unvalidated fields are omitted instead of returned as synthetic errors.
|
|
128
|
+
|
|
129
|
+
### Replace Change Helpers With `dirtyFeature`
|
|
130
|
+
|
|
131
|
+
`hasFormChanged()` was removed.
|
|
132
|
+
|
|
133
|
+
Use `dirtyFeature()` when you need reactive dirty state:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
import { createForm, dirtyFeature } from 'feature-form';
|
|
137
|
+
|
|
138
|
+
const form = createForm({
|
|
139
|
+
fields: {
|
|
140
|
+
name: { defaultValue: '' }
|
|
141
|
+
}
|
|
142
|
+
}).with(dirtyFeature());
|
|
143
|
+
|
|
144
|
+
form.isDirty.get();
|
|
145
|
+
form.dirtyFields.get();
|
|
146
|
+
form.resetDirty();
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Removed Exports
|
|
150
|
+
|
|
151
|
+
The root export no longer includes `BitwiseFlag`, `bitwiseFlag`, `helper/*`, `form-field/*` barrels, or `isFormWithFeatures`.
|
|
152
|
+
|
|
153
|
+
Use the root exports for `createForm`, `createFormField`, `dirtyFeature`, and public types. Deep imports from old `dist/.../form-field`, `dist/.../helper`, or split `types/*` paths are not supported.
|
package/README.md
CHANGED
|
@@ -17,98 +17,420 @@
|
|
|
17
17
|
</a>
|
|
18
18
|
</p>
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
`feature-form` is framework-agnostic form state built from reactive fields. It uses Standard Schema validators directly, lets each field choose when validation runs, and keeps submit handling on the form object.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
- Validate with Zod, Valibot, ArkType, or any [Standard Schema](https://github.com/standard-schema/standard-schema) validator without resolver packages
|
|
23
|
+
- Tune validation per field: stay quiet while typing, validate on blur or submit, then revalidate on change
|
|
24
|
+
- Subscribe only to the state a view renders: value, status, touched, submitted, dirty
|
|
25
|
+
- Add typed behavior with `.with()` features, including built-in dirty tracking
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- **Typesafe**: Build with TypeScript for strong type safety
|
|
28
|
-
- **Standalone**: Zero external dependencies, ensuring ease of use in various environments
|
|
27
|
+
```ts
|
|
28
|
+
import { createForm, dirtyFeature } from 'feature-form';
|
|
29
|
+
import * as z from 'zod';
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
const $form = createForm({
|
|
32
|
+
fields: {
|
|
33
|
+
email: {
|
|
34
|
+
defaultValue: '',
|
|
35
|
+
validator: z.string().email(),
|
|
36
|
+
validateOn: ['blur', 'submit'], // no errors while typing
|
|
37
|
+
revalidateOn: ['change', 'submit'] // revalidate after first submit
|
|
38
|
+
},
|
|
39
|
+
password: {
|
|
40
|
+
defaultValue: '',
|
|
41
|
+
validator: z.string().min(8),
|
|
42
|
+
validateOn: ['touched', 'submit'], // first blur, subsequent changes, and submit
|
|
43
|
+
revalidateOn: ['change', 'submit']
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
onValidSubmit: (data) => console.log(data)
|
|
47
|
+
}).with(dirtyFeature());
|
|
31
48
|
|
|
32
|
-
|
|
49
|
+
const unbind = $form.fields.email.status.listen(({ value }) => {
|
|
50
|
+
if (value.type === 'invalid') {
|
|
51
|
+
console.log(value.errors[0].message);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
33
54
|
|
|
34
|
-
|
|
55
|
+
$form.fields.email.set('not-an-email');
|
|
56
|
+
$form.fields.email.blur(); // validates email and notifies the status listener
|
|
57
|
+
|
|
58
|
+
await $form.submit(); // runs submit-triggered validators and calls onValidSubmit when valid
|
|
59
|
+
$form.isDirty.get(); // true if any field differs from its default value
|
|
60
|
+
unbind();
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Migrating from `0.0.x`? See [MIGRATION.md](./MIGRATION.md).
|
|
64
|
+
|
|
65
|
+
## Install
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install feature-form
|
|
69
|
+
```
|
|
35
70
|
|
|
36
|
-
|
|
71
|
+
Examples use Zod, but any Standard Schema validator works:
|
|
37
72
|
|
|
38
|
-
|
|
73
|
+
```bash
|
|
74
|
+
npm install zod
|
|
75
|
+
```
|
|
39
76
|
|
|
40
|
-
|
|
77
|
+
## Usage
|
|
41
78
|
|
|
42
|
-
|
|
79
|
+
Fields are reactive states. Subscribe to status changes to wire validation feedback directly into your UI:
|
|
43
80
|
|
|
44
|
-
```
|
|
81
|
+
```ts
|
|
45
82
|
import { createForm } from 'feature-form';
|
|
46
|
-
import { useForm } from 'feature-react/form';
|
|
47
|
-
import * as v from 'valibot';
|
|
48
|
-
import { vValidator } from 'validation-adapters/valibot';
|
|
49
|
-
import { zValidator } from 'validation-adapters/zod';
|
|
50
83
|
import * as z from 'zod';
|
|
51
84
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
85
|
+
const $form = createForm({
|
|
86
|
+
fields: {
|
|
87
|
+
email: { defaultValue: '', validator: z.string().email() }
|
|
88
|
+
},
|
|
89
|
+
onValidSubmit: (data) => console.log(data)
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
$form.fields.email.status.listen(({ value }) => {
|
|
93
|
+
if (value.type === 'invalid') {
|
|
94
|
+
console.log(value.errors[0].message);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
$form.fields.email.set('not-an-email'); // set() alone does not validate (validateOn defaults to ['submit'])
|
|
99
|
+
await $form.submit(); // triggers validation, listener fires with error
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Control when validation fires with per-field triggers. Keep errors quiet before the user has finished, then switch to immediate feedback once they have tried to submit:
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
const $form = createForm({
|
|
106
|
+
fields: {
|
|
107
|
+
email: {
|
|
108
|
+
defaultValue: '',
|
|
109
|
+
validator: z.string().email(),
|
|
110
|
+
validateOn: ['blur', 'submit'], // quiet while typing, fires on blur
|
|
111
|
+
revalidateOn: ['change', 'submit'] // immediate feedback after first submit
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Form
|
|
118
|
+
|
|
119
|
+
### `createForm(config)`
|
|
120
|
+
|
|
121
|
+
Creates a form and returns it as a feature host. Each key in `fields` becomes a reactive `TFormField` with validation, blur tracking, and status.
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const $form = createForm({
|
|
125
|
+
fields: {
|
|
126
|
+
age: { defaultValue: 0 },
|
|
127
|
+
username: {
|
|
128
|
+
defaultValue: '',
|
|
129
|
+
validator: z.string().min(3),
|
|
130
|
+
validateOn: ['submit', 'blur'],
|
|
131
|
+
revalidateOn: ['submit', 'change', 'blur']
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Option | Default | Description |
|
|
138
|
+
| ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
139
|
+
| `fields` | required | Field configs or pre-built fields keyed by form data property. |
|
|
140
|
+
| `validator` | none | Form-level validator for cross-field constraints. |
|
|
141
|
+
| `validateOn` | `['submit']` | Default triggers for the form validator and fields that do not override them. |
|
|
142
|
+
| `revalidateOn` | `['submit', 'change']` | Default revalidation triggers for the form validator and fields that do not override them. |
|
|
143
|
+
| `collectErrorMode` | `'firstError'` | Default Standard Schema error collection mode for the form validator and fields that do not override it. |
|
|
144
|
+
| `onValidSubmit` | none | Called on every valid submit. Per-call overrides can be passed to `submit()`. |
|
|
145
|
+
| `onInvalidSubmit` | none | Called on every invalid submit. Per-call overrides can be passed to `submit()`. |
|
|
146
|
+
|
|
147
|
+
**Field config options**
|
|
148
|
+
|
|
149
|
+
| Option | Default | Description |
|
|
150
|
+
| ------------------ | ---------------------- | -------------------------------------------------------------------------------- |
|
|
151
|
+
| `defaultValue` | required | Initial value and reset target. |
|
|
152
|
+
| `validator` | none | Field-level validator. |
|
|
153
|
+
| `validateOn` | `['submit']` | Triggers that run the validator before the first submit. |
|
|
154
|
+
| `revalidateOn` | `['submit', 'change']` | Triggers that run the validator after the first submit. |
|
|
155
|
+
| `collectErrorMode` | `'firstError'` | `'firstError'` keeps the first Standard Schema issue; `'all'` keeps every issue. |
|
|
156
|
+
|
|
157
|
+
### `submit()` / `validate()` / `reset()`
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const isValid = await $form.submit();
|
|
56
161
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
},
|
|
63
|
-
email: {
|
|
64
|
-
validator: vValidator(v.pipe(v.string(), v.email())),
|
|
65
|
-
defaultValue: ''
|
|
66
|
-
}
|
|
67
|
-
},
|
|
68
|
-
onValidSubmit: (data) => console.log('ValidSubmit', data),
|
|
69
|
-
onInvalidSubmit: (errors) => console.log('InvalidSubmit', errors)
|
|
162
|
+
await $form.submit({
|
|
163
|
+
onValidSubmit: (data) => console.log(data),
|
|
164
|
+
onInvalidSubmit: (errors) => console.error(errors),
|
|
165
|
+
updateDefaultValues: true, // treat submitted values as new reset baseline
|
|
166
|
+
context: { source: 'settings-form' } // passed through to submit callbacks
|
|
70
167
|
});
|
|
71
168
|
|
|
72
|
-
|
|
73
|
-
|
|
169
|
+
const unbind = $form.onValidSubmit((data) => console.log(data));
|
|
170
|
+
unbind();
|
|
171
|
+
|
|
172
|
+
const isValid = await $form.validate(); // runs all validators without submitting
|
|
173
|
+
|
|
174
|
+
$form.reset(); // resets values, validation status, isTouched, and isSubmitted
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`submit()` runs validators configured for the submit trigger. All matching field validators and the form validator run together. No failing validator prevents the others from completing, so submit gives you a complete error picture for validators that ran. Returns `true` if the form was valid, `false` otherwise. Persistent callbacks registered via `onValidSubmit()` / `onInvalidSubmit()` and per-call options passed to `submit()` both run in parallel.
|
|
178
|
+
|
|
179
|
+
`validate()` runs all validators the same way but has no submit side effects: it updates validation state, but does not set `isSubmitted`, does not fire `onValidSubmit` or `onInvalidSubmit`, and does not update default values.
|
|
180
|
+
|
|
181
|
+
`reset()` restores all fields to their `defaultValue` and clears `status`, `isTouched`, and `isSubmitted` on both the form and every field. Any in-flight async validation is invalidated so stale results cannot update field status.
|
|
182
|
+
|
|
183
|
+
### `getData()` / `getValidData()` / `getErrors()`
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
const data = $form.getData(); // current field values, regardless of validity
|
|
187
|
+
const data = $form.getValidData(); // current field values, or null if form status is not 'valid'
|
|
188
|
+
|
|
189
|
+
const errors = $form.getErrors();
|
|
190
|
+
errors.fields; // invalid fields and form-level errors whose path points at a field
|
|
191
|
+
errors.form; // pathless or unknown-path form-level errors
|
|
192
|
+
```
|
|
74
193
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
{status('name').error && <span>{status('name').error}</span>}
|
|
81
|
-
</div>
|
|
82
|
-
<div>
|
|
83
|
-
<label>Email</label>
|
|
84
|
-
<input {...register('email')} />
|
|
85
|
-
{status('email').error && <span>{status('email').error}</span>}
|
|
86
|
-
</div>
|
|
87
|
-
<button type="submit">Submit</button>
|
|
88
|
-
</form>
|
|
89
|
-
);
|
|
90
|
-
};
|
|
194
|
+
### `fields` / `getField(key)`
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
$form.fields.name; // TFormField<string>
|
|
198
|
+
$form.getField('name'); // same, useful when the key is dynamic
|
|
91
199
|
```
|
|
92
200
|
|
|
93
|
-
###
|
|
201
|
+
### Reactive states
|
|
202
|
+
|
|
203
|
+
| State | Type | Description |
|
|
204
|
+
| -------------- | ------------------- | ------------------------------------------------------------ |
|
|
205
|
+
| `status` | `TValidationStatus` | Aggregate form status: `unvalidated`, `valid`, or `invalid`. |
|
|
206
|
+
| `isValidating` | `TState<boolean>` | True while the latest validation run is pending. |
|
|
207
|
+
| `isSubmitted` | `TState<boolean>` | True after the first submit attempt. |
|
|
208
|
+
| `isSubmitting` | `TState<boolean>` | True while `submit()` is in progress. |
|
|
209
|
+
|
|
210
|
+
### Validation triggers
|
|
211
|
+
|
|
212
|
+
`validateOn` controls which events run the validator before the first submit. `revalidateOn` controls the same after the first submit.
|
|
213
|
+
|
|
214
|
+
| Trigger | When it fires |
|
|
215
|
+
| ----------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
216
|
+
| `'submit'` | On `submit()`. |
|
|
217
|
+
| `'blur'` | On every `blur()`. |
|
|
218
|
+
| `'change'` | On every value change via `set()`. |
|
|
219
|
+
| `'touched'` | On the first `blur()`, and on every subsequent `set()` once the field has been touched. Only valid in `validateOn`. |
|
|
94
220
|
|
|
95
|
-
`
|
|
221
|
+
The `'touched'` trigger covers the "validate once the user has interacted" pattern: no validation fires until the first blur, then validation follows every change from that point on. In `revalidateOn` use `'blur'` instead.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
// validate on blur before submit, revalidate on every change after
|
|
225
|
+
const $form = createForm({
|
|
226
|
+
fields: {
|
|
227
|
+
email: {
|
|
228
|
+
defaultValue: '',
|
|
229
|
+
validator: z.string().email(),
|
|
230
|
+
validateOn: ['blur', 'submit'],
|
|
231
|
+
revalidateOn: ['change', 'submit']
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Form-level validator
|
|
238
|
+
|
|
239
|
+
Use `validator` for cross-field constraints. `validateOn`, `revalidateOn`, and `collectErrorMode` are shared defaults for the form validator and field validators unless a field overrides them.
|
|
240
|
+
|
|
241
|
+
The library routes form-level validator errors by path. An issue that points at a field appears in `getErrors().fields` and in the matching field's `status`, with the field key stripped from the path. Pathless issues and paths with no matching field appear in `getErrors().form`.
|
|
242
|
+
|
|
243
|
+
```ts
|
|
244
|
+
const $form = createForm({
|
|
245
|
+
fields: {
|
|
246
|
+
password: { defaultValue: '' },
|
|
247
|
+
confirm: { defaultValue: '' }
|
|
248
|
+
},
|
|
249
|
+
validator: z
|
|
250
|
+
.object({ password: z.string(), confirm: z.string() })
|
|
251
|
+
.refine((d) => d.password === d.confirm, {
|
|
252
|
+
message: 'Passwords do not match',
|
|
253
|
+
path: ['confirm']
|
|
254
|
+
}),
|
|
255
|
+
validateOn: ['submit'],
|
|
256
|
+
revalidateOn: ['change', 'submit']
|
|
257
|
+
});
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Validation status
|
|
261
|
+
|
|
262
|
+
Both `form.status` and `field.status` are discriminated unions:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
const status = $form.fields.email.status.get();
|
|
266
|
+
|
|
267
|
+
if (status.type === 'invalid') {
|
|
268
|
+
status.errors; // readonly TValidationError[]
|
|
269
|
+
status.errors[0].message; // string
|
|
270
|
+
status.errors[0].path; // validator path, e.g. ['address', 'city']
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
| `type` | Meaning |
|
|
275
|
+
| --------------- | ----------------------------------------------- |
|
|
276
|
+
| `'unvalidated'` | No validator has run yet. |
|
|
277
|
+
| `'valid'` | Last run passed. |
|
|
278
|
+
| `'invalid'` | Last run failed; `errors` contains the details. |
|
|
279
|
+
|
|
280
|
+
### Validators
|
|
281
|
+
|
|
282
|
+
Any [Standard Schema](https://github.com/standard-schema/standard-schema) compatible validator works directly without an adapter.
|
|
96
283
|
|
|
97
284
|
```ts
|
|
98
285
|
import * as v from 'valibot';
|
|
99
|
-
import { vValidator } from 'validation-adapters/valibot';
|
|
100
|
-
import { zValidator } from 'validation-adapters/zod';
|
|
101
286
|
import * as z from 'zod';
|
|
102
287
|
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
)
|
|
288
|
+
const zodValidator = z.string().min(2).max(50);
|
|
289
|
+
const valibotValidator = v.pipe(v.string(), v.minLength(2), v.maxLength(50));
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
For custom validators, implement the `StandardSchemaV1` interface from [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema).
|
|
293
|
+
|
|
294
|
+
Validators run for validation only. If a schema transforms or coerces output values, the parsed output does not write back into the field state. `getValidData()` and submit callbacks keep returning the current field values.
|
|
295
|
+
|
|
296
|
+
## Field
|
|
297
|
+
|
|
298
|
+
Each entry in `form.fields` is a `TFormField<GValue>`: a full `feature-state` state with form-specific methods added.
|
|
299
|
+
|
|
300
|
+
### `createFormField(defaultValue, config)`
|
|
301
|
+
|
|
302
|
+
Creates a field independently of any form. Use this for a shared search input or a field conditionally composed into different forms. `config.key` is required because validation errors use it for path routing. Pass the resulting `TFormField` directly into the `createForm` fields config.
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
import { createFormField } from 'feature-form';
|
|
306
|
+
|
|
307
|
+
const $name = createFormField('', {
|
|
308
|
+
key: 'name',
|
|
309
|
+
validator: z.string().min(2),
|
|
310
|
+
validateOn: ['blur'],
|
|
311
|
+
revalidateOn: ['change']
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
$name.set('Alice');
|
|
315
|
+
await $name.validate();
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
### `set()` / `get()` / `value`
|
|
110
319
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
);
|
|
320
|
+
```ts
|
|
321
|
+
$form.fields.name.set('Alice');
|
|
322
|
+
$form.fields.name.get(); // 'Alice'
|
|
323
|
+
$form.fields.name.value; // 'Alice'
|
|
324
|
+
$form.fields.name.value = 'Bob'; // same as set('Bob')
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### `blur()` / `validate()` / `reset()`
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
$form.fields.name.blur(); // marks touched, runs blur/touched validators
|
|
331
|
+
await $form.fields.name.validate(); // runs the field validator, returns true if valid
|
|
332
|
+
$form.fields.name.reset(); // resets value, touched, submitted, and status
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### `onBlur(callback)`
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
const unbind = $form.fields.name.onBlur(({ wasTouched }) => {
|
|
339
|
+
if (!wasTouched) {
|
|
340
|
+
// first time this field was blurred
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
unbind();
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Reactive states
|
|
348
|
+
|
|
349
|
+
| State | Type | Description |
|
|
350
|
+
| -------------- | ------------------- | ------------------------------------------------------------------------------------ |
|
|
351
|
+
| `status` | `TValidationStatus` | Field display status, including field validator errors and routed form-level errors. |
|
|
352
|
+
| `isTouched` | `TState<boolean>` | True after the field has been blurred at least once. |
|
|
353
|
+
| `isSubmitted` | `TState<boolean>` | True after the form has been submitted. |
|
|
354
|
+
| `isValidating` | `TState<boolean>` | True while the field validator is running. |
|
|
355
|
+
|
|
356
|
+
### `defaultValue` / `key`
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
$form.fields.name.defaultValue; // the value used when reset() is called
|
|
360
|
+
$form.fields.name.key; // 'name', used in validation error paths
|
|
114
361
|
```
|
|
362
|
+
|
|
363
|
+
## Built-in Features
|
|
364
|
+
|
|
365
|
+
Features are installed via `.with()` and extend the form with new methods.
|
|
366
|
+
|
|
367
|
+
### `dirtyFeature()`
|
|
368
|
+
|
|
369
|
+
Adds `isDirty`, `dirtyFields`, and `resetDirty()`. Tracks whether each field differs from its default value. By default it compares primitives, arrays, and plain objects structurally. Pass `dirtyFeature({ isEqual })` for other value types.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { dirtyFeature } from 'feature-form';
|
|
373
|
+
|
|
374
|
+
const $form = createForm({
|
|
375
|
+
fields: {
|
|
376
|
+
name: { defaultValue: 'Alice' },
|
|
377
|
+
email: { defaultValue: 'alice@example.com' }
|
|
378
|
+
}
|
|
379
|
+
}).with(dirtyFeature());
|
|
380
|
+
|
|
381
|
+
$form.fields.name.set('Bob');
|
|
382
|
+
|
|
383
|
+
$form.isDirty.get(); // true
|
|
384
|
+
$form.dirtyFields.get(); // { name: true, email: false }
|
|
385
|
+
|
|
386
|
+
$form.resetDirty(); // updates each field's defaultValue to its current value
|
|
387
|
+
$form.isDirty.get(); // false
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
`isDirty` and `dirtyFields` are reactive states. `resetDirty()` makes the current values the new baseline without clearing them. When `submit({ updateDefaultValues: true })` succeeds, dirty state clears automatically.
|
|
391
|
+
|
|
392
|
+
## Extending with Features
|
|
393
|
+
|
|
394
|
+
Forms are `feature-core` feature hosts. Add behavior with `.with(yourFeature())`. See the [feature-core README](https://github.com/builder-group/community/tree/develop/packages/feature-core) for a full guide on `defineFeature()`, dependency declaration, and the feature model.
|
|
395
|
+
|
|
396
|
+
## Examples
|
|
397
|
+
|
|
398
|
+
- [React Basic](https://github.com/builder-group/community/tree/develop/examples/feature-form/react/basic) ([CodeSandbox](https://codesandbox.io/p/sandbox/basic-c4gd3t))
|
|
399
|
+
|
|
400
|
+
## FAQ
|
|
401
|
+
|
|
402
|
+
### How does it compare to react-hook-form, Formik, and TanStack Form?
|
|
403
|
+
|
|
404
|
+
`feature-form` puts the form object outside the UI framework. That makes it closer to a reactive model than a React hook. Use it when you want one form core that can be reused across React, Vue, Svelte, tests, and plain JavaScript.
|
|
405
|
+
|
|
406
|
+
- [react-hook-form](https://github.com/react-hook-form/react-hook-form): strong React-first uncontrolled form library with validation resolvers
|
|
407
|
+
- [Formik](https://formik.org): mature controlled React form library
|
|
408
|
+
- [TanStack Form](https://tanstack.com/form): framework-agnostic form library with official framework adapters
|
|
409
|
+
|
|
410
|
+
### Does it work outside React?
|
|
411
|
+
|
|
412
|
+
Yes. The form and field objects are plain JavaScript. Fields are reactive states from `feature-state`, which has no framework dependency. Subscribe with `.listen()` in Vue, Svelte, vanilla JS, or any runtime. The [feature-react](https://github.com/builder-group/community/tree/develop/packages/feature-react) package provides React hooks if you want them.
|
|
413
|
+
|
|
414
|
+
### Why separate `validateOn` and `revalidateOn`?
|
|
415
|
+
|
|
416
|
+
Before the first submit, aggressive validation (e.g. `'change'`) can feel intrusive because the user has not finished yet. After submit they expect immediate feedback as they correct errors. Keeping the phases separate lets you configure each independently without a single `mode` flag that tries to cover both.
|
|
417
|
+
|
|
418
|
+
### Does it support async validators?
|
|
419
|
+
|
|
420
|
+
Yes. Any Standard Schema validator can be async. `submit()` and `validate()` are both async and await all validators. The `isValidating` state on both the form and each field reflects whether a run is in progress. In-flight async runs are invalidated by run ID when `reset()` is called, so stale results never overwrite reset state.
|
|
421
|
+
|
|
422
|
+
### Do all field validators run on submit, or does it stop at the first error?
|
|
423
|
+
|
|
424
|
+
All validators configured for the submit trigger run together. No failing submit-triggered validator skips the others, so submit gives a complete picture of every submit-triggered validation error.
|
|
425
|
+
|
|
426
|
+
### What does `getErrors()` return before any validation has run?
|
|
427
|
+
|
|
428
|
+
Only fields with `'invalid'` status or form-level path errors appear in `errors.fields`. Unvalidated fields are omitted. `errors.form` contains only pathless form-level errors.
|
|
429
|
+
|
|
430
|
+
### Can I register multiple `onValidSubmit` callbacks?
|
|
431
|
+
|
|
432
|
+
Yes. Callbacks registered via `form.onValidSubmit(callback)` are additive. On submit, all persistent callbacks and any per-call callback passed to `submit({ onValidSubmit })` run in parallel. There is no guaranteed order between them.
|
|
433
|
+
|
|
434
|
+
### When should I use `createFormField` instead of defining fields inside `createForm`?
|
|
435
|
+
|
|
436
|
+
Use `createFormField` when a field needs to exist independently of any specific form: a shared search input, or a field conditionally composed into different forms. Pass the resulting `TFormField` directly into the `createForm` fields config.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"use strict";var v=require("feature-core"),n=require("feature-state"),f=require("./lib/deep-copy.js"),_=require("./lib/standard-schema.js"),y=require("./lib/validation-status.js"),g=(t,e,s)=>new Promise((o,r)=>{var i=d=>{try{l(s.next(d))}catch(c){r(c)}},a=d=>{try{l(s.throw(d))}catch(c){r(c)}},l=d=>d.done?o(d.value):Promise.resolve(d.value).then(i,a);l((s=s.apply(t,e)).next())});function m(t,e){const{key:s,validator:o,validateOn:r=["submit"],revalidateOn:i=["submit","change"],collectErrorMode:a="firstError"}=e,l=n.createState(t).with(p({key:s,validation:o==null?void 0:{validator:o,config:{validateOn:r,revalidateOn:i,collectErrorMode:a}}}));return V(l),l}function S(t){return v.hasFeature(t,"form-field")}function p(t){const{key:e,validation:s}=t;return v.defineFeature({key:"form-field",install(o){const r=s==null?{type:"valid"}:{type:"unvalidated"};return{_validation:s,_validationRunId:0,_fieldValidatorStatus:r,_formValidatorErrors:[],_callbacks:{blur:[]},key:e,defaultValue:f.deepCopy(o._value),isTouched:n.createState(!1),isSubmitted:n.createState(!1),isValidating:n.createState(!1),status:n.createState(r).with(n.isEqualFeature(y.areValidationStatusesEqual)),_applyFormValidatorErrors(i){this._formValidatorErrors=i,this.status.set(u(this))},validate(){return g(this,null,function*(){if(this._validation==null)return this._fieldValidatorStatus={type:"valid"},this.status.set(u(this)),this.status.get().type==="valid";const i=++this._validationRunId;let a={type:"valid"};this.isValidating.set(!0);try{a=yield _.validateStandardSchema(this._validation.validator,this.get(),this._validation.config.collectErrorMode)}catch(l){a={type:"invalid",errors:[{message:l instanceof Error?l.message:String(l)}]}}finally{i===this._validationRunId&&this.isValidating.set(!1)}return i!==this._validationRunId?this.status.get().type==="valid":(this._fieldValidatorStatus=a,this.status.set(u(this)),this.status.get().type==="valid")})},onBlur(i){return this._callbacks.blur.push(i),()=>{const a=this._callbacks.blur.indexOf(i);a!==-1&&this._callbacks.blur.splice(a,1)}},blur(){const i=this.isTouched.get();this._validation!=null&&(this.isSubmitted.get()?this._validation.config.revalidateOn.includes("blur"):this._validation.config.validateOn.includes("blur")||this._validation.config.validateOn.includes("touched")&&!i)&&this.validate(),this.isTouched.set(!0);for(const a of this._callbacks.blur)a({wasTouched:i})},reset(){this.set(f.deepCopy(this.defaultValue),{listenerContext:{source:h}}),this._validationRunId++,this.isTouched.set(!1),this.isSubmitted.set(!1),this.isValidating.set(!1),this._formValidatorErrors=[],this._fieldValidatorStatus=this._validation==null?{type:"valid"}:{type:"unvalidated"},this.status.set(u(this))}}}})}const h="formFieldReset";function V(t){t.listen(({source:e})=>{e!==h&&t._validation!=null&&(t.isSubmitted.get()?t._validation.config.revalidateOn.includes("change"):t._validation.config.validateOn.includes("change")||t._validation.config.validateOn.includes("touched")&&t.isTouched.get())&&t.validate()})}function u(t){const e=t._fieldValidatorStatus.type==="invalid"?[...t._fieldValidatorStatus.errors,...t._formValidatorErrors]:t._formValidatorErrors;return e.length>0?{type:"invalid",errors:e}:t._fieldValidatorStatus}exports.createFormField=m,exports.formFieldResetSourceKey=h,exports.isFormField=S;
|