@samline/forms 2.6.0 → 2.7.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.
@@ -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.6.0` — see [Releases](https://github.com/samline/forms/releases) for the changelog.
11
+ > Note: the latest version is `2.7.0` — 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 the native form, clear errors, and strip visual attributes.
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()`; strips attributes | no | yes | no | cleared |
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 `css-filled` / `css-error` indirectly via listener teardown | no | no longer fires | disabled | unchanged |
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
- return response
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
- required: true,
201
- validate: ({ value }) => {
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 still triggers the delegated event pipeline.
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
 
@@ -469,6 +465,63 @@ Things to know:
469
465
 
470
466
  ---
471
467
 
468
+ ## 15. Validate numeric ranges
469
+
470
+ ```ts
471
+ import { form } from '@samline/forms'
472
+
473
+ const invoice = form('invoice-form', {
474
+ validators: {
475
+ amount: {
476
+ required: true,
477
+ numeric: { value: true, message: 'Enter an unformatted decimal amount.' },
478
+ min: { value: 0, message: 'Amount cannot be negative.' },
479
+ max: { value: 100000, message: 'Amount is above the limit.' }
480
+ }
481
+ }
482
+ })
483
+ ```
484
+
485
+ 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.
486
+
487
+ ---
488
+
489
+ ## 16. Track async submission state
490
+
491
+ ```ts
492
+ const profile = form('profile-form')
493
+
494
+ profile.subscribe(state => {
495
+ saveButton.disabled = state.isSubmitting
496
+ saveButton.textContent = state.isSubmitting ? 'Saving...' : 'Save'
497
+ })
498
+
499
+ profile.onSubmit(async (_form, _data, formData) => {
500
+ await fetch('/api/profile', { method: 'POST', body: formData })
501
+ })
502
+ ```
503
+
504
+ 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.
505
+
506
+ ---
507
+
508
+ ## 17. Tie third-party resources to controller cleanup
509
+
510
+ ```ts
511
+ const checkout = form('checkout-form')
512
+ const abortController = new AbortController()
513
+
514
+ window.addEventListener('resize', updateCheckoutLayout, {
515
+ signal: abortController.signal
516
+ })
517
+
518
+ checkout.addCleanup(() => abortController.abort())
519
+ ```
520
+
521
+ 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.
522
+
523
+ ---
524
+
472
525
  ## Next steps
473
526
 
474
527
  - Need a full options reference? See [docs/options.md](options.md).
@@ -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 can infer reactive dependencies from `sameAs`, but not from arbitrary reads inside `validate`. If `start_date` changes after `end_date` has been validated, explicitly revalidate `end_date` or watch the source field.
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 at least once. |
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
- sameAs?: RuleConfig<string>
364
+ numeric?: RuleConfig<boolean>
365
+ min?: RuleConfig<number>
366
+ max?: RuleConfig<number>
354
367
  validate?: FieldValidator | FieldValidator[]
355
368
  }
356
369
  ```
357
370
 
358
- Rules run in the order: `required` → `minLength` → `maxLength` → `pattern` → `sameAs` → `validate`. All are optional; an empty rules object contributes nothing.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samline/forms",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "TypeScript-first, framework-free form controller with validation, reactive state, serialization, auto-submit, and input formatting.",
5
5
  "keywords": [
6
6
  "forms",