@samline/forms 2.7.2 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/README.md +119 -119
  3. package/dist/browser/global.d.ts +50 -16
  4. package/dist/browser/global.global.js +12 -6188
  5. package/dist/browser/global.global.js.map +1 -1
  6. package/dist/browser/index.cjs +340 -84
  7. package/dist/browser/index.cjs.map +1 -1
  8. package/dist/browser/index.d.cts +1 -1
  9. package/dist/browser/index.d.ts +1 -1
  10. package/dist/browser/index.js +1 -1
  11. package/dist/{chunk-MMHX34WB.js → chunk-6MA5TF6R.js} +341 -85
  12. package/dist/chunk-6MA5TF6R.js.map +1 -0
  13. package/dist/index.cjs +340 -84
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +2 -2
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +1 -1
  18. package/dist/{registry-GzopYPCR.d.ts → registry-CPe_lf8-.d.cts} +50 -4
  19. package/dist/{registry-GzopYPCR.d.cts → registry-CPe_lf8-.d.ts} +50 -4
  20. package/package.json +39 -8
  21. package/dist/chunk-MMHX34WB.js.map +0 -1
  22. package/docs/api/add-cleanup.md +0 -52
  23. package/docs/api/append.md +0 -96
  24. package/docs/api/auto-submit.md +0 -91
  25. package/docs/api/clear-errors.md +0 -69
  26. package/docs/api/destroy.md +0 -80
  27. package/docs/api/disable-auto-submit.md +0 -65
  28. package/docs/api/element.md +0 -78
  29. package/docs/api/form.md +0 -156
  30. package/docs/api/format.md +0 -270
  31. package/docs/api/get-data.md +0 -90
  32. package/docs/api/get-field.md +0 -87
  33. package/docs/api/get-state.md +0 -111
  34. package/docs/api/get-value.md +0 -114
  35. package/docs/api/index.md +0 -55
  36. package/docs/api/observe.md +0 -126
  37. package/docs/api/on-submit.md +0 -94
  38. package/docs/api/parse-form-data.md +0 -94
  39. package/docs/api/prefill.md +0 -89
  40. package/docs/api/regex.md +0 -155
  41. package/docs/api/reset.md +0 -61
  42. package/docs/api/revalidate.md +0 -52
  43. package/docs/api/set-errors.md +0 -97
  44. package/docs/api/set-value.md +0 -120
  45. package/docs/api/subscribe.md +0 -107
  46. package/docs/api/unwatch.md +0 -74
  47. package/docs/api/validate-field-value.md +0 -144
  48. package/docs/api/validate-values.md +0 -103
  49. package/docs/api/validate.md +0 -102
  50. package/docs/api/watch.md +0 -117
  51. package/docs/browser.md +0 -156
  52. package/docs/css-styling.md +0 -191
  53. package/docs/getting-started.md +0 -222
  54. package/docs/options.md +0 -326
  55. package/docs/recipes.md +0 -531
  56. package/docs/typescript.md +0 -615
  57. package/docs/vanilla.md +0 -12
  58. /package/{LICENSE → LICENSE.md} +0 -0
@@ -1,270 +0,0 @@
1
- # `format(config)` · `formatAll(config)`
2
-
3
- Apply an `@samline/formatter` pipeline to one or more fields inside the bound form. Chainable.
4
-
5
- `format()` and `formatAll()` behave identically — the alias exists so the call site reads naturally when the caller wants to apply the same configuration to several inputs.
6
-
7
- ## The mirror convention
8
-
9
- A formatted field is exposed as a pair of inputs:
10
-
11
- | Role | `name` | `type` | Value |
12
- | --- | --- | --- | --- |
13
- | **Canonical** (the one the backend reads) | `<field>` | `hidden` | The raw value |
14
- | **Display** (what the user types) | `<field>_displayed` | `text` / `tel` / `…` | The formatted value |
15
-
16
- You write the HTML with the canonical name only — `<input name="phone" />`. The first time `format()` runs for that field it renames the visible from `phone` to `phone_displayed` and appends a hidden input with `name="phone"` that carries the raw value. The rename is idempotent and the hidden is reused (not duplicated) on re-binding.
17
-
18
- After the rename both names are first-class in the controller's API:
19
-
20
- | API call | Effect |
21
- | --- | --- |
22
- | `getValue('phone')` | Returns the **raw** value. |
23
- | `getValue('phone_displayed')` | Returns the **formatted** value. |
24
- | `getField('phone')` | Returns the **hidden** raw mirror element. |
25
- | `getField('phone_displayed')` | Returns the **visible** input element. |
26
- | `setValue('phone', x)` | Writes the raw; the visible is reformatted automatically. |
27
- | `setValue('phone_displayed', x)` | Writes the formatted; the raw is recomputed automatically. |
28
- | `watch('phone', cb)` | `cb` receives the raw value on every keystroke. |
29
- | `watch('phone_displayed', cb)` | `cb` receives the formatted value on every keystroke. |
30
- | `getData()` | The `FormData` carries both `phone` and `phone_displayed`. |
31
- | `validators.phone` | Validates the **raw** value (the one the backend gets). |
32
-
33
- This is the orthogonal model: every name is a real, queryable slot. You never need to know which is the "real" one — each method returns the value of the name you asked for.
34
-
35
- > **Optional peer dependency.** ESM, CJS, and browser-module builds require the optional `@samline/formatter` peer. If it is missing, the module instance logs one cached `console.error` and asynchronously rolls back affected field pairs; the controller remains usable. The standalone global IIFE bundles the formatter.
36
-
37
- ---
38
-
39
- ## Signature
40
-
41
- ```ts
42
- format(config: FieldFormatConfig): FormController
43
- formatAll(config: FieldFormatConfig): FormController
44
- ```
45
-
46
- ## Parameters
47
-
48
- `config` is a [`FieldFormatConfig`](../typescript.md#fieldformatconfig):
49
-
50
- | Name | Type | Required | Description |
51
- | --- | --- | --- | --- |
52
- | `type` | `'general' \| 'phone' \| 'numeral' \| 'date' \| 'time' \| 'creditCard' \| 'creditCardType'` | yes | One of the supported `FormatType` values exported by `@samline/formatter`. |
53
- | `field` | `string \| string[]` | yes | The **canonical** name of the field — the one the backend reads. The visible you authored in the HTML should already have this name (or the display name, see [Pre-authoring the visible](#pre-authoring-the-visible) below). |
54
- | `displayField` | `string` | no | Name of the visible input for a single `field`. Combining it with a field array logs an error and leaves the form unchanged; arrays derive `${fieldName}_displayed` per field. |
55
- | `options` | `Record<string, unknown>` | no | Format-specific options forwarded to `@samline/formatter`. See the [formatter options reference](https://github.com/samline/formatter/blob/main/docs/options.md). |
56
-
57
- ## Returns
58
-
59
- The same [`FormController`](../typescript.md#formcontroller) — chainable. The method returns the controller unchanged when:
60
-
61
- - the form has already been destroyed,
62
- - there is no bound `HTMLFormElement`,
63
- - `@samline/formatter` is not installed (a single `console.error` is logged in this case, and the visible's name is restored to its canonical form so the form goes back to its original state),
64
- - no field matches `field` (or the display name) in the form.
65
-
66
- ## Behaviour
67
-
68
- 1. The sync part of `format()` renames the visible from the canonical name to the display name and creates / reuses the hidden raw mirror. The controller's API (`getValue`, `getField`, `setValue`, `watch`, `getData`) is fully functional for the field by the time `format()` returns.
69
- 2. The async part loads the formatter peer through a lazy dynamic import. Subsequent calls reuse the cached module so the lookup cost is paid once. If the peer loads, the input listener is bound and the formatter is applied to the current value of the visible (pre-filled data, server-rendered values, etc.) so the hidden mirror is populated without waiting for a user keystroke. If the peer fails to load, the rename is rolled back.
70
- 3. On every keystroke the capture-phase listener runs the formatter and rewrites the visible input **and** the hidden raw mirror. The capture-phase registration guarantees the mirror is up to date before the controller's delegated handler fires, so `watch('phone', cb)` callbacks receive the new raw value on the same tick.
71
- 4. Cursor position is preserved using a cleave-style algorithm (see [Cursor tracking](#cursor-tracking)).
72
- 5. If a hidden input already exists in the form for the same canonical name — either with the `data-formatter-raw-for="<field>"` attribute or as a plain `<input type="hidden" name="<field>">` — `format()` reuses it instead of duplicating it. The previously authored value is preserved across `destroy()` (see [Cleanup](#cleanup)).
73
- 6. Calling `format()` twice for the same canonical name is idempotent: the listener is bound once and the configuration is refreshed in the internal registry.
74
-
75
- ## Initial value interpretation (server pre-fill)
76
-
77
- When the visible field already carries a value at the moment `format()` first runs — typically a server-rendered `value="{{ old('field') }}"` in a Blade template, or a value set by a previous `setValue` call — the controller runs that value through the formatter once before any user interaction. The formatter's input is the visible's value, which on a server pre-fill is the **canonical raw** (e.g. `"19901212"` for a date with a `Ymd` raw), not the display form.
78
-
79
- `@samline/formatter` v2.0.0+ changed its default `interpretInputAs` to `'auto'`, which detects the raw shape by the absence of delimiters and the digit length matching the raw pattern. The controller still injects `interpretInputAs: 'auto'` explicitly for the initial pass and for mirror-source events so the intent is documented in code and the behaviour is robust against a future formatter change that flips the default again.
80
-
81
- To bridge the two layers, the controller:
82
-
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 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
- - 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
-
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
-
90
- ## Pre-authoring the visible
91
-
92
- The default flow is: write `<input name="phone" />` and let `format()` rename it to `phone_displayed`. If you'd rather keep the visible's name stable in your HTML, pre-author it with the display name:
93
-
94
- ```html
95
- <input name="phone_displayed" />
96
- <form id="checkout">…</form>
97
- ```
98
-
99
- ```ts
100
- form('checkout', { formats: { phone: { type: 'phone', field: 'phone' } } })
101
- ```
102
-
103
- `format()` will pick up `<input name="phone_displayed">` as the visible, leave its name alone, and create the hidden with `name="phone"`. Both authoring styles end up with the same DOM.
104
-
105
- ## Hidden mirror semantics
106
-
107
- Every formatted field gets a sibling hidden input that:
108
-
109
- - has `type="hidden"`,
110
- - is named with the canonical `<field>` (the one the backend reads),
111
- - carries `data-formatter-raw-for="<field>"` so the controller can identify it as owned and clean it up on `destroy()`,
112
- - receives the latest `FormatterResult.raw` value on every input event.
113
-
114
- Because the mirror is a real `<input>` inside the form, it shows up automatically in `FormData`, `serialize()`, and `api.getData()`. You can also read or write it via the canonical name in every controller API method.
115
-
116
- ## Cursor tracking
117
-
118
- The helper in [`src/core/format-helpers.ts`](../../src/core/format-helpers.ts) implements a cursor-restore algorithm modelled on cleave.js' `getNextCursorPosition` and `postDelimiterBackspace`:
119
-
120
- - The caret position is captured **before** the value is rewritten.
121
- - After `FormatterResult.formatted` is written, the helper counts the number of non-format characters (digits, ASCII letters) to the left of the original caret and re-positions the caret at the same logical index in the new value, skipping delimiters.
122
- - When the user deletes one character that sits right before a delimiter, the helper pulls the caret back one extra position to mirror cleave's `postDelimiterBackspace` behaviour so the cursor never ends up "jumping" past a delimiter that disappeared.
123
- - On Android, the caret restoration is deferred one animation frame via `requestAnimationFrame` to work around the IME quirk that causes the caret to land at the end on the first frame after a value rewrite.
124
-
125
- ## Cleanup
126
-
127
- `destroy()` calls a dedicated cleanup routine that:
128
-
129
- - removes the `input` listener registered by `format()` (capture-phase, matched on registration),
130
- - restores the visible's name to the canonical form so the DOM goes back to the developer's authored state,
131
- - removes hidden raw mirrors that **this** controller created (mirrors authored before `format()` was called carry no `data-formatter-raw-for` attribute and survive `destroy()`),
132
- - clears the `formattedFields` registry so the controller can be garbage-collected.
133
-
134
- ## Reading both values in one place
135
-
136
- If a watcher needs the raw and the formatted at the same time, the orthogonal model makes that cheap:
137
-
138
- ```ts
139
- checkout.watch('phone', (raw, _field, _form, state) => {
140
- const formatted = state.values.phone_displayed
141
- // raw is the canonical value, formatted is what the user sees.
142
- console.log({ raw, formatted })
143
- })
144
- ```
145
-
146
- Both `state.values.phone` and `state.values.phone_displayed` are populated on every keystroke — pick whichever you need.
147
-
148
- ## Examples
149
-
150
- ### Format a phone field declaratively
151
-
152
- ```ts
153
- import { form } from '@samline/forms'
154
-
155
- const checkout = form('checkout-form', {
156
- formats: {
157
- phone: { type: 'phone', field: 'phone', options: { country: 'MX' } }
158
- }
159
- })
160
-
161
- // After `format()` runs (sync part):
162
- // - the visible is now <input name="phone_displayed">
163
- // - the hidden is <input type="hidden" name="phone">
164
- checkout.getValue('phone') // '' (no user input yet)
165
- checkout.getValue('phone_displayed') // ''
166
- ```
167
-
168
- ### Apply a single configuration to multiple fields with `formatAll`
169
-
170
- ```ts
171
- import { form } from '@samline/forms'
172
-
173
- const pricing = form('pricing-form', {
174
- formats: {
175
- money: {
176
- type: 'numeral',
177
- field: ['price', 'subtotal', 'total'],
178
- options: { prefix: '$', delimiter: ',' }
179
- }
180
- }
181
- })
182
-
183
- // Three different fields, one configuration, one pair per field.
184
- pricing.getField('price_displayed') // visible
185
- pricing.getField('price') // hidden raw mirror
186
- pricing.getField('subtotal_displayed') // visible
187
- pricing.getField('subtotal') // hidden raw mirror
188
- pricing.getField('total_displayed') // visible
189
- pricing.getField('total') // hidden raw mirror
190
- ```
191
-
192
- ### Wire it imperatively after mount
193
-
194
- ```ts
195
- import { form } from '@samline/forms'
196
-
197
- const profile = form('profile-form')
198
- profile.format({ type: 'date', field: 'birthday' })
199
- profile.format({ type: 'creditCard', field: 'card' })
200
- ```
201
-
202
- ### Custom display name
203
-
204
- ```ts
205
- profile.format({ type: 'phone', field: 'phone', displayField: 'phoneVisible' })
206
-
207
- // After `format()` runs:
208
- // - visible: <input name="phoneVisible">
209
- // - hidden: <input type="hidden" name="phone">
210
- ```
211
-
212
- ### React to keystrokes on either name
213
-
214
- ```ts
215
- const checkout = form('checkout-form', {
216
- formats: { phone: { type: 'phone', field: 'phone' } }
217
- })
218
-
219
- // Both watchers fire on the same keystroke. Each receives the
220
- // value of the name it was registered against.
221
- checkout.watch('phone', raw => console.log('backend value:', raw))
222
- checkout.watch('phone_displayed', formatted => console.log('user sees:', formatted))
223
- ```
224
-
225
- ### Read both values at submit time
226
-
227
- ```ts
228
- checkout.onSubmit((_form, data, formData) => {
229
- // data and formData carry both names:
230
- // phone = '5512345678' (raw)
231
- // phone_displayed = '55 1234 5678' (formatted)
232
- // The backend only needs `phone`.
233
- fetch('/api/checkout', { method: 'POST', body: formData })
234
- })
235
- ```
236
-
237
- ### Fallback when the peer is missing
238
-
239
- ```ts
240
- // @samline/formatter is NOT installed
241
- const profile = form('profile-form')
242
- profile.format({ type: 'phone', field: 'phone' })
243
- // console.error:
244
- // [samline/forms] The `format()` and `formatAll()` methods require the
245
- // @samline/formatter package. Install it with: npm i @samline/formatter ...
246
- // The visible is left with its original name (`phone`), no hidden
247
- // mirror is created, no listener is bound — the form is exactly
248
- // as the developer authored it. `profile.format(...)` still
249
- // returned the controller, so chaining works.
250
- profile.setValue('phone', '...').validate()
251
- ```
252
-
253
- ## Edge cases
254
-
255
- - **Non-text inputs are skipped.** `format()` ignores checkboxes, radios, files, submit buttons, and buttons. Calling it for one of those field names is a no-op (no listener, no mirror).
256
- - **Same field across multiple controllers.** Each `FormController` instance owns its own mirrors, so two controllers bound to different forms can format a `phone` field without the raw inputs colliding.
257
- - **Field pre-filled from the server.** If the visible field already carries a value when `format()` runs, the formatter is applied immediately (after the peer resolves) and the mirror is populated without waiting for a user keystroke.
258
- - **`setValue('phone', x)` and `setValue('phone_displayed', x)` both reformat the other side.** Each `setValue` writes its target, dispatches a synthetic `input` event, and the capture-phase listener reformats the partner.
259
- - **`validators.phone` runs against the raw value.** The raw is the value the backend ultimately receives, so the validator is the gatekeeper for backend acceptance.
260
- - **`destroy()` is idempotent.** Calling `destroy()` after `format()` was never wired leaves the form untouched; calling it after `format()` was wired removes only the mirrors owned by the current controller and restores the visible's name.
261
-
262
- ## Related
263
-
264
- - [`getValue`](get-value.md) — read either the formatted or the raw value (orthogonal).
265
- - [`getField`](get-field.md) — grab the underlying DOM elements (visible or hidden).
266
- - [`setValue`](set-value.md) — write either the raw or the formatted value (the other side is reformatted automatically).
267
- - [`watch`](watch.md) / [`observe`](observe.md) — react to changes on either name; each callback receives the value of the name it was registered against.
268
- - [`getData`](get-data.md) — the serialized payload carries both names.
269
- - [`destroy`](destroy.md) — releases listeners, the mutation observer, owned raw mirrors, and restores the visible's name.
270
- - [Optional peer dependency `@samline/formatter`](https://github.com/samline/formatter)
@@ -1,90 +0,0 @@
1
- # `getData()`
2
-
3
- Returns the form serialized as both a plain object and a fresh `FormData` instance. Empty file inputs are filtered out.
4
-
5
- ## Signature
6
-
7
- ```ts
8
- getData(): SerializedFormResult
9
- ```
10
-
11
- ```ts
12
- interface SerializedFormResult {
13
- data: Record<string, SerializedFormValue>
14
- formData: FormData
15
- }
16
- ```
17
-
18
- ## Parameters
19
-
20
- None.
21
-
22
- ## Returns
23
-
24
- A [`SerializedFormResult`](../typescript.md#serializedformresult):
25
-
26
- - `data` — plain object mirror of the form. Repeated names become arrays. Strings and `File` instances are preserved as-is.
27
- - `formData` — a fresh `FormData` instance, safe to send directly to `fetch`.
28
-
29
- ## Behaviour
30
-
31
- - Reads directly from the live DOM via `new FormData(formElement)`.
32
- - Drops entries where the value is an empty `File` (`size === 0` and `name === ''`) — these are produced by empty `<input type="file">` controls.
33
- - Builds the `data` mirror while iterating the raw `FormData`, grouping repeated names into arrays.
34
-
35
- ## Examples
36
-
37
- ### Send to a JSON endpoint
38
-
39
- ```ts
40
- import { form } from '@samline/forms'
41
-
42
- const contact = form('contact-form')
43
-
44
- contact.onSubmit(async (_element, data) => {
45
- await fetch('/api/contact', {
46
- method: 'POST',
47
- headers: { 'Content-Type': 'application/json' },
48
- body: JSON.stringify(data)
49
- })
50
- })
51
- ```
52
-
53
- ### Send a real `FormData` to a file-upload endpoint
54
-
55
- ```ts
56
- import { form } from '@samline/forms'
57
-
58
- const upload = form('upload-form')
59
-
60
- upload.onSubmit(async (_element, _data, formData) => {
61
- await fetch('/api/upload', { method: 'POST', body: formData })
62
- })
63
- ```
64
-
65
- ### Snapshot the form state without submitting
66
-
67
- ```ts
68
- const profile = form('profile-form')
69
-
70
- profile.subscribe(state => {
71
- if (state.submitCount > 0) {
72
- const { data } = profile.getData()
73
- console.log('last submit payload:', data)
74
- }
75
- })
76
- ```
77
-
78
- ## Edge cases
79
-
80
- - **Empty file inputs are filtered out of both `data` and `formData`.** This is intentional — HTML produces placeholder `File` entries when a file input is rendered but no file is selected.
81
- - **The returned `data` mirror is fresh** on every call. Safe to mutate.
82
- - **`getData()` does not trigger validation, watchers, or autoSubmit** — it is a pure read.
83
- - **The plain object form is convenient for JSON endpoints; the `FormData` form is required for file uploads.** Browsers will not serialize `File` instances into JSON.
84
-
85
- ## Related
86
-
87
- - [`parseFormData`](../api/parse-form-data.md) — same serializer, used directly without a controller.
88
- - [`getValue`](get-value.md) — read a single field’s value.
89
- - [`onSubmit`](../api/on-submit.md) — receives the same shape as arguments.
90
- - [`format`](format.md) — when a field is formatted, the `FormData` carries both the canonical name (with the raw value) and the `<field>_displayed` name (with the formatted value). See [the mirror convention](format.md#the-mirror-convention).
@@ -1,87 +0,0 @@
1
- # `getField(name)`
2
-
3
- Returns the underlying DOM field(s) for a given name. Useful when you need direct DOM access — for example, to focus a field, attach a third-party widget, or read custom attributes.
4
-
5
- ## Signature
6
-
7
- ```ts
8
- getField(name: string): FormFieldElement | FormFieldElement[] | null
9
- ```
10
-
11
- ```ts
12
- type FormFieldElement =
13
- | HTMLInputElement
14
- | HTMLSelectElement
15
- | HTMLTextAreaElement
16
- ```
17
-
18
- ## Parameters
19
-
20
- | Name | Type | Required | Description |
21
- | --- | --- | --- | --- |
22
- | `name` | `string` | yes | The `name` attribute of the target field. |
23
-
24
- ## Returns
25
-
26
- - A single `FormFieldElement` when exactly one matching field exists.
27
- - An array of `FormFieldElement` when multiple fields share the same `name` (radios, checkbox groups).
28
- - `null` when no field with that `name` exists.
29
-
30
- ## Behaviour
31
-
32
- - The lookup is performed against the live DOM, including dynamically added fields (the `MutationObserver` keeps the cache fresh).
33
- - The returned element(s) are live references — mutations you make to them are reflected in the form, but they bypass the controller’s normal pipeline. If you want watchers and validation to run, prefer [`setValue`](set-value.md).
34
-
35
- ## Examples
36
-
37
- ### Focus a field after submit
38
-
39
- ```ts
40
- import { form } from '@samline/forms'
41
-
42
- const profile = form('profile-form')
43
-
44
- profile.onSubmit(async (_element, _data, formData) => {
45
- const response = await fetch('/profile', { method: 'POST', body: formData })
46
-
47
- if (response.status === 422) {
48
- const field = profile.getField('email') as HTMLInputElement | null
49
- field?.focus()
50
- }
51
- })
52
- ```
53
-
54
- ### Iterate a radio group
55
-
56
- ```ts
57
- const shipping = form('shipping-form')
58
-
59
- const radios = shipping.getField('method')
60
-
61
- if (Array.isArray(radios)) {
62
- for (const radio of radios) {
63
- radio.addEventListener('change', () => console.log(radio.value))
64
- }
65
- }
66
- ```
67
-
68
- ### Read a custom data attribute
69
-
70
- ```ts
71
- const profile = form('profile-form')
72
-
73
- const field = profile.getField('avatar') as HTMLInputElement | null
74
- const maxSize = Number(field?.dataset.maxSize ?? '0')
75
- ```
76
-
77
- ## Edge cases
78
-
79
- - **`null` means “no field with that name”** — distinct from `[]` which means “no matching fields but the lookup ran”.
80
- - **The array form is only used when more than one field matches.** A single checkbox that happens to be a group of one is still returned as a single element, not an array.
81
- - **`getField` does not throw** when the controller has no bound form — it returns `null`.
82
-
83
- ## Related
84
-
85
- - [`getValue`](get-value.md) — read the value.
86
- - [`setValue`](set-value.md) — write a value through the controller.
87
- - [`format`](format.md) — when a field is formatted, `getField('<field>')` returns the hidden raw mirror and `getField('<field>_displayed')` returns the visible input. See [the mirror convention](format.md#the-mirror-convention).
@@ -1,111 +0,0 @@
1
- # `getState()`
2
-
3
- Returns a fresh snapshot of the controller state. Pure read — does not mutate anything and does not notify subscribers.
4
-
5
- ## Signature
6
-
7
- ```ts
8
- getState(): FormStateSnapshot
9
- ```
10
-
11
- ```ts
12
- interface FormStateSnapshot {
13
- values: FormValues
14
- errors: FormErrors
15
- filledFields: string[]
16
- isValid: boolean
17
- isValidated: boolean
18
- autoSubmit: boolean
19
- isSubmitting: boolean
20
- submitCount: number
21
- }
22
- ```
23
-
24
- ## Parameters
25
-
26
- None.
27
-
28
- ## Returns
29
-
30
- A [`FormStateSnapshot`](../typescript.md#formstatesnapshot). Every call returns a fresh object — safe to mutate.
31
-
32
- ## Snapshot fields
33
-
34
- | Field | Meaning |
35
- | --- | --- |
36
- | `values` | Current values for every tracked field (validators + named fields in the form). |
37
- | `errors` | Merged validation errors (from rules) and manual errors (from [`setErrors`](set-errors.md)). |
38
- | `filledFields` | Names of fields that have a non-empty value. |
39
- | `isValid` | `true` when `errors` has no entries. |
40
- | `isValidated` | `true` after the initial auto-validation pass or once [`validate`](validate.md) has run. |
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. |
43
- | `submitCount` | Number of submit attempts (valid or invalid). |
44
-
45
- ## Examples
46
-
47
- ### Read the current state
48
-
49
- ```ts
50
- import { form } from '@samline/forms'
51
-
52
- const profile = form('profile-form')
53
-
54
- const state = profile.getState()
55
-
56
- console.log(state.values.email)
57
- console.log(state.errors)
58
- console.log(state.isValid)
59
- console.log(state.isSubmitting)
60
- ```
61
-
62
- ### Drive an accessible error message
63
-
64
- ```ts
65
- const profile = form('profile-form')
66
-
67
- profile.element?.addEventListener('input', () => {
68
- const state = profile.getState()
69
- emailError.hidden = !state.errors.email
70
- emailError.textContent = state.errors.email?.[0] ?? ''
71
- })
72
- ```
73
-
74
- The controller manages `aria-invalid`; use state to manage the message referenced by `aria-describedby`.
75
-
76
- ### Snapshot on submit
77
-
78
- ```ts
79
- const profile = form('profile-form')
80
-
81
- profile.onSubmit((_form, _data, _formData, state) => {
82
- console.log('submit #', state.submitCount)
83
- console.log('values at submit:', state.values)
84
- console.log('errors at submit:', state.errors)
85
- })
86
- ```
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
-
100
- ## Edge cases
101
-
102
- - **`getState()` does not notify subscribers.** If you need a notification-driven view, use [`subscribe`](subscribe.md).
103
- - **`errors` is the merged map** (validation + manual). Use [`validate`](validate.md) if you need just the validation errors.
104
- - **`filledFields` reflects the value snapshot at the time of the call** — it is not stored between calls.
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.
107
-
108
- ## Related
109
-
110
- - [`subscribe`](subscribe.md) — receive state updates reactively.
111
- - [`getData`](get-data.md) — serialize the form payload.
@@ -1,114 +0,0 @@
1
- # `getValue(name)`
2
-
3
- Reads the current value of a field, normalized for the field type.
4
-
5
- ## Signature
6
-
7
- ```ts
8
- getValue(name: string): FormFieldValue
9
- ```
10
-
11
- ```ts
12
- type FormFieldValue = string | string[] | File[] | undefined
13
- ```
14
-
15
- ## Parameters
16
-
17
- | Name | Type | Required | Description |
18
- | --- | --- | --- | --- |
19
- | `name` | `string` | yes | The `name` attribute of the target field. |
20
-
21
- ## Returns
22
-
23
- The normalized value:
24
-
25
- - `string` for text inputs, textareas, single selects, the single checked radio, or a single checked checkbox.
26
- - `string[]` for checkbox groups with multiple selections.
27
- - `string[]` for any field whose `name` ends in `[]` and that matches more than one input — the values of every matching input, in DOM order.
28
- - `string[]` for the selected options of `<select multiple>`.
29
- - `File[]` for `<input type="file">` (may be empty), or the concatenation of every selected file across all inputs that share a `name` ending in `[]`.
30
- - `undefined` when no field with that `name` exists.
31
-
32
- ## Behaviour by field type
33
-
34
- | Field type | Returned value |
35
- | --- | --- |
36
- | `<input>` (text-like) | `field.value`. |
37
- | `<input>` (text-like) with `name="foo[]"` and more than one input | `[input[0].value, input[1].value, ...]` — one entry per matching input, in DOM order. |
38
- | `<input type="radio">` (group) | The `value` of the checked radio, or `''` when none is checked. The `[]` suffix is ignored for radio — radio groups are always single-valued. |
39
- | `<input type="checkbox">` (single) | `field.value` when checked, `''` when unchecked. |
40
- | `<input type="checkbox">` (group) | `value` when exactly one is checked, `[value1, value2, ...]` when multiple are checked, `''` when none is checked. |
41
- | `<input type="checkbox">` (group with `name="foo[]"`) | `[value1, value2, ...]` of every checked input. Always an array — no single-value collapse — so `Array.isArray(value)` validators stay correct. |
42
- | `<input type="file">` | `Array.from(field.files)`. |
43
- | `<input type="file">` (group with `name="foo[]"`) | The concatenation of `Array.from(input.files)` across every matching input, in DOM order. |
44
- | `<select>` | `field.value`. |
45
- | `<select multiple>` | Values of every selected option. |
46
- | `<select>` (group with `name="foo[]"`) | `[select[0].value, select[1].value, ...]`. |
47
- | `<textarea>` | `field.value`. |
48
- | `<textarea>` (group with `name="foo[]"`) | `[textarea[0].value, textarea[1].value, ...]`. |
49
-
50
- ## Examples
51
-
52
- ### Read a text value
53
-
54
- ```ts
55
- import { form } from '@samline/forms'
56
-
57
- const contact = form('contact-form')
58
- contact.setValue('email', 'hello@example.com')
59
-
60
- contact.getValue('email') // 'hello@example.com'
61
- ```
62
-
63
- ### Read a checkbox group
64
-
65
- ```ts
66
- const signup = form('signup-form')
67
-
68
- signup.setValue('interests', ['design', 'code'])
69
-
70
- signup.getValue('interests') // ['design', 'code']
71
-
72
- signup.setValue('interests', ['design'])
73
-
74
- signup.getValue('interests') // 'design' (single string)
75
- ```
76
-
77
- ### Read a file input
78
-
79
- ```ts
80
- const upload = form('upload-form')
81
-
82
- const files = upload.getValue('attachments')
83
- if (Array.isArray(files) && files.length > 0) {
84
- console.log('first file:', files[0]!.name)
85
- }
86
- ```
87
-
88
- ### Read a `name="foo[]"` group
89
-
90
- ```ts
91
- const signup = form('signup-form')
92
-
93
- signup.getValue('docusign_email[]') // ['a@example.com', 'b@example.com']
94
-
95
- // The `[]` suffix is the de facto multi-value input convention used by
96
- // PHP, Rails, Express, Spring, etc. on the server. It tells the
97
- // controller to collect every matching input as an array, so custom
98
- // validators can rely on `Array.isArray(value)`.
99
- ```
100
-
101
- ## Edge cases
102
-
103
- - **`getValue` is a pure read.** It does not mutate the DOM, does not notify subscribers, and does not trigger validation.
104
- - **`undefined` vs `''`** — `undefined` means “no field with that name”. `''` means “the field exists but is empty”.
105
- - **`getValue('radio-name')` returns `''` when no radio in the group is checked.** Treat empty string as “no selection”.
106
- - **The `name="foo[]"` suffix is the explicit opt-in for multi-value inputs.** A field with the suffix but a single matching input still returns a plain string (not a one-element array). Bare names — `name="foo"` with multiple inputs — keep the original first-value semantics and are not collapsed into an array.
107
- - **Whitespace is not trimmed.** If you need trimming, do it in your own code or in a custom validator.
108
-
109
- ## Related
110
-
111
- - [`setValue`](set-value.md) — write a value.
112
- - [`getField`](get-field.md) — read the underlying DOM element(s).
113
- - [`getData`](get-data.md) — serialize the whole form.
114
- - [`format`](format.md) — when a field is formatted, `getValue('<field>')` returns the raw value and `getValue('<field>_displayed')` returns the formatted value. See [the mirror convention](format.md#the-mirror-convention).