@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.
- package/CHANGELOG.md +316 -0
- package/README.md +119 -119
- package/dist/browser/global.d.ts +50 -16
- package/dist/browser/global.global.js +12 -6188
- package/dist/browser/global.global.js.map +1 -1
- package/dist/browser/index.cjs +340 -84
- package/dist/browser/index.cjs.map +1 -1
- package/dist/browser/index.d.cts +1 -1
- package/dist/browser/index.d.ts +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/{chunk-MMHX34WB.js → chunk-6MA5TF6R.js} +341 -85
- package/dist/chunk-6MA5TF6R.js.map +1 -0
- package/dist/index.cjs +340 -84
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/{registry-GzopYPCR.d.ts → registry-CPe_lf8-.d.cts} +50 -4
- package/dist/{registry-GzopYPCR.d.cts → registry-CPe_lf8-.d.ts} +50 -4
- package/package.json +39 -8
- package/dist/chunk-MMHX34WB.js.map +0 -1
- package/docs/api/add-cleanup.md +0 -52
- package/docs/api/append.md +0 -96
- package/docs/api/auto-submit.md +0 -91
- package/docs/api/clear-errors.md +0 -69
- package/docs/api/destroy.md +0 -80
- package/docs/api/disable-auto-submit.md +0 -65
- package/docs/api/element.md +0 -78
- package/docs/api/form.md +0 -156
- package/docs/api/format.md +0 -270
- package/docs/api/get-data.md +0 -90
- package/docs/api/get-field.md +0 -87
- package/docs/api/get-state.md +0 -111
- package/docs/api/get-value.md +0 -114
- package/docs/api/index.md +0 -55
- package/docs/api/observe.md +0 -126
- package/docs/api/on-submit.md +0 -94
- package/docs/api/parse-form-data.md +0 -94
- package/docs/api/prefill.md +0 -89
- package/docs/api/regex.md +0 -155
- package/docs/api/reset.md +0 -61
- package/docs/api/revalidate.md +0 -52
- package/docs/api/set-errors.md +0 -97
- package/docs/api/set-value.md +0 -120
- package/docs/api/subscribe.md +0 -107
- package/docs/api/unwatch.md +0 -74
- package/docs/api/validate-field-value.md +0 -144
- package/docs/api/validate-values.md +0 -103
- package/docs/api/validate.md +0 -102
- package/docs/api/watch.md +0 -117
- package/docs/browser.md +0 -156
- package/docs/css-styling.md +0 -191
- package/docs/getting-started.md +0 -222
- package/docs/options.md +0 -326
- package/docs/recipes.md +0 -531
- package/docs/typescript.md +0 -615
- package/docs/vanilla.md +0 -12
- /package/{LICENSE → LICENSE.md} +0 -0
package/docs/api/format.md
DELETED
|
@@ -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)
|
package/docs/api/get-data.md
DELETED
|
@@ -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).
|
package/docs/api/get-field.md
DELETED
|
@@ -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).
|
package/docs/api/get-state.md
DELETED
|
@@ -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.
|
package/docs/api/get-value.md
DELETED
|
@@ -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).
|