@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.
Files changed (39) hide show
  1. package/README.md +6 -4
  2. package/dist/browser/global.d.ts +19 -3
  3. package/dist/browser/global.global.js +176 -30
  4. package/dist/browser/global.global.js.map +1 -1
  5. package/dist/browser/index.cjs +176 -30
  6. package/dist/browser/index.cjs.map +1 -1
  7. package/dist/browser/index.d.cts +1 -1
  8. package/dist/browser/index.d.ts +1 -1
  9. package/dist/browser/index.js +1 -1
  10. package/dist/{chunk-L4GRKSQ5.js → chunk-MMHX34WB.js} +181 -31
  11. package/dist/chunk-MMHX34WB.js.map +1 -0
  12. package/dist/index.cjs +180 -30
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +2 -2
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/{registry-BpqJetAt.d.cts → registry-GzopYPCR.d.cts} +20 -4
  18. package/dist/{registry-BpqJetAt.d.ts → registry-GzopYPCR.d.ts} +20 -4
  19. package/docs/api/add-cleanup.md +52 -0
  20. package/docs/api/destroy.md +16 -1
  21. package/docs/api/form.md +4 -2
  22. package/docs/api/format.md +3 -3
  23. package/docs/api/get-state.md +17 -1
  24. package/docs/api/index.md +1 -0
  25. package/docs/api/on-submit.md +7 -3
  26. package/docs/api/reset.md +3 -2
  27. package/docs/api/revalidate.md +3 -1
  28. package/docs/api/subscribe.md +16 -0
  29. package/docs/api/validate-field-value.md +39 -3
  30. package/docs/api/validate-values.md +4 -1
  31. package/docs/api/validate.md +5 -3
  32. package/docs/browser.md +5 -5
  33. package/docs/css-styling.md +5 -1
  34. package/docs/getting-started.md +13 -8
  35. package/docs/options.md +69 -2
  36. package/docs/recipes.md +65 -11
  37. package/docs/typescript.md +47 -8
  38. package/package.json +1 -1
  39. package/dist/chunk-L4GRKSQ5.js.map +0 -1
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FormTarget, a as FormControllerOptions, b as FormController, S as SerializedFormResult, c as FormFieldValue, d as FieldValidationRules, e as FormValues, V as ValidationSchema, f as ValidationResult } from './registry-BpqJetAt.cjs';
2
- export { A as AppendContentOptions, g as AutoSubmitOptions, h as FieldFormatConfig, i as FieldFormatConfigMap, j as FieldValidationContext, k as FieldValidator, l as FormDataPrimitive, m as FormErrors, n as FormFieldElement, o as FormFieldWatcher, p as FormStateListener, q as FormStateSnapshot, r as FormSubmitHandler, s as FormatType, t as FormsApi, u as FormsAvailable, N as NewFormInput, R as RuleConfig, v as SerializedFormValue, w as VisualAttributes, x as browser } from './registry-BpqJetAt.cjs';
1
+ import { F as FormTarget, a as FormControllerOptions, b as FormController, S as SerializedFormResult, c as FormFieldValue, d as FieldValidationRules, e as FormValues, V as ValidationSchema, f as ValidationResult } from './registry-GzopYPCR.cjs';
2
+ export { A as AppendContentOptions, g as AutoSubmitOptions, h as FieldFormatConfig, i as FieldFormatConfigMap, j as FieldValidationContext, k as FieldValidator, l as FormCleanup, m as FormDataPrimitive, n as FormErrors, o as FormFieldElement, p as FormFieldWatcher, q as FormStateListener, r as FormStateSnapshot, s as FormSubmitHandler, t as FormatType, u as FormsApi, v as FormsAvailable, N as NewFormInput, R as RuleConfig, w as SerializedFormValue, x as ValueValidationRules, y as VisualAttributes, z as browser } from './registry-GzopYPCR.cjs';
3
3
 
4
4
  declare const form: (target: FormTarget, options?: FormControllerOptions) => FormController;
5
5
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { F as FormTarget, a as FormControllerOptions, b as FormController, S as SerializedFormResult, c as FormFieldValue, d as FieldValidationRules, e as FormValues, V as ValidationSchema, f as ValidationResult } from './registry-BpqJetAt.js';
2
- export { A as AppendContentOptions, g as AutoSubmitOptions, h as FieldFormatConfig, i as FieldFormatConfigMap, j as FieldValidationContext, k as FieldValidator, l as FormDataPrimitive, m as FormErrors, n as FormFieldElement, o as FormFieldWatcher, p as FormStateListener, q as FormStateSnapshot, r as FormSubmitHandler, s as FormatType, t as FormsApi, u as FormsAvailable, N as NewFormInput, R as RuleConfig, v as SerializedFormValue, w as VisualAttributes, x as browser } from './registry-BpqJetAt.js';
1
+ import { F as FormTarget, a as FormControllerOptions, b as FormController, S as SerializedFormResult, c as FormFieldValue, d as FieldValidationRules, e as FormValues, V as ValidationSchema, f as ValidationResult } from './registry-GzopYPCR.js';
2
+ export { A as AppendContentOptions, g as AutoSubmitOptions, h as FieldFormatConfig, i as FieldFormatConfigMap, j as FieldValidationContext, k as FieldValidator, l as FormCleanup, m as FormDataPrimitive, n as FormErrors, o as FormFieldElement, p as FormFieldWatcher, q as FormStateListener, r as FormStateSnapshot, s as FormSubmitHandler, t as FormatType, u as FormsApi, v as FormsAvailable, N as NewFormInput, R as RuleConfig, w as SerializedFormValue, x as ValueValidationRules, y as VisualAttributes, z as browser } from './registry-GzopYPCR.js';
3
3
 
4
4
  declare const form: (target: FormTarget, options?: FormControllerOptions) => FormController;
5
5
 
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  parseFormData,
6
6
  validateFieldValue,
7
7
  validateValues
8
- } from "./chunk-L4GRKSQ5.js";
8
+ } from "./chunk-MMHX34WB.js";
9
9
  export {
10
10
  browser,
11
11
  createFormController,
@@ -30,16 +30,29 @@ interface FieldValidationContext {
30
30
  field: string;
31
31
  value: FormFieldValue;
32
32
  values: FormValues;
33
+ /** Concrete control while validating one member through `each`. */
34
+ element?: FormFieldElement;
35
+ /** Zero-based member position while validating through `each`. */
36
+ index?: number;
33
37
  }
34
38
  type FieldValidator = (context: FieldValidationContext) => string | undefined | null | false | true;
35
- interface FieldValidationRules {
39
+ interface ValueValidationRules {
36
40
  required?: RuleConfig<boolean>;
37
41
  minLength?: RuleConfig<number>;
38
42
  maxLength?: RuleConfig<number>;
39
43
  pattern?: RuleConfig<RegExp>;
44
+ numeric?: RuleConfig<boolean>;
45
+ min?: RuleConfig<number>;
46
+ max?: RuleConfig<number>;
47
+ validate?: FieldValidator | FieldValidator[];
48
+ }
49
+ interface FieldValidationRules extends ValueValidationRules {
40
50
  /** Field name whose non-empty value must equal this field's value. */
41
51
  sameAs?: RuleConfig<string>;
42
- validate?: FieldValidator | FieldValidator[];
52
+ /** Fields whose changes should revalidate this field. */
53
+ dependsOn?: string | string[];
54
+ /** Rules applied independently to each collection member. */
55
+ each?: ValueValidationRules;
43
56
  }
44
57
  type ValidationSchema = Record<string, FieldValidationRules>;
45
58
  interface VisualAttributes {
@@ -79,6 +92,7 @@ interface FormStateSnapshot {
79
92
  isValid: boolean;
80
93
  isValidated: boolean;
81
94
  autoSubmit: boolean;
95
+ isSubmitting: boolean;
82
96
  submitCount: number;
83
97
  }
84
98
  type FormStateListener = (state: FormStateSnapshot) => void;
@@ -93,12 +107,14 @@ interface AppendContentOptions {
93
107
  atStart?: boolean;
94
108
  }
95
109
  type FormFieldWatcher = (value: FormFieldValue, field: FormFieldElement | FormFieldElement[] | null, form: HTMLFormElement, state: FormStateSnapshot) => void;
96
- type FormSubmitHandler = (form: HTMLFormElement, data: Record<string, SerializedFormValue>, formData: FormData, state: FormStateSnapshot) => void;
110
+ type FormSubmitHandler = (form: HTMLFormElement, data: Record<string, SerializedFormValue>, formData: FormData, state: FormStateSnapshot) => void | Promise<void>;
111
+ type FormCleanup = () => void;
97
112
  interface FormController {
98
113
  readonly element: HTMLFormElement | null;
99
114
  readonly f: HTMLFormElement | null;
100
115
  readonly options: FormControllerOptions;
101
116
  onSubmit: (callback: FormSubmitHandler, preventDefault?: boolean) => FormController;
117
+ addCleanup: (cleanup: FormCleanup) => () => void;
102
118
  watch: (field: string, callback: FormFieldWatcher) => FormController;
103
119
  observe: (field: string, callback: FormFieldWatcher) => () => void;
104
120
  unwatch: (field?: string, callback?: FormFieldWatcher) => FormController;
@@ -145,4 +161,4 @@ interface FormsApi {
145
161
  }
146
162
  declare const browser: FormsApi;
147
163
 
148
- export { type AppendContentOptions as A, type FormTarget as F, type NewFormInput as N, type RuleConfig as R, type SerializedFormResult as S, type ValidationSchema as V, type FormControllerOptions as a, type FormController as b, type FormFieldValue as c, type FieldValidationRules as d, type FormValues as e, type ValidationResult as f, type AutoSubmitOptions as g, type FieldFormatConfig as h, type FieldFormatConfigMap as i, type FieldValidationContext as j, type FieldValidator as k, type FormDataPrimitive as l, type FormErrors as m, type FormFieldElement as n, type FormFieldWatcher as o, type FormStateListener as p, type FormStateSnapshot as q, type FormSubmitHandler as r, type FormatType as s, type FormsApi as t, type FormsAvailable as u, type SerializedFormValue as v, type VisualAttributes as w, browser as x };
164
+ export { type AppendContentOptions as A, type FormTarget as F, type NewFormInput as N, type RuleConfig as R, type SerializedFormResult as S, type ValidationSchema as V, type FormControllerOptions as a, type FormController as b, type FormFieldValue as c, type FieldValidationRules as d, type FormValues as e, type ValidationResult as f, type AutoSubmitOptions as g, type FieldFormatConfig as h, type FieldFormatConfigMap as i, type FieldValidationContext as j, type FieldValidator as k, type FormCleanup as l, type FormDataPrimitive as m, type FormErrors as n, type FormFieldElement as o, type FormFieldWatcher as p, type FormStateListener as q, type FormStateSnapshot as r, type FormSubmitHandler as s, type FormatType as t, type FormsApi as u, type FormsAvailable as v, type SerializedFormValue as w, type ValueValidationRules as x, type VisualAttributes as y, browser as z };
@@ -30,16 +30,29 @@ interface FieldValidationContext {
30
30
  field: string;
31
31
  value: FormFieldValue;
32
32
  values: FormValues;
33
+ /** Concrete control while validating one member through `each`. */
34
+ element?: FormFieldElement;
35
+ /** Zero-based member position while validating through `each`. */
36
+ index?: number;
33
37
  }
34
38
  type FieldValidator = (context: FieldValidationContext) => string | undefined | null | false | true;
35
- interface FieldValidationRules {
39
+ interface ValueValidationRules {
36
40
  required?: RuleConfig<boolean>;
37
41
  minLength?: RuleConfig<number>;
38
42
  maxLength?: RuleConfig<number>;
39
43
  pattern?: RuleConfig<RegExp>;
44
+ numeric?: RuleConfig<boolean>;
45
+ min?: RuleConfig<number>;
46
+ max?: RuleConfig<number>;
47
+ validate?: FieldValidator | FieldValidator[];
48
+ }
49
+ interface FieldValidationRules extends ValueValidationRules {
40
50
  /** Field name whose non-empty value must equal this field's value. */
41
51
  sameAs?: RuleConfig<string>;
42
- validate?: FieldValidator | FieldValidator[];
52
+ /** Fields whose changes should revalidate this field. */
53
+ dependsOn?: string | string[];
54
+ /** Rules applied independently to each collection member. */
55
+ each?: ValueValidationRules;
43
56
  }
44
57
  type ValidationSchema = Record<string, FieldValidationRules>;
45
58
  interface VisualAttributes {
@@ -79,6 +92,7 @@ interface FormStateSnapshot {
79
92
  isValid: boolean;
80
93
  isValidated: boolean;
81
94
  autoSubmit: boolean;
95
+ isSubmitting: boolean;
82
96
  submitCount: number;
83
97
  }
84
98
  type FormStateListener = (state: FormStateSnapshot) => void;
@@ -93,12 +107,14 @@ interface AppendContentOptions {
93
107
  atStart?: boolean;
94
108
  }
95
109
  type FormFieldWatcher = (value: FormFieldValue, field: FormFieldElement | FormFieldElement[] | null, form: HTMLFormElement, state: FormStateSnapshot) => void;
96
- type FormSubmitHandler = (form: HTMLFormElement, data: Record<string, SerializedFormValue>, formData: FormData, state: FormStateSnapshot) => void;
110
+ type FormSubmitHandler = (form: HTMLFormElement, data: Record<string, SerializedFormValue>, formData: FormData, state: FormStateSnapshot) => void | Promise<void>;
111
+ type FormCleanup = () => void;
97
112
  interface FormController {
98
113
  readonly element: HTMLFormElement | null;
99
114
  readonly f: HTMLFormElement | null;
100
115
  readonly options: FormControllerOptions;
101
116
  onSubmit: (callback: FormSubmitHandler, preventDefault?: boolean) => FormController;
117
+ addCleanup: (cleanup: FormCleanup) => () => void;
102
118
  watch: (field: string, callback: FormFieldWatcher) => FormController;
103
119
  observe: (field: string, callback: FormFieldWatcher) => () => void;
104
120
  unwatch: (field?: string, callback?: FormFieldWatcher) => FormController;
@@ -145,4 +161,4 @@ interface FormsApi {
145
161
  }
146
162
  declare const browser: FormsApi;
147
163
 
148
- export { type AppendContentOptions as A, type FormTarget as F, type NewFormInput as N, type RuleConfig as R, type SerializedFormResult as S, type ValidationSchema as V, type FormControllerOptions as a, type FormController as b, type FormFieldValue as c, type FieldValidationRules as d, type FormValues as e, type ValidationResult as f, type AutoSubmitOptions as g, type FieldFormatConfig as h, type FieldFormatConfigMap as i, type FieldValidationContext as j, type FieldValidator as k, type FormDataPrimitive as l, type FormErrors as m, type FormFieldElement as n, type FormFieldWatcher as o, type FormStateListener as p, type FormStateSnapshot as q, type FormSubmitHandler as r, type FormatType as s, type FormsApi as t, type FormsAvailable as u, type SerializedFormValue as v, type VisualAttributes as w, browser as x };
164
+ export { type AppendContentOptions as A, type FormTarget as F, type NewFormInput as N, type RuleConfig as R, type SerializedFormResult as S, type ValidationSchema as V, type FormControllerOptions as a, type FormController as b, type FormFieldValue as c, type FieldValidationRules as d, type FormValues as e, type ValidationResult as f, type AutoSubmitOptions as g, type FieldFormatConfig as h, type FieldFormatConfigMap as i, type FieldValidationContext as j, type FieldValidator as k, type FormCleanup as l, type FormDataPrimitive as m, type FormErrors as n, type FormFieldElement as o, type FormFieldWatcher as p, type FormStateListener as q, type FormStateSnapshot as r, type FormSubmitHandler as s, type FormatType as t, type FormsApi as u, type FormsAvailable as v, type SerializedFormValue as w, type ValueValidationRules as x, type VisualAttributes as y, browser as z };
@@ -0,0 +1,52 @@
1
+ # `addCleanup(cleanup)`
2
+
3
+ Registers teardown work owned by the controller. The callback runs when [`destroy()`](destroy.md) tears the controller down, unless that registration is removed first.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ addCleanup(cleanup: FormCleanup): () => void
9
+
10
+ type FormCleanup = () => void
11
+ ```
12
+
13
+ ## Parameters
14
+
15
+ | Name | Type | Required | Description |
16
+ | --- | --- | --- | --- |
17
+ | `cleanup` | [`FormCleanup`](../typescript.md#formcleanup) | yes | Synchronous callback to run during controller destruction. |
18
+
19
+ ## Returns
20
+
21
+ An unregister function. Calling it removes only that registration without running the cleanup. It is idempotent.
22
+
23
+ ## Behaviour
24
+
25
+ - Active cleanups run once, in reverse registration order, when `destroy()` is first called.
26
+ - Registering the same callback more than once creates independent registrations. Each active registration runs once.
27
+ - A cleanup added after the controller is already destroyed runs immediately. Its returned unregister function is a no-op.
28
+ - If a cleanup throws, `destroy()` logs `[forms] cleanup failed`, continues with the remaining callbacks, and completes teardown.
29
+ - Calling `destroy()` again does not rerun cleanups.
30
+
31
+ ## Example
32
+
33
+ ```ts
34
+ import { form } from '@samline/forms'
35
+
36
+ const profile = form('profile-form')
37
+ const abortController = new AbortController()
38
+
39
+ const unregister = profile.addCleanup(() => abortController.abort())
40
+
41
+ // Optional: keep the resource alive beyond this controller.
42
+ unregister()
43
+
44
+ profile.destroy()
45
+ ```
46
+
47
+ This hook is useful for integration-owned event listeners, observers, subscriptions, abort controllers, and other resources that should share the form controller's lifetime.
48
+
49
+ ## Related
50
+
51
+ - [`destroy`](destroy.md) — runs all active cleanup registrations.
52
+ - [`observe`](observe.md) and [`subscribe`](subscribe.md) — return their own unsubscribe functions.
@@ -1,6 +1,6 @@
1
1
  # `destroy()`
2
2
 
3
- Tears the controller down. Removes all DOM listeners, disconnects the `MutationObserver`, clears caches, drops subscribers and submit handlers, and resets internal state.
3
+ Tears the controller down. Removes all DOM listeners, disconnects the `MutationObserver`, runs registered cleanups, clears caches, drops subscribers and submit handlers, and resets internal state.
4
4
 
5
5
  ## Signature
6
6
 
@@ -19,8 +19,10 @@ When invoked, the controller:
19
19
  - Removes every listener it added (`input`, `submit`, formatter listeners, and document delegation for external controls).
20
20
  - Cancels any pending auto-submit debounce timer.
21
21
  - Disconnects the form `MutationObserver`.
22
+ - Runs active callbacks registered with [`addCleanup()`](add-cleanup.md) once in reverse registration order. A thrown cleanup is logged and does not stop the others.
22
23
  - Clears `watchedFields`, `subscribers`, `submitHandlers`, and `fieldCache`.
23
24
  - Resets manual and validation error maps.
25
+ - Clears tracked async-submit state. Pending handler promises continue independently, but no completion notification is emitted after destruction.
24
26
 
25
27
  After `destroy()` the controller object still exists and its methods remain callable. Direct reads and writes can still inspect or mutate the bound DOM, but the removed controller listeners no longer react to resulting events.
26
28
 
@@ -53,13 +55,26 @@ window.addEventListener('beforeunload', () => {
53
55
  })
54
56
  ```
55
57
 
58
+ ### Own integration resources
59
+
60
+ ```ts
61
+ const profile = form('profile-form')
62
+ const observer = new ResizeObserver(() => updateLayout())
63
+
64
+ if (profile.element) observer.observe(profile.element)
65
+ profile.addCleanup(() => observer.disconnect())
66
+ ```
67
+
56
68
  ## Edge cases
57
69
 
58
70
  - **`destroy()` does not remove `css-filled` / `css-error` attributes** from fields. If you need that, call [`reset()`](reset.md) before destroy, or strip the attributes yourself.
59
71
  - **Subscribers and watchers stop receiving notifications immediately**, but their closures remain until you release them.
72
+ - **Cleanup registrations are independent.** Registering the same callback twice runs it twice unless one registration's unregister function is called.
73
+ - **Late cleanup registration runs immediately.** Calling `addCleanup()` after destruction executes the callback at once and returns a no-op unregister function.
60
74
  - **Reusing the controller reference after destroy is possible but usually misleading.** Reads still inspect the bound form and methods such as `setValue()` can still write fields, but watchers, subscribers, validation-on-input, and auto-submit no longer run.
61
75
 
62
76
  ## Related
63
77
 
64
78
  - [`reset`](reset.md) — restore form values without removing listeners.
65
79
  - [`form`](form.md) — bind a new controller.
80
+ - [`addCleanup`](add-cleanup.md) — register controller-owned teardown work.
package/docs/api/form.md CHANGED
@@ -69,8 +69,8 @@ On creation the controller:
69
69
  3. Wires delegated `input` and `submit` listeners, including controls associated through `form="id"`.
70
70
  4. Starts a `MutationObserver` on the form subtree to track dynamic fields.
71
71
  5. Optionally enables [`autoSubmit`](../api/auto-submit.md) when `options.autoSubmit` is truthy.
72
- 6. Runs an initial validation pass if `autoValidate` is enabled.
73
- 7. Notifies subscribers with the initial state.
72
+ 6. Synchronizes initial `css-filled` state for every tracked control, including when `autoValidate` is disabled.
73
+ 7. Runs an initial validation pass only if `autoValidate` is enabled.
74
74
 
75
75
  ## Examples
76
76
 
@@ -134,6 +134,7 @@ form(profileRef)
134
134
  - **The id is resolved once.** If the DOM element with that id is replaced, the controller keeps listening to the original element. Recreate the controller if you re-render the form.
135
135
  - **`null` / `undefined` targets** are accepted and produce an inert controller. The chainable methods still return the controller, so they are safe to call as no-ops.
136
136
  - **Reusing a target across multiple controllers** is supported but they each install their own listeners — call [`destroy()`](destroy.md) on the previous one to avoid duplicate work.
137
+ - **`autoValidate: false` disables the initial rule pass, not visual initialization.** Prefilled fields still receive `css-filled`; no validation errors are produced and `isValidated` remains `false`.
137
138
  - **`createFormController` is also exported** as the lower-level factory; prefer `form` for ergonomic imports.
138
139
 
139
140
  ## `createFormController`
@@ -151,4 +152,5 @@ This is the factory used by `form()` and accepts the same arguments. It is expor
151
152
 
152
153
  - [`FormController`](../typescript.md#formcontroller) — the returned controller type.
153
154
  - [`destroy`](destroy.md) — tear the controller down.
155
+ - [`addCleanup`](add-cleanup.md) — attach integration cleanup to the controller lifecycle.
154
156
  - [docs/options.md](../options.md) — every option in detail.
@@ -81,11 +81,11 @@ When the visible field already carries a value at the moment `format()` first ru
81
81
  To bridge the two layers, the controller:
82
82
 
83
83
  - For the **initial pass** (and for input events whose source is the hidden mirror, e.g. a `setValue('field', raw)` that wrote the canonical raw), overrides the formatter call to use `interpretInputAs: 'auto'`. This handles the realistic `old()` re-render case where the server hands the visible the canonical raw.
84
- - For **live keystrokes** on the visible input, the formatter is invoked with the caller's `options` as-is — what the user typed is in display order, and the formatter's default (`'auto'` since v2.0.0) does the right thing.
85
- - For the **re-bind** path (a second `format()` call with new options for the same field), the formatter is also invoked with the caller's `options` as-is — the visible at that point is whatever the formatter previously produced, in display order.
84
+ - For **live input events** on the visible field, the controller injects `interpretInputAs: 'display'` when the caller did not choose a mode. This keeps typing and full-length delimiter-less pastes in display order instead of leaving them ambiguous under the formatter's automatic heuristic.
85
+ - For the **re-bind** path (a second `format()` call with new options for the same field), the controller also injects `'display'` by default because the current visible value is already in display order.
86
86
  - An **explicit `interpretInputAs`** in `options` is always respected, never overridden. Use `'display'` if you pre-render the visible in display order from the server, or `'raw'` if you want the legacy pre-1.2.0 round-trip semantics for the initial pass.
87
87
 
88
- This is transparent for the common case: a Blade re-render with `value="{{ old('birthday') }}"` carrying `"19901212"` repopulates the visible as `"12/12/1990"` without any extra wiring on the consumer side. The option is opt-in only when the consumer needs the inverse convention.
88
+ This is transparent for the common case: a Blade re-render with `value="{{ old('birthday') }}"` carrying `"19901212"` repopulates the visible as `"12/12/1990"`, while pasting `"12121990"` into the visible field remains 12 December 1990. Set `interpretInputAs` explicitly only when your application needs a different convention.
89
89
 
90
90
  ## Pre-authoring the visible
91
91
 
@@ -16,6 +16,7 @@ interface FormStateSnapshot {
16
16
  isValid: boolean
17
17
  isValidated: boolean
18
18
  autoSubmit: boolean
19
+ isSubmitting: boolean
19
20
  submitCount: number
20
21
  }
21
22
  ```
@@ -36,8 +37,9 @@ A [`FormStateSnapshot`](../typescript.md#formstatesnapshot). Every call returns
36
37
  | `errors` | Merged validation errors (from rules) and manual errors (from [`setErrors`](set-errors.md)). |
37
38
  | `filledFields` | Names of fields that have a non-empty value. |
38
39
  | `isValid` | `true` when `errors` has no entries. |
39
- | `isValidated` | `true` after [`validate`](validate.md) has run at least once. |
40
+ | `isValidated` | `true` after the initial auto-validation pass or once [`validate`](validate.md) has run. |
40
41
  | `autoSubmit` | `true` while auto-submit is enabled. |
42
+ | `isSubmitting` | `true` while at least one valid submission has asynchronous `onSubmit` work that has not settled. |
41
43
  | `submitCount` | Number of submit attempts (valid or invalid). |
42
44
 
43
45
  ## Examples
@@ -54,6 +56,7 @@ const state = profile.getState()
54
56
  console.log(state.values.email)
55
57
  console.log(state.errors)
56
58
  console.log(state.isValid)
59
+ console.log(state.isSubmitting)
57
60
  ```
58
61
 
59
62
  ### Drive an accessible error message
@@ -82,12 +85,25 @@ profile.onSubmit((_form, _data, _formData, state) => {
82
85
  })
83
86
  ```
84
87
 
88
+ ### Disable a button during async submissions
89
+
90
+ ```ts
91
+ profile.subscribe(state => {
92
+ submitButton.disabled = state.isSubmitting
93
+ })
94
+
95
+ profile.onSubmit(async (_form, _data, formData) => {
96
+ await fetch('/profile', { method: 'POST', body: formData })
97
+ })
98
+ ```
99
+
85
100
  ## Edge cases
86
101
 
87
102
  - **`getState()` does not notify subscribers.** If you need a notification-driven view, use [`subscribe`](subscribe.md).
88
103
  - **`errors` is the merged map** (validation + manual). Use [`validate`](validate.md) if you need just the validation errors.
89
104
  - **`filledFields` reflects the value snapshot at the time of the call** — it is not stored between calls.
90
105
  - **`submitCount` increments on every submit**, valid or invalid. It does not reset on [`reset`](reset.md); it tracks the lifetime of the controller.
106
+ - **`isSubmitting` tracks overlapping work.** It remains `true` until every currently pending async submission batch settles, including rejected promises. Synchronous handlers do not toggle it.
91
107
 
92
108
  ## Related
93
109
 
package/docs/api/index.md CHANGED
@@ -8,6 +8,7 @@ Every public method and helper gets its own page. Use this index to navigate to
8
8
  - [`createFormController(target, options?)`](form.md#createformcontroller) — underlying controller factory.
9
9
  - [`browser`](../browser.md) — registry-oriented browser API and global build.
10
10
  - [`element`](element.md) — the bound form (`f` is an alias).
11
+ - [`addCleanup(cleanup)`](add-cleanup.md) — register controller-owned teardown work.
11
12
  - [`destroy()`](destroy.md) — tear down listeners, observer, and caches.
12
13
  - [`reset()`](reset.md) — restore native form values and clear errors.
13
14
 
@@ -28,12 +28,13 @@ The submit pipeline runs in this order:
28
28
 
29
29
  1. If `clearErrorsOnSubmit` is `true` (default), manual errors are cleared.
30
30
  2. Validation runs for every field with rules. `isValidated` becomes `true`.
31
- 3. If validation fails, the browser’s native submit is prevented, submit handlers are **not** invoked, `aria-invalid="true"` is synchronized, the first connected, enabled, non-hidden invalid field receives focus, `submitCount` is incremented, and subscribers are notified.
31
+ 3. If validation fails, the browser’s native submit is prevented, submit handlers are **not** invoked, `aria-invalid="true"` is synchronized, the first connected, enabled, non-hidden invalid control receives focus (including the first failing `each` member), `submitCount` is incremented, and subscribers are notified.
32
32
  4. If validation passes:
33
33
  - `submitCount` is incremented.
34
34
  - Subscribers are notified.
35
35
  - If at least one handler was registered with `preventDefault: true`, the native submit is prevented. Otherwise (all handlers `preventDefault: false`), the native submit proceeds.
36
- - Every registered handler is invoked in registration order. When the event supplies a successful named submit button, its name/value is included in `data` and `formData`.
36
+ - Every registered handler is invoked in registration order. When the event supplies a successful named submit button, its name/value is included in `data` and `formData`.
37
+ - Promise-returning handlers are tracked concurrently. Subscribers receive `isSubmitting: true` while the batch is pending and another update when all promises in it have fulfilled or rejected.
37
38
 
38
39
  ## Examples
39
40
 
@@ -81,7 +82,10 @@ profile
81
82
  - **`data` is a plain object mirror of `FormData`.** Repeated names become arrays (e.g. `interests: ['design', 'code']`).
82
83
  - **Handlers do not receive errors as an argument.** If you need to inspect the validation result, read [`getState()`](get-state.md) or call [`validate()`](validate.md) from inside the handler.
83
84
  - **`onSubmit` cannot be removed** — there is no `offSubmit` API. If you need conditional submission, guard inside the handler or recreate the controller.
84
- - **The handler is invoked synchronously after the submit event.** If your handler returns a promise, the native submit is already prevented (when applicable) — the promise’s resolution does not gate the browser.
85
+ - **Handlers may return `void` or `Promise<void>`.** Invocation still starts synchronously and native submit prevention is decided before promises settle; async resolution does not gate the browser.
86
+ - **Handlers in one submission run concurrently.** The controller invokes them in registration order without awaiting between calls, then tracks all returned promises together.
87
+ - **Rejected promises settle submission state.** They do not become form errors and are consumed by the controller's all-settled tracking, so `isSubmitting` returns to `false` when no other submission work remains.
88
+ - **Overlapping submissions are tracked independently.** `isSubmitting` remains `true` until every pending submission batch has settled.
85
89
 
86
90
  ## Related
87
91
 
package/docs/api/reset.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `reset()`
2
2
 
3
- Restores the form to its initial state: native field values reset, manual and validation errors cleared, visual attributes stripped, subscribers notified.
3
+ Restores the form to its initial state: native field values reset, manual and validation errors cleared, visual attributes synchronized, subscribers notified.
4
4
 
5
5
  ## Signature
6
6
 
@@ -20,7 +20,7 @@ When invoked, the controller:
20
20
  2. Clears `state.manualErrors` and `state.validationErrors`.
21
21
  3. Restores matching visible/raw defaults for formatted fields.
22
22
  4. Removes every `css-filled`, `css-error`, and `aria-invalid` attribute from associated fields.
23
- 5. Re-syncs visual state if the form has been validated (`isValidated` is `true`).
23
+ 5. Re-syncs visual state. Controls whose native default value is non-empty regain `css-filled`, even when `autoValidate` is `false`; errors and `aria-invalid` stay cleared.
24
24
  6. Notifies subscribers.
25
25
 
26
26
  ## Examples
@@ -53,6 +53,7 @@ document.querySelector('#cancel')?.addEventListener('click', () => {
53
53
  - **Native `reset()` does not dispatch `input` events** for fields whose value changes because of the reset. The controller restores formatted mirrors and visual/ARIA state explicitly.
54
54
  - **Submit handlers are not invoked.** `reset()` is purely a UI / state operation.
55
55
  - **The `submitCount` from [`getState()`](get-state.md) is not reset.** It tracks the lifetime of the controller, not the lifetime of a single form session.
56
+ - **Pending async submit work is not cancelled.** `isSubmitting` remains true until the outstanding `onSubmit` promises settle.
56
57
 
57
58
  ## Related
58
59
 
@@ -21,6 +21,8 @@ Same as [`validate`](validate.md) — a [`ValidationResult`](../typescript.md#va
21
21
 
22
22
  Identical to [`validate`](validate.md). The two methods share the same internal implementation.
23
23
 
24
+ This includes strict decimal `numeric` / inclusive `min` / `max` rules, per-member `each` validation and element-level error styling. Explicit field lists are not expanded through `sameAs` or `dependsOn`; those relationships drive revalidation after input events.
25
+
24
26
  ## When to use which
25
27
 
26
28
  Use `validate` when you are running validation as part of an initial check. Use `revalidate` when you are explicitly asking the controller to recompute validation after a state change (e.g. after `setErrors`, after async data loads, after resetting partial form state).
@@ -47,4 +49,4 @@ profile.revalidate(['email'])
47
49
 
48
50
  ## Related
49
51
 
50
- - [`validate`](validate.md) — same implementation, different name.
52
+ - [`validate`](validate.md) — same implementation, different name.
@@ -32,6 +32,7 @@ State mutations that fire subscribers include:
32
32
  - [`autoSubmit`](auto-submit.md) and [`disableAutoSubmit`](disable-auto-submit.md).
33
33
  - [`reset`](reset.md).
34
34
  - Submit attempts, after their validation pass and `submitCount` update.
35
+ - Async submit state transitions: once when promise-returning handlers make `isSubmitting` true, and again after their submission batch settles. Overlapping batches keep it true until all settle.
35
36
  - `MutationObserver`-detected DOM changes.
36
37
 
37
38
  Direct [`validate`](validate.md) and [`revalidate`](revalidate.md) calls update validation state and visual attributes but do not independently notify subscribers. Use their returned result when synchronous rendering is required.
@@ -78,6 +79,21 @@ profile.subscribe(state => {
78
79
  })
79
80
  ```
80
81
 
82
+ ### Track asynchronous submission
83
+
84
+ ```ts
85
+ const profile = form('profile-form')
86
+
87
+ profile.subscribe(state => {
88
+ submitButton.disabled = state.isSubmitting
89
+ submitButton.textContent = state.isSubmitting ? 'Saving...' : 'Save'
90
+ })
91
+
92
+ profile.onSubmit(async (_form, _data, formData) => {
93
+ await fetch('/profile', { method: 'POST', body: formData })
94
+ })
95
+ ```
96
+
81
97
  ## Edge cases
82
98
 
83
99
  - **The listener is invoked with a fresh snapshot** on every fire — it is safe to store the reference for the duration of one call.
@@ -34,8 +34,10 @@ Runs each rule in order:
34
34
  2. `minLength` — string length (or array length) must be ≥ the threshold.
35
35
  3. `maxLength` — string length (or array length) must be ≤ the threshold.
36
36
  4. `pattern` — the string form of the value must match the regex (skipped when the value is empty).
37
- 5. `sameAs` — the value must equal the named value (skipped until both values are non-empty).
38
- 6. `validate` — each custom validator runs in registration order. Returning a string pushes an error; returning `false` pushes `"Validation failed."`; returning `null`, `undefined`, or `true` is treated as a pass.
37
+ 5. `numeric`, `min`, and `max` — non-empty values must be strict decimals and satisfy the inclusive bounds.
38
+ 6. `validate` — each field-level custom validator runs in registration order. Returning a string pushes an error; returning `false` pushes `"Validation failed."`; returning `null`, `undefined`, or `true` is treated as a pass.
39
+ 7. `sameAs` — the aggregate value must equal the named value (skipped until both values are non-empty).
40
+ 8. `each` — the value-level rules run independently for every collection member, including any member-level custom validators.
39
41
 
40
42
  Default error messages are produced when the rule was configured without an explicit `message`. Custom messages come from the `{ value, message }` form of `RuleConfig`.
41
43
 
@@ -93,14 +95,48 @@ const errors = validateFieldValue(
93
95
  console.log(errors) // ['Use at least 8 characters.']
94
96
  ```
95
97
 
98
+ ### Validate strict decimal values and bounds
99
+
100
+ ```ts
101
+ const errors = validateFieldValue(
102
+ 'amount',
103
+ '100.25',
104
+ { numeric: true, min: 0, max: 500 },
105
+ { amount: '100.25' }
106
+ )
107
+
108
+ console.log(errors) // []
109
+ ```
110
+
111
+ ### Validate every collection member
112
+
113
+ ```ts
114
+ const errors = validateFieldValue(
115
+ 'emails',
116
+ ['valid@example.com', 'invalid'],
117
+ {
118
+ each: {
119
+ required: true,
120
+ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
121
+ }
122
+ },
123
+ { emails: ['valid@example.com', 'invalid'] }
124
+ )
125
+
126
+ console.log(errors) // ['Value does not match the required pattern.']
127
+ ```
128
+
96
129
  ## Edge cases
97
130
 
98
131
  - **The pattern rule is skipped for empty values.** This is the standard “required + pattern” pattern: required runs first, then pattern runs only when there is a value.
132
+ - **Numeric rules are skipped for empty values.** Use `required` as well when the field is mandatory.
133
+ - **Numeric parsing is strict.** Surrounding whitespace is trimmed, and ordinary signed decimals such as `-2`, `+3.5`, `.5`, and `10.` are accepted. Exponents, hexadecimal, `Infinity`, `NaN`, and formatted separators are rejected. `min` and `max` are inclusive and also enforce numeric input when `numeric` is omitted.
99
134
  - **The `sameAs` rule is skipped until both values are non-empty.** Add `required` to each mandatory field; `sameAs` does not imply requiredness.
100
- - **Pure validation does not track dependencies.** `validateFieldValue` compares the supplied values once. Automatic dependent revalidation is controller behavior.
135
+ - **Pure validation does not track dependencies.** `dependsOn` is controller metadata and has no effect here; `validateFieldValue` evaluates the supplied values once.
101
136
  - **`validateFieldValue` does not read from any DOM.** Pass everything in as arguments.
102
137
  - **`validate` runs after the built-in rules.** It receives the full context, including the current value and the other field values.
103
138
  - **Multiple custom validators** can be passed as an array — they all run, and any error from any of them is collected.
139
+ - **`each` keeps the public return type flat.** Member errors are appended to the returned `string[]` in member order. Member custom validators receive a zero-based `index`; `element` is unavailable in this DOM-free helper.
104
140
 
105
141
  ## Related
106
142
 
@@ -38,7 +38,7 @@ A [`ValidationResult`](../typescript.md#validationresult):
38
38
 
39
39
  ## Behaviour
40
40
 
41
- For each `[field, rules]` entry in the schema, runs [`validateFieldValue`](validate-field-value.md) against `values[field]`. The other values are available to `sameAs` and custom validators.
41
+ For each `[field, rules]` entry in the schema, runs [`validateFieldValue`](validate-field-value.md) against `values[field]`. The other values are available to `sameAs` and custom validators. Aggregate rules and `each` member rules are collected into the same `string[]` for that field.
42
42
 
43
43
  ## Examples
44
44
 
@@ -92,6 +92,9 @@ if (!result.isValid) {
92
92
  - **`validateValues` does not know which fields are “tracked”.** It validates every entry in the schema, even fields that are not present in `values`. An undefined value fails `required` but passes the rest.
93
93
  - **Errors are always a fresh object.** Safe to mutate.
94
94
  - **`sameAs` compares the provided snapshot only.** Dependency tracking and automatic revalidation belong to a form controller, not this pure helper.
95
+ - **`dependsOn` has no effect in this helper.** It describes controller revalidation triggers, not a validation rule.
96
+ - **`each` errors stay flat.** Member messages are appended under the field's existing `FormErrors` key in member order; no nested error shape is introduced.
97
+ - **Numeric rules use strict decimal syntax and inclusive bounds.** Empty values skip `numeric`, `min`, and `max`; use `required` to reject emptiness.
95
98
  - **Custom validators receive the full values map**, so cross-field rules work the same as inside a controller.
96
99
 
97
100
  ## Related
@@ -33,7 +33,8 @@ A [`ValidationResult`](../typescript.md#validationresult):
33
33
 
34
34
  - Marks the form as validated (`state.isValidated = true`). This means future field changes will run validation again, even when the controller was created with `autoValidate: false`.
35
35
  - Re-syncs visual attributes (`css-filled` / `css-error`) for the affected fields.
36
- - After the form is validated, an input event also revalidates fields whose `sameAs` rule references the changed field. Cycles are deduplicated and each affected field runs once.
36
+ - After the form is validated, an input event also revalidates fields whose `sameAs` or `dependsOn` metadata references the changed field. Transitive chains and cycles are deduplicated and each affected field runs once.
37
+ - `each` validates every collection member independently. Item-only failures apply `css-error` and `aria-invalid="true"` only to the failing DOM controls, while the public `errors[field]` remains a flat `string[]`.
37
38
  - Subscribers are not notified — this method is a pure read of the rule engine. To run validation and notify subscribers, use [`subscribe`](subscribe.md) and read [`getState()`](get-state.md).
38
39
 
39
40
  ## Examples
@@ -88,8 +89,9 @@ function nextStep(step: 1 | 2) {
88
89
 
89
90
  - **`validate` ignores manual errors** during computation, but the returned `errors` includes them (merged with validation errors). The `isValid` flag reflects the merged map.
90
91
  - **Validation rules must be configured** via `options.validators`. Fields without rules are always considered valid.
91
- - **An explicit partial call validates exactly the requested fields.** `validate(['password'])` does not expand `sameAs` dependencies; dependency expansion happens for input events, including those dispatched by `setValue()`.
92
- - **Custom validators receive `{ field, value, values }`** — see [`FieldValidationContext`](../typescript.md#fieldvalidationcontext).
92
+ - **An explicit partial call validates exactly the requested fields.** `validate(['password'])` does not expand `sameAs` / `dependsOn` relationships; dependency expansion happens for input events, including those dispatched by `setValue()`.
93
+ - **Custom validators receive `{ field, value, values }`.** During `each`, the context also includes the concrete `element` and zero-based `index` — see [`FieldValidationContext`](../typescript.md#fieldvalidationcontext).
94
+ - **Numeric rules are strict and bounds are inclusive.** Empty values skip `numeric`, `min`, and `max`; add `required` when needed.
93
95
  - **A custom validator that returns `false` pushes the generic message `"Validation failed."`.**
94
96
  - **`validate()` is the same as calling [`revalidate()`](revalidate.md)`** — they are aliases for readability.
95
97
 
package/docs/browser.md CHANGED
@@ -11,10 +11,10 @@ For every other case (modern apps, bundlers, TypeScript projects), use the main
11
11
  ## Script tag
12
12
 
13
13
  ```html
14
- <script src="https://unpkg.com/@samline/forms@2.6.0/dist/browser/global.global.js"></script>
14
+ <script src="https://unpkg.com/@samline/forms@2.7.1/dist/browser/global.global.js"></script>
15
15
  ```
16
16
 
17
- > Pin the version in production. Replace `2.6.0` with the version you ship.
17
+ > Pin the version in production. Replace `2.7.1` with the version you ship.
18
18
 
19
19
  The bundle is a single IIFE that registers a global object.
20
20
 
@@ -49,7 +49,7 @@ The factory returns a `FormController` with the same signatures, semantics, and
49
49
  <button type="submit">Send</button>
50
50
  </form>
51
51
 
52
- <script src="https://unpkg.com/@samline/forms@2.6.0/dist/browser/global.global.js"></script>
52
+ <script src="https://unpkg.com/@samline/forms@2.7.1/dist/browser/global.global.js"></script>
53
53
  <script>
54
54
  const contactForm = window.Forms.newForm({
55
55
  id: 'contact-form',
@@ -102,7 +102,7 @@ The browser bundle ships only the registry helpers plus the `form` factory. Ever
102
102
 
103
103
  The controller returned by `form` / `newForm` exposes the methods documented under [docs/api/](api/index.md):
104
104
 
105
- - Lifecycle: [`element`](api/element.md), [`reset`](api/reset.md), [`destroy`](api/destroy.md).
105
+ - Lifecycle: [`element`](api/element.md), [`addCleanup`](api/add-cleanup.md), [`reset`](api/reset.md), [`destroy`](api/destroy.md).
106
106
  - Submission: [`onSubmit`](api/on-submit.md), [`autoSubmit`](api/auto-submit.md), [`disableAutoSubmit`](api/disable-auto-submit.md).
107
107
  - Field observation: [`watch`](api/watch.md), [`observe`](api/observe.md), [`unwatch`](api/unwatch.md), [`subscribe`](api/subscribe.md).
108
108
  - Field values: [`setValue`](api/set-value.md), [`getValue`](api/get-value.md), [`getField`](api/get-field.md), [`prefill`](api/prefill.md).
@@ -126,7 +126,7 @@ window.Forms.destroyForm('contact-form')
126
126
 
127
127
  ## Common pitfalls
128
128
 
129
- - **Pin the version.** The CDN URL above is `2.6.0`. Replace it whenever you upgrade.
129
+ - **Pin the version.** The CDN URL above is `2.7.1`. Replace it whenever you upgrade.
130
130
  - **The script must be loaded before any code that uses `window.Forms`.** Place the `<script>` tag in `<head>` with `defer`, or before the user script in `<body>`.
131
131
  - **No bundler means no tree-shaking.** The global browser bundle includes the controller and formatter peer. Inspect the published artifact when bundle size is a constraint.
132
132
  - **CSP:** if your site uses a strict Content Security Policy, allow `unpkg.com` in `script-src` (or self-host the file).