@samline/forms 2.6.0 → 2.7.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/README.md +6 -4
- package/dist/browser/global.d.ts +19 -3
- package/dist/browser/global.global.js +176 -30
- package/dist/browser/global.global.js.map +1 -1
- package/dist/browser/index.cjs +176 -30
- package/dist/browser/index.cjs.map +1 -1
- package/dist/browser/index.d.cts +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/{chunk-L4GRKSQ5.js → chunk-MMHX34WB.js} +181 -31
- package/dist/chunk-MMHX34WB.js.map +1 -0
- package/dist/index.cjs +180 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{registry-BpqJetAt.d.cts → registry-GzopYPCR.d.cts} +20 -4
- package/dist/{registry-BpqJetAt.d.ts → registry-GzopYPCR.d.ts} +20 -4
- package/docs/api/add-cleanup.md +52 -0
- package/docs/api/destroy.md +16 -1
- package/docs/api/form.md +4 -2
- package/docs/api/format.md +3 -3
- package/docs/api/get-state.md +17 -1
- package/docs/api/index.md +1 -0
- package/docs/api/on-submit.md +7 -3
- package/docs/api/reset.md +3 -2
- package/docs/api/revalidate.md +3 -1
- package/docs/api/subscribe.md +16 -0
- package/docs/api/validate-field-value.md +39 -3
- package/docs/api/validate-values.md +4 -1
- package/docs/api/validate.md +5 -3
- package/docs/browser.md +5 -5
- package/docs/css-styling.md +5 -1
- package/docs/getting-started.md +13 -8
- package/docs/options.md +69 -2
- package/docs/recipes.md +65 -11
- package/docs/typescript.md +47 -8
- package/package.json +1 -1
- package/dist/chunk-L4GRKSQ5.js.map +0 -1
package/docs/css-styling.md
CHANGED
|
@@ -20,7 +20,9 @@ The attributes are added and removed on:
|
|
|
20
20
|
- Every explicit call to [`validate`](api/validate.md), [`revalidate`](api/revalidate.md), [`setErrors`](api/set-errors.md), or [`clearErrors`](api/clear-errors.md).
|
|
21
21
|
- DOM mutations detected by the controller’s `MutationObserver` (new fields, changed `name` / `type` attributes).
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
[`reset`](api/reset.md) clears both attributes, then re-synchronizes visual state from native default values. As a result, reset controls with non-empty defaults regain `css-filled`, while `css-error` and `aria-invalid` remain cleared.
|
|
24
|
+
|
|
25
|
+
Initial `css-filled` synchronization is independent of validation. With `autoValidate: false`, prefilled controls are styled on mount without running rules, setting `css-error`, or marking the form as validated.
|
|
24
26
|
|
|
25
27
|
---
|
|
26
28
|
|
|
@@ -177,6 +179,8 @@ emailErrorEl.hidden = !state.errors.email
|
|
|
177
179
|
emailErrorEl.textContent = state.errors.email?.[0] ?? ''
|
|
178
180
|
```
|
|
179
181
|
|
|
182
|
+
For fields validated with `each`, item-only failures add `css-error` and `aria-invalid="true"` only to the failing controls. Aggregate field rules and manual errors still mark every control with that name. The public error map remains field-based (`errors[field]: string[]`), so use the optional `element` / `index` in an `each` custom validator when an integration needs item identity.
|
|
183
|
+
|
|
180
184
|
---
|
|
181
185
|
|
|
182
186
|
## Common pitfalls
|
package/docs/getting-started.md
CHANGED
|
@@ -8,7 +8,7 @@ This page explains what `@samline/forms` is, how the controller is wired, and wh
|
|
|
8
8
|
|
|
9
9
|
Use the main `@samline/forms` entrypoint for native HTML forms. The package also provides `@samline/forms/browser` as an ESM/CJS browser module and `@samline/forms/browser/global` as the global IIFE distribution.
|
|
10
10
|
|
|
11
|
-
> Note: the latest version is `2.
|
|
11
|
+
> Note: the latest version is `2.7.1` — see [Releases](https://github.com/samline/forms/releases) for the changelog.
|
|
12
12
|
|
|
13
13
|
If you want a `<script>`-only setup without a bundler, see [docs/browser.md](browser.md).
|
|
14
14
|
|
|
@@ -34,7 +34,7 @@ const contact = form('contact-form')
|
|
|
34
34
|
.setErrors({ email: ['Already in use'] })
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
-
Methods that return data instead of the controller: [`getValue`](api/get-value.md), [`getField`](api/get-field.md), [`getData`](api/get-data.md), [`getState`](api/get-state.md), [`validate`](api/validate.md), [`revalidate`](api/revalidate.md). Methods that return an unsubscribe function: [`observe`](api/observe.md), [`subscribe`](api/subscribe.md).
|
|
37
|
+
Methods that return data instead of the controller: [`getValue`](api/get-value.md), [`getField`](api/get-field.md), [`getData`](api/get-data.md), [`getState`](api/get-state.md), [`validate`](api/validate.md), [`revalidate`](api/revalidate.md). Methods that return an unsubscribe or unregister function: [`observe`](api/observe.md), [`subscribe`](api/subscribe.md), [`addCleanup`](api/add-cleanup.md).
|
|
38
38
|
|
|
39
39
|
---
|
|
40
40
|
|
|
@@ -43,10 +43,12 @@ Methods that return data instead of the controller: [`getValue`](api/get-value.m
|
|
|
43
43
|
Once a controller is created, you can rely on the following behaviour:
|
|
44
44
|
|
|
45
45
|
- **`css-filled` attribute** is added to a field when it has a non-empty value, and removed when it becomes empty. Override the attribute name with `options.attributes.filled`.
|
|
46
|
+
- **Initial filled state is always synchronized.** Prefilled controls receive `css-filled` on mount even with `autoValidate: false`; this does not run validation or set `isValidated`.
|
|
46
47
|
- **`css-error` attribute** is added to a field when it has at least one error (validation or manual), and removed when it has none. Override with `options.attributes.error`.
|
|
47
48
|
- **Validation runs on every change** for any field that has rules configured under `options.validators` (gated by `autoValidate`, default `true`).
|
|
48
49
|
- **Manual errors from [`setErrors`](api/set-errors.md) are cleared by default when the affected field changes.** Set `clearManualErrorsOnChange: false` to keep them.
|
|
49
50
|
- **Submit handlers receive a real `FormData` instance** built from the live form, plus a plain-object mirror. Invalid submissions never reach your handler.
|
|
51
|
+
- **Async submit handlers are tracked.** `getState().isSubmitting` stays true while any promise-returning `onSubmit` work is pending, including overlapping submissions, and returns to false after fulfillment or rejection.
|
|
50
52
|
- **Dynamic fields are auto-discovered.** A `MutationObserver` watches the form subtree for `name` / `type` attribute changes or new fields, re-caches the field registry, and re-runs visual state and validation.
|
|
51
53
|
|
|
52
54
|
---
|
|
@@ -59,8 +61,8 @@ The recommended flow:
|
|
|
59
61
|
2. **React** — register [`watch`](api/watch.md) / [`observe`](api/observe.md) callbacks for field-level reactions, and [`subscribe`](api/subscribe.md) for whole-form reactions.
|
|
60
62
|
3. **Validate** — built-in validation runs automatically. Use [`validate`](api/validate.md) (or the alias [`revalidate`](api/revalidate.md)) to run it on demand.
|
|
61
63
|
4. **Submit** — register [`onSubmit`](api/on-submit.md) handlers. Valid submissions are intercepted (default) or allowed to continue natively (`preventDefault: false`).
|
|
62
|
-
5. **Reset** — call [`reset`](api/reset.md) to restore
|
|
63
|
-
6. **Destroy** — call [`destroy`](api/destroy.md) to remove listeners, disconnect the `MutationObserver`, clear caches, and drop all subscribers.
|
|
64
|
+
5. **Reset** — call [`reset`](api/reset.md) to restore native defaults, clear errors, and re-synchronize visual attributes.
|
|
65
|
+
6. **Destroy** — register integration teardown with [`addCleanup`](api/add-cleanup.md), then call [`destroy`](api/destroy.md) to remove listeners, disconnect the `MutationObserver`, run cleanups in reverse registration order, clear caches, and drop all subscribers.
|
|
64
66
|
|
|
65
67
|
---
|
|
66
68
|
|
|
@@ -79,16 +81,17 @@ Use this as a quick lookup when you need to know what a method will touch.
|
|
|
79
81
|
| [`revalidate`](api/revalidate.md) | same as [`validate`](api/validate.md) | no | no | no | re-synced |
|
|
80
82
|
| [`setErrors`](api/set-errors.md) | re-syncs visual attributes for the targeted fields | no | yes | no | re-synced |
|
|
81
83
|
| [`clearErrors`](api/clear-errors.md) | re-syncs visual attributes (all or for the listed fields) | no | yes | no | re-synced |
|
|
82
|
-
| [`reset`](api/reset.md) | calls native `form.reset()`;
|
|
84
|
+
| [`reset`](api/reset.md) | calls native `form.reset()`; clears errors and re-syncs attributes from default values | no | yes | no | re-synced |
|
|
83
85
|
| [`onSubmit`](api/on-submit.md) | none directly; submit handler is invoked on submit | n/a | n/a | n/a | n/a |
|
|
84
86
|
| [`autoSubmit`](api/auto-submit.md) | schedules `form.requestSubmit()` (with optional debounce) | native submit | yes (config change) | enables the behaviour | unchanged |
|
|
85
87
|
| [`disableAutoSubmit`](api/disable-auto-submit.md) | cancels any pending debounce timer | no | yes | disables | unchanged |
|
|
86
88
|
| [`watch`](api/watch.md) / [`observe`](api/observe.md) | none directly; callback fires on changes | n/a | n/a | n/a | unchanged |
|
|
87
89
|
| [`unwatch`](api/unwatch.md) | none | no | no | no | unchanged |
|
|
88
90
|
| [`subscribe`](api/subscribe.md) | none; fires immediately with the current snapshot | no | n/a | n/a | unchanged |
|
|
91
|
+
| [`addCleanup`](api/add-cleanup.md) | none until its callback runs during destroy | no | no | no | unchanged |
|
|
89
92
|
| [`getData`](api/get-data.md) | no | no | no | no | unchanged |
|
|
90
93
|
| [`getState`](api/get-state.md) | no | no | no | no | unchanged |
|
|
91
|
-
| [`destroy`](api/destroy.md) | removes
|
|
94
|
+
| [`destroy`](api/destroy.md) | removes listeners and runs registered cleanups; does not remove visual attributes itself | no | no longer fires | disabled | unchanged |
|
|
92
95
|
|
|
93
96
|
> Native field `input` events are delegated. Controls outside the form that use `form="id"` are handled through a document-level delegated listener.
|
|
94
97
|
|
|
@@ -98,6 +101,7 @@ Use this as a quick lookup when you need to know what a method will touch.
|
|
|
98
101
|
|
|
99
102
|
- Use [`watch`](api/watch.md) when you want a chainable, fire-and-forget reaction to a field. Use [`observe`](api/observe.md) when you need to clean up later — it returns an unsubscribe function.
|
|
100
103
|
- Use [`subscribe`](api/subscribe.md) when a higher-level component (router, store, view layer) needs to react to the whole form state.
|
|
104
|
+
- Use [`addCleanup`](api/add-cleanup.md) when third-party listeners, observers, subscriptions, or abort controllers should be released with the form controller.
|
|
101
105
|
- Use [`getData()`](api/get-data.md) when you need both a plain object and a `FormData` instance.
|
|
102
106
|
- Use [`setErrors`](api/set-errors.md) and [`clearErrors`](api/clear-errors.md) to drive visual feedback from server responses.
|
|
103
107
|
- Pass `clearManualErrorsOnChange: false` only when manual errors should persist after the user edits a field.
|
|
@@ -184,11 +188,12 @@ profile.onSubmit(async (_element, _data, formData) => {
|
|
|
184
188
|
method: 'POST',
|
|
185
189
|
body: formData
|
|
186
190
|
})
|
|
187
|
-
|
|
191
|
+
|
|
192
|
+
if (!response.ok) throw new Error('Profile update failed')
|
|
188
193
|
})
|
|
189
194
|
```
|
|
190
195
|
|
|
191
|
-
When `preventDefault` is omitted, valid submissions are intercepted — the browser will not navigate. Invalid submissions are always intercepted, regardless of the flag.
|
|
196
|
+
When `preventDefault` is omitted, valid submissions are intercepted — the browser will not navigate. Invalid submissions are always intercepted, regardless of the flag. Promise-returning handlers run concurrently and are reflected by `isSubmitting`; rejection settles that state but is not converted into a form error.
|
|
192
197
|
|
|
193
198
|
### Let the browser submit natively
|
|
194
199
|
|
package/docs/options.md
CHANGED
|
@@ -43,7 +43,7 @@ interface FormControllerOptions {
|
|
|
43
43
|
| Option | Type | Default | Behaviour |
|
|
44
44
|
| --- | --- | --- | --- |
|
|
45
45
|
| `attributes` | `Partial<VisualAttributes>` | `{ filled: 'css-filled', error: 'css-error' }` | Override the names of the visual attributes applied to fields. |
|
|
46
|
-
| `autoValidate` | `boolean` | `true` | Run validation on construction and on handled `input` events for fields that have rules. |
|
|
46
|
+
| `autoValidate` | `boolean` | `true` | Run validation on construction and on handled `input` events for fields that have rules. Initial `css-filled` synchronization still runs when this is `false`. |
|
|
47
47
|
| `autoSubmit` | `boolean \| AutoSubmitOptions` | `false` | Submit automatically after handled `input` events. Pass `{ debounce: ms }` to delay. |
|
|
48
48
|
| `clearErrorsOnSubmit` | `boolean` | `true` | Clear all manual errors before submit validation runs. |
|
|
49
49
|
| `clearManualErrorsOnChange` | `boolean` | `true` | Clear the manual error of a field when it changes. Set `false` to keep manual errors until you call [`clearErrors`](api/clear-errors.md). |
|
|
@@ -79,6 +79,8 @@ Set this to `false` if you want to validate manually (e.g. only on submit, or on
|
|
|
79
79
|
form('wizard-form', { autoValidate: false }).validate(['step-1'])
|
|
80
80
|
```
|
|
81
81
|
|
|
82
|
+
The initial visual-state pass is independent of validation. Prefilled controls receive `css-filled` on mount even when `autoValidate` is `false`, but no rules run and `isValidated` remains `false` until validation is requested.
|
|
83
|
+
|
|
82
84
|
---
|
|
83
85
|
|
|
84
86
|
## `autoSubmit`
|
|
@@ -150,6 +152,18 @@ form('signup-form', {
|
|
|
150
152
|
value: 'password',
|
|
151
153
|
message: 'Passwords do not match.'
|
|
152
154
|
}
|
|
155
|
+
},
|
|
156
|
+
amount: {
|
|
157
|
+
required: true,
|
|
158
|
+
numeric: true,
|
|
159
|
+
min: 0,
|
|
160
|
+
max: 10000
|
|
161
|
+
},
|
|
162
|
+
'attendees[].email': {
|
|
163
|
+
each: {
|
|
164
|
+
required: true,
|
|
165
|
+
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
166
|
+
}
|
|
153
167
|
}
|
|
154
168
|
}
|
|
155
169
|
})
|
|
@@ -163,11 +177,20 @@ form('signup-form', {
|
|
|
163
177
|
| `minLength` | `number \| { value: number; message?: string }` | Minimum string length (for checkbox groups, the minimum number of selected items). |
|
|
164
178
|
| `maxLength` | `number \| { value: number; message?: string }` | Maximum string length (for checkbox groups, the maximum number of selected items). |
|
|
165
179
|
| `pattern` | `RegExp \| { value: RegExp; message?: string }` | String must match the regular expression. Skipped when the field is empty. |
|
|
180
|
+
| `numeric` | `boolean \| { value: boolean; message?: string }` | Non-empty value must be an ordinary signed decimal. |
|
|
181
|
+
| `min` | `number \| { value: number; message?: string }` | Non-empty numeric value must be greater than or equal to the inclusive bound. |
|
|
182
|
+
| `max` | `number \| { value: number; message?: string }` | Non-empty numeric value must be less than or equal to the inclusive bound. |
|
|
166
183
|
| `sameAs` | `string \| { value: string; message?: string }` | Non-empty value must equal the named field. Changing the named field automatically revalidates this field. |
|
|
184
|
+
| `dependsOn` | `string \| string[]` | Names fields whose changes should revalidate this field. Useful for custom cross-field validators; it does not itself add a validation error. |
|
|
185
|
+
| `each` | `ValueValidationRules` | Applies value-level rules independently to every member/control in a collection. |
|
|
167
186
|
| `validate` | `FieldValidator \| FieldValidator[]` | Custom validators. Return a string to push an error, or `null` / `undefined` / `true` to pass. Return `false` to push a generic `"Validation failed."` message. |
|
|
168
187
|
|
|
169
188
|
Built-in rules accept either a plain value or a `{ value, message }` object. Use the object form when you want a custom error message per rule.
|
|
170
189
|
|
|
190
|
+
`numeric`, `min`, and `max` trim surrounding whitespace and accept only ordinary signed decimal syntax, such as `12`, `-3.5`, `+4`, `.75`, or `10.`. They reject exponent notation, hexadecimal, `Infinity`, `NaN`, and formatted values containing separators. Empty values skip all three rules; add `required` when emptiness should fail. `min` and `max` are inclusive and also reject a non-numeric value even when `numeric` is omitted.
|
|
191
|
+
|
|
192
|
+
Their default messages are `"Value must be a number."`, `"Minimum value is N."`, and `"Maximum value is N."` respectively.
|
|
193
|
+
|
|
171
194
|
### Matching fields with `sameAs`
|
|
172
195
|
|
|
173
196
|
Declare `sameAs` on the field that owns the error, normally the confirmation field:
|
|
@@ -197,6 +220,50 @@ Important behavior:
|
|
|
197
220
|
- Put the rule on the confirmation field only in most forms. Putting reciprocal rules on both fields is cycle-safe, but both fields will own and display the same mismatch error.
|
|
198
221
|
- Dependency cycles do not recurse. The controller resolves the affected fields with a visited set and validates each field at most once per input event.
|
|
199
222
|
|
|
223
|
+
### Custom cross-field dependencies with `dependsOn`
|
|
224
|
+
|
|
225
|
+
Custom validators can read any value, but the controller cannot infer those reads. Declare each source with `dependsOn` so changing it revalidates the field that owns the custom validator:
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
form('shipping-form', {
|
|
229
|
+
validators: {
|
|
230
|
+
method: { required: true },
|
|
231
|
+
country: {
|
|
232
|
+
dependsOn: 'method',
|
|
233
|
+
validate: ({ value, values }) =>
|
|
234
|
+
values.method === 'domestic' && value !== 'US'
|
|
235
|
+
? 'Domestic shipping requires a US address.'
|
|
236
|
+
: null
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Names are exact HTML field names. A string array declares multiple sources. Like `sameAs`, dependencies become reactive once validation is active, and transitive chains and cycles are deduplicated so each affected field runs at most once per input event. `dependsOn` is controller metadata only: the pure validation helpers evaluate the supplied snapshot without tracking changes.
|
|
243
|
+
|
|
244
|
+
### Validate every collection member with `each`
|
|
245
|
+
|
|
246
|
+
Use `each` for repeated controls, checkbox groups, or other fields whose value is a collection:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
form('attendees-form', {
|
|
250
|
+
validators: {
|
|
251
|
+
'attendees[].email': {
|
|
252
|
+
minLength: { value: 1, message: 'Add at least one attendee.' },
|
|
253
|
+
each: {
|
|
254
|
+
required: true,
|
|
255
|
+
pattern: {
|
|
256
|
+
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
257
|
+
message: 'Enter a valid attendee email.'
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Rules outside `each` validate the aggregate value. Rules inside `each` validate members independently. In a bound controller, `css-error` and `aria-invalid="true"` are applied only to the controls whose `each` rules fail; an aggregate or manual field error still marks every control in the field. Public errors remain `Record<string, string[]>`: item messages are flattened under the field name in member order rather than exposing a nested error shape.
|
|
266
|
+
|
|
200
267
|
### Custom validators
|
|
201
268
|
|
|
202
269
|
```ts
|
|
@@ -215,7 +282,7 @@ form('checkout-form', {
|
|
|
215
282
|
})
|
|
216
283
|
```
|
|
217
284
|
|
|
218
|
-
The custom validator receives `{ field, value, values }` and runs after the built-in rules. See [`FieldValidationContext`](typescript.md#fieldvalidationcontext).
|
|
285
|
+
The custom validator receives `{ field, value, values }` and runs after the built-in rules. During `each`, it also receives the optional concrete `element` and zero-based `index`. See [`FieldValidationContext`](typescript.md#fieldvalidationcontext).
|
|
219
286
|
|
|
220
287
|
---
|
|
221
288
|
|
package/docs/recipes.md
CHANGED
|
@@ -197,12 +197,8 @@ import { form } from '@samline/forms'
|
|
|
197
197
|
const builder = form('builder', {
|
|
198
198
|
validators: {
|
|
199
199
|
'rows[].name': {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const names = Array.isArray(value) ? value : [value]
|
|
203
|
-
return names.every(name => typeof name === 'string' && name.trim() !== '')
|
|
204
|
-
? null
|
|
205
|
-
: 'Every row needs a name.'
|
|
200
|
+
each: {
|
|
201
|
+
required: { value: true, message: 'Every row needs a name.' }
|
|
206
202
|
}
|
|
207
203
|
}
|
|
208
204
|
}
|
|
@@ -219,7 +215,7 @@ document.querySelector('#add')?.addEventListener('click', () => {
|
|
|
219
215
|
})
|
|
220
216
|
```
|
|
221
217
|
|
|
222
|
-
Validator keys match literal HTML names, not wildcard paths. Reusing `rows[].name` gives the validator an array once multiple rows exist. The form-subtree `MutationObserver` clears field lookups and, because the default controller is already validated, re-runs validation when each row is appended.
|
|
218
|
+
Validator keys match literal HTML names, not wildcard paths. Reusing `rows[].name` gives the validator an array once multiple rows exist. `each` validates every row independently, so only an empty row receives `css-error` and `aria-invalid="true"`; `getState().errors['rows[].name']` remains a flat `string[]`. The form-subtree `MutationObserver` clears field lookups and, because the default controller is already validated, re-runs validation when each row is appended.
|
|
223
219
|
|
|
224
220
|
---
|
|
225
221
|
|
|
@@ -251,7 +247,7 @@ mount()
|
|
|
251
247
|
|
|
252
248
|
## 7. Manual-only validation
|
|
253
249
|
|
|
254
|
-
When you want validation to run only when you ask (not on every change), disable `autoValidate` and call `validate()` yourself.
|
|
250
|
+
When you want validation to run only when you ask (not on every change), disable `autoValidate` and call `validate()` yourself. Initial `css-filled` state still synchronizes for prefilled controls without running any rules.
|
|
255
251
|
|
|
256
252
|
```ts
|
|
257
253
|
import { form } from '@samline/forms'
|
|
@@ -403,6 +399,7 @@ const shipping = form('shipping-form', {
|
|
|
403
399
|
validators: {
|
|
404
400
|
method: { required: true },
|
|
405
401
|
country: {
|
|
402
|
+
dependsOn: 'method',
|
|
406
403
|
validate: ({ value, values }) => {
|
|
407
404
|
if (values.method === 'international' && value !== 'US') return null
|
|
408
405
|
if (values.method === 'domestic' && value === 'US') return null
|
|
@@ -412,12 +409,11 @@ const shipping = form('shipping-form', {
|
|
|
412
409
|
}
|
|
413
410
|
})
|
|
414
411
|
|
|
415
|
-
// Programmatic update
|
|
412
|
+
// Programmatic update triggers the delegated pipeline and revalidates country.
|
|
416
413
|
shipping.setValue('method', 'international')
|
|
417
|
-
shipping.validate(['method', 'country'])
|
|
418
414
|
```
|
|
419
415
|
|
|
420
|
-
Custom validators receive the full values map, so cross-field rules read like plain JavaScript.
|
|
416
|
+
Custom validators receive the full values map, so cross-field rules read like plain JavaScript. `dependsOn` tells the controller that `country` must be revalidated whenever `method` changes; exact-name dependency chains and cycles are deduplicated.
|
|
421
417
|
|
|
422
418
|
---
|
|
423
419
|
|
|
@@ -465,10 +461,68 @@ Things to know:
|
|
|
465
461
|
- If you authored your own `<input type="hidden" name="<field>">` (or `<input data-formatter-raw-for="<field>">`) before wiring the controller, `format()` reuses it instead of duplicating it. Pre-existing mirrors survive `destroy()`; mirrors created by `format()` are removed.
|
|
466
462
|
- If you prefer to pre-author the visible with the display name (e.g. `<input name="phone_displayed" />`) instead of letting `format()` rename it, `format()` picks that up and skips the rename. Both authoring styles end up with the same DOM.
|
|
467
463
|
- Use `formatAll({ type, field: ['a', 'b', 'c'], options })` to bind the same configuration to several fields in one call. Each field gets its own pair (`a` + `a_displayed`, `b` + `b_displayed`, `c` + `c_displayed`).
|
|
464
|
+
- Date/time initial values and canonical mirror writes use the formatter's `auto` interpretation; live visible-input events and re-binds use `display`. This preserves server-prefilled raw values without misreading a full-length delimiter-less paste in the visible field. An explicit `options.interpretInputAs` always wins.
|
|
468
465
|
- `@samline/formatter` is an **optional peer dependency** for module builds. When it is missing, the module instance logs one cached `console.error` and asynchronously restores affected fields so the rest of the form keeps working. The standalone global IIFE bundles formatter behavior.
|
|
469
466
|
|
|
470
467
|
---
|
|
471
468
|
|
|
469
|
+
## 15. Validate numeric ranges
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
import { form } from '@samline/forms'
|
|
473
|
+
|
|
474
|
+
const invoice = form('invoice-form', {
|
|
475
|
+
validators: {
|
|
476
|
+
amount: {
|
|
477
|
+
required: true,
|
|
478
|
+
numeric: { value: true, message: 'Enter an unformatted decimal amount.' },
|
|
479
|
+
min: { value: 0, message: 'Amount cannot be negative.' },
|
|
480
|
+
max: { value: 100000, message: 'Amount is above the limit.' }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
The bounds are inclusive. Ordinary signed decimals such as `-1`, `+2.5`, `.75`, and `10.` are accepted; exponent notation, hexadecimal, `Infinity`, `NaN`, and values containing formatting separators are rejected. Empty input skips numeric rules, so use `required` when it must fail.
|
|
487
|
+
|
|
488
|
+
---
|
|
489
|
+
|
|
490
|
+
## 16. Track async submission state
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
const profile = form('profile-form')
|
|
494
|
+
|
|
495
|
+
profile.subscribe(state => {
|
|
496
|
+
saveButton.disabled = state.isSubmitting
|
|
497
|
+
saveButton.textContent = state.isSubmitting ? 'Saving...' : 'Save'
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
profile.onSubmit(async (_form, _data, formData) => {
|
|
501
|
+
await fetch('/api/profile', { method: 'POST', body: formData })
|
|
502
|
+
})
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Multiple async handlers start in registration order without awaiting one another. Overlapping submissions keep `isSubmitting` true until all tracked work settles. Rejections settle the state and are not converted into validation errors.
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## 17. Tie third-party resources to controller cleanup
|
|
510
|
+
|
|
511
|
+
```ts
|
|
512
|
+
const checkout = form('checkout-form')
|
|
513
|
+
const abortController = new AbortController()
|
|
514
|
+
|
|
515
|
+
window.addEventListener('resize', updateCheckoutLayout, {
|
|
516
|
+
signal: abortController.signal
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
checkout.addCleanup(() => abortController.abort())
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
Active cleanups run once in reverse registration order on `destroy()`. The returned unregister function removes only its own registration, duplicate callback registrations remain independent, and a cleanup registered after destruction runs immediately.
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
472
526
|
## Next steps
|
|
473
527
|
|
|
474
528
|
- Need a full options reference? See [docs/options.md](options.md).
|
package/docs/typescript.md
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
FormatType,
|
|
17
17
|
FormController,
|
|
18
18
|
FormControllerOptions,
|
|
19
|
+
FormCleanup,
|
|
19
20
|
FormDataPrimitive,
|
|
20
21
|
FormErrors,
|
|
21
22
|
FormFieldElement,
|
|
@@ -34,6 +35,7 @@ import type {
|
|
|
34
35
|
SerializedFormValue,
|
|
35
36
|
ValidationResult,
|
|
36
37
|
ValidationSchema,
|
|
38
|
+
ValueValidationRules,
|
|
37
39
|
VisualAttributes
|
|
38
40
|
} from '@samline/forms'
|
|
39
41
|
```
|
|
@@ -50,6 +52,7 @@ interface FormController {
|
|
|
50
52
|
readonly f: HTMLFormElement | null
|
|
51
53
|
readonly options: FormControllerOptions
|
|
52
54
|
onSubmit: (callback: FormSubmitHandler, preventDefault?: boolean) => FormController
|
|
55
|
+
addCleanup: (cleanup: FormCleanup) => () => void
|
|
53
56
|
watch: (field: string, callback: FormFieldWatcher) => FormController
|
|
54
57
|
observe: (field: string, callback: FormFieldWatcher) => () => void
|
|
55
58
|
unwatch: (field?: string, callback?: FormFieldWatcher) => FormController
|
|
@@ -182,7 +185,7 @@ form('booking-form', {
|
|
|
182
185
|
})
|
|
183
186
|
```
|
|
184
187
|
|
|
185
|
-
The controller
|
|
188
|
+
The controller infers reactive dependencies from `sameAs`, but not from arbitrary reads inside `validate`. Add `dependsOn: 'start_date'` to the `end_date` rules when changes to the source should automatically revalidate the custom rule.
|
|
186
189
|
|
|
187
190
|
---
|
|
188
191
|
|
|
@@ -210,6 +213,7 @@ interface FormStateSnapshot {
|
|
|
210
213
|
isValid: boolean
|
|
211
214
|
isValidated: boolean
|
|
212
215
|
autoSubmit: boolean
|
|
216
|
+
isSubmitting: boolean
|
|
213
217
|
submitCount: number
|
|
214
218
|
}
|
|
215
219
|
```
|
|
@@ -220,8 +224,9 @@ interface FormStateSnapshot {
|
|
|
220
224
|
| `errors` | Merged validation and manual errors. |
|
|
221
225
|
| `filledFields` | Names of fields that have a non-empty value. |
|
|
222
226
|
| `isValid` | `true` when `errors` has no entries. |
|
|
223
|
-
| `isValidated` | `true` once [`validate`](api/validate.md) has run
|
|
227
|
+
| `isValidated` | `true` after the initial auto-validation pass or once [`validate`](api/validate.md) has run. |
|
|
224
228
|
| `autoSubmit` | `true` while auto-submit is enabled. |
|
|
229
|
+
| `isSubmitting` | `true` while one or more valid submissions have asynchronous handlers that have not settled. |
|
|
225
230
|
| `submitCount` | Number of submit attempts (valid or invalid). |
|
|
226
231
|
|
|
227
232
|
---
|
|
@@ -234,7 +239,7 @@ The callback passed to [`subscribe`](api/subscribe.md).
|
|
|
234
239
|
type FormStateListener = (state: FormStateSnapshot) => void
|
|
235
240
|
```
|
|
236
241
|
|
|
237
|
-
Receives the current snapshot immediately, then at controller notification points such as handled input, manual-error changes, reset, submit attempts, auto-submit toggles, and observed DOM mutations. A direct `validate()` call does not independently notify it.
|
|
242
|
+
Receives the current snapshot immediately, then at controller notification points such as handled input, manual-error changes, reset, submit attempts, async-submit state transitions, auto-submit toggles, and observed DOM mutations. A direct `validate()` call does not independently notify it.
|
|
238
243
|
|
|
239
244
|
---
|
|
240
245
|
|
|
@@ -248,10 +253,10 @@ type FormSubmitHandler = (
|
|
|
248
253
|
data: Record<string, SerializedFormValue>,
|
|
249
254
|
formData: FormData,
|
|
250
255
|
state: FormStateSnapshot
|
|
251
|
-
) => void
|
|
256
|
+
) => void | Promise<void>
|
|
252
257
|
```
|
|
253
258
|
|
|
254
|
-
Only invoked when the form is valid. `data` and `formData` are produced fresh on each invocation.
|
|
259
|
+
Only invoked when the form is valid. `data` and `formData` are produced fresh on each invocation. Returned promises are tracked by `FormStateSnapshot.isSubmitting`; fulfillment or rejection settles the tracked work.
|
|
255
260
|
|
|
256
261
|
---
|
|
257
262
|
|
|
@@ -345,20 +350,38 @@ form('signup-form', {
|
|
|
345
350
|
The rule set for a single field.
|
|
346
351
|
|
|
347
352
|
```ts
|
|
348
|
-
interface FieldValidationRules {
|
|
353
|
+
interface FieldValidationRules extends ValueValidationRules {
|
|
354
|
+
sameAs?: RuleConfig<string>
|
|
355
|
+
dependsOn?: string | string[]
|
|
356
|
+
each?: ValueValidationRules
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
interface ValueValidationRules {
|
|
349
360
|
required?: RuleConfig<boolean>
|
|
350
361
|
minLength?: RuleConfig<number>
|
|
351
362
|
maxLength?: RuleConfig<number>
|
|
352
363
|
pattern?: RuleConfig<RegExp>
|
|
353
|
-
|
|
364
|
+
numeric?: RuleConfig<boolean>
|
|
365
|
+
min?: RuleConfig<number>
|
|
366
|
+
max?: RuleConfig<number>
|
|
354
367
|
validate?: FieldValidator | FieldValidator[]
|
|
355
368
|
}
|
|
356
369
|
```
|
|
357
370
|
|
|
358
|
-
|
|
371
|
+
Value rules run in the order: `required` → `minLength` → `maxLength` → `pattern` → numeric bounds → `validate`. Field-level `sameAs` and member-level `each` are then evaluated. All are optional; an empty rules object contributes nothing.
|
|
359
372
|
|
|
360
373
|
`sameAs` names another exact field key. It compares non-empty strings exactly and arrays by ordered contents; file entries compare by `File` object identity. In a controller, changing the referenced field automatically revalidates the field that declares `sameAs`. Reciprocal declarations are cycle-safe, but usually duplicate the same error on both controls; prefer declaring the rule only on the confirmation field.
|
|
361
374
|
|
|
375
|
+
`dependsOn` declares one or more exact source field names for controller revalidation. It is intended for custom validators that read `values`; it does not add an error or affect the pure helpers by itself. Dependency chains and cycles are traversed once per input event.
|
|
376
|
+
|
|
377
|
+
`each` applies a `ValueValidationRules` object to every collection member independently. Controller validation can therefore mark only the failing DOM controls, while [`FormErrors`](#formerrors) remains a flat `Record<string, string[]>` with item messages collected under the field name.
|
|
378
|
+
|
|
379
|
+
## `ValueValidationRules`
|
|
380
|
+
|
|
381
|
+
The exported subset of rules that can validate one value, including each member of a collection. It excludes field-level `sameAs`, `dependsOn`, and nested `each`.
|
|
382
|
+
|
|
383
|
+
`numeric`, `min`, and `max` skip empty values. Non-empty input must be a finite ordinary signed decimal: decimal integers and fractions are accepted, while exponent notation, hexadecimal, `Infinity`, `NaN`, and formatted separators are rejected. `min` and `max` are inclusive and imply numeric parsing even when `numeric` is omitted.
|
|
384
|
+
|
|
362
385
|
---
|
|
363
386
|
|
|
364
387
|
## `RuleConfig<T>`
|
|
@@ -395,9 +418,13 @@ interface FieldValidationContext {
|
|
|
395
418
|
field: string
|
|
396
419
|
value: FormFieldValue
|
|
397
420
|
values: FormValues
|
|
421
|
+
element?: FormFieldElement
|
|
422
|
+
index?: number
|
|
398
423
|
}
|
|
399
424
|
```
|
|
400
425
|
|
|
426
|
+
`element` and `index` are populated while a bound controller evaluates an `each` member. `index` is zero-based. Pure helper validation has no DOM element, so `element` remains undefined; `index` is still provided for array members.
|
|
427
|
+
|
|
401
428
|
Use it to write cross-field validators:
|
|
402
429
|
|
|
403
430
|
```ts
|
|
@@ -450,6 +477,18 @@ form('order-form', {
|
|
|
450
477
|
|
|
451
478
|
---
|
|
452
479
|
|
|
480
|
+
## `FormCleanup`
|
|
481
|
+
|
|
482
|
+
The callback accepted by [`addCleanup()`](api/add-cleanup.md).
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
type FormCleanup = () => void
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
Cleanups are synchronous and run once in reverse registration order during `destroy()`, unless their returned unregister function removes them first.
|
|
489
|
+
|
|
490
|
+
---
|
|
491
|
+
|
|
453
492
|
## `AutoSubmitOptions`
|
|
454
493
|
|
|
455
494
|
The argument to [`autoSubmit()`](api/auto-submit.md) and the `autoSubmit` option.
|
package/package.json
CHANGED