feature-form 0.0.59 → 0.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.
Files changed (62) hide show
  1. package/MIGRATION.md +153 -0
  2. package/README.md +390 -68
  3. package/dist/cjs/create-form-field.js +1 -0
  4. package/dist/cjs/create-form.js +1 -1
  5. package/dist/cjs/features/dirty.js +1 -0
  6. package/dist/cjs/index.js +1 -1
  7. package/dist/cjs/lib/deep-copy.js +1 -0
  8. package/dist/cjs/lib/standard-schema.js +1 -0
  9. package/dist/cjs/lib/validation-status.js +1 -0
  10. package/dist/esm/create-form-field.js +1 -0
  11. package/dist/esm/create-form.js +1 -1
  12. package/dist/esm/features/dirty.js +1 -0
  13. package/dist/esm/index.js +1 -1
  14. package/dist/esm/lib/deep-copy.js +1 -0
  15. package/dist/esm/lib/standard-schema.js +1 -0
  16. package/dist/esm/lib/validation-status.js +1 -0
  17. package/dist/types/create-form-field.d.ts +18 -0
  18. package/dist/types/create-form.d.ts +48 -29
  19. package/dist/types/features/dirty.d.ts +33 -0
  20. package/dist/types/features/index.d.ts +1 -0
  21. package/dist/types/index.d.ts +2 -4
  22. package/dist/types/lib/deep-copy.d.ts +2 -0
  23. package/dist/types/lib/index.d.ts +3 -0
  24. package/dist/types/lib/standard-schema.d.ts +4 -0
  25. package/dist/types/lib/validation-status.d.ts +3 -0
  26. package/dist/types/types.d.ts +198 -0
  27. package/package.json +26 -11
  28. package/dist/cjs/form-field/create-form-field-validation-context.js +0 -1
  29. package/dist/cjs/form-field/create-form-field.js +0 -1
  30. package/dist/cjs/form-field/create-status.js +0 -1
  31. package/dist/cjs/form-field/is-form-field-status.js +0 -1
  32. package/dist/cjs/form-field/is-form-field.js +0 -1
  33. package/dist/cjs/helper/from-validator.js +0 -1
  34. package/dist/cjs/helper/has-form-changed.js +0 -1
  35. package/dist/cjs/helper/reset-form-submitted.js +0 -1
  36. package/dist/cjs/is-form-with-features.js +0 -1
  37. package/dist/cjs/types/form-field.js +0 -1
  38. package/dist/esm/form-field/create-form-field-validation-context.js +0 -1
  39. package/dist/esm/form-field/create-form-field.js +0 -1
  40. package/dist/esm/form-field/create-status.js +0 -1
  41. package/dist/esm/form-field/is-form-field-status.js +0 -1
  42. package/dist/esm/form-field/is-form-field.js +0 -1
  43. package/dist/esm/helper/from-validator.js +0 -1
  44. package/dist/esm/helper/has-form-changed.js +0 -1
  45. package/dist/esm/helper/reset-form-submitted.js +0 -1
  46. package/dist/esm/is-form-with-features.js +0 -1
  47. package/dist/esm/types/form-field.js +0 -1
  48. package/dist/types/form-field/create-form-field-validation-context.d.ts +0 -2
  49. package/dist/types/form-field/create-form-field.d.ts +0 -7
  50. package/dist/types/form-field/create-status.d.ts +0 -2
  51. package/dist/types/form-field/index.d.ts +0 -4
  52. package/dist/types/form-field/is-form-field-status.d.ts +0 -2
  53. package/dist/types/form-field/is-form-field.d.ts +0 -2
  54. package/dist/types/helper/from-validator.d.ts +0 -3
  55. package/dist/types/helper/has-form-changed.d.ts +0 -2
  56. package/dist/types/helper/index.d.ts +0 -3
  57. package/dist/types/helper/reset-form-submitted.d.ts +0 -2
  58. package/dist/types/is-form-with-features.d.ts +0 -3
  59. package/dist/types/types/features.d.ts +0 -0
  60. package/dist/types/types/form-field.d.ts +0 -71
  61. package/dist/types/types/form.d.ts +0 -49
  62. 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
- > Status: Experimental
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
- `feature-form` is a straightforward, typesafe, and feature-based form library.
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
- - **Lightweight & Tree Shakable**: Function-based and modular design
25
- - **Fast**: Optimized for speed and efficiency, ensuring smooth user experience
26
- - **Modular & Extendable**: Easily extendable with features
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
- ### 📚 Examples
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
- - [ReactJs Basic](https://github.com/builder-group/community/tree/develop/examples/feature-form/react/basic) ([Code Sandbox](https://codesandbox.io/p/sandbox/basic-c4gd3t))
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
- ### 🌟 Motivation
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
- Create a typesafe, straightforward, and lightweight form library designed to be modular and extendable with features.
71
+ Examples use Zod, but any Standard Schema validator works:
37
72
 
38
- ### ⚖️ Alternatives
73
+ ```bash
74
+ npm install zod
75
+ ```
39
76
 
40
- - [react-hook-form](https://github.com/react-hook-form/react-hook-form)
77
+ ## Usage
41
78
 
42
- ## 📖 Usage
79
+ Fields are reactive states. Subscribe to status changes to wire validation feedback directly into your UI:
43
80
 
44
- ```tsx
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
- interface TFormData {
53
- name: string;
54
- email: string;
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
- const $form = createForm<TFormData>({
58
- fields: {
59
- name: {
60
- validator: zValidator(z.string().min(2).max(10)),
61
- defaultValue: ''
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
- export const MyFormComponent: React.FC = () => {
73
- const { handleSubmit, register, status } = useForm($form);
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
- return (
76
- <form onSubmit={handleSubmit()}>
77
- <div>
78
- <label>Name</label>
79
- <input {...register('name')} />
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
- ### Validators ([`validation-adapters`](https://github.com/builder-group/community/tree/develop/packages/validation-adapters))
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
- `feature-form` supports various validators such as [Zod](https://github.com/colinhacks/zod), [Yup](https://github.com/jquense/yup), [Valibot](https://github.com/fabian-hiller/valibot) and more.
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 zodNameValidator = zValidator(
104
- z
105
- .string()
106
- .min(2)
107
- .max(10)
108
- .regex(/^([^0-9]*)$/)
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
- const valibotNameValidator = vValidator(
112
- v.pipe(v.string(), v.minLength(2), v.maxLength(10), v.regex(/^([^0-9]*)$/))
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,l)=>new Promise((o,r)=>{var i=d=>{try{s(l.next(d))}catch(c){r(c)}},a=d=>{try{s(l.throw(d))}catch(c){r(c)}},s=d=>d.done?o(d.value):Promise.resolve(d.value).then(i,a);s((l=l.apply(t,e)).next())});function m(t,e){const{key:l,validator:o,validateOn:r=["submit"],revalidateOn:i=["submit","change"],collectErrorMode:a="firstError"}=e,s=n.createState(t).with(p({key:l,validation:o==null?void 0:{validator:o,config:{validateOn:r,revalidateOn:i,collectErrorMode:a}}}));return V(s),s}function S(t){return v.hasFeature(t,"form-field")}function p(t){const{key:e,validation:l}=t;return v.defineFeature({key:"form-field",install(o){const r=l==null?{type:"valid"}:{type:"unvalidated"};return{_validation:l,_validationRunId:0,_fieldValidatorStatus:r,_formValidatorErrors:[],_callbacks:{blur:[]},key:e,defaultValue:f.deepCopy(o._v),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(s){a={type:"invalid",errors:[{message:s instanceof Error?s.message:String(s)}]}}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;