@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
package/docs/recipes.md DELETED
@@ -1,531 +0,0 @@
1
- # Recipes
2
-
3
- End-to-end patterns that combine multiple methods. Each recipe is a self-contained example you can drop into a real project.
4
-
5
- ---
6
-
7
- ## 1. Login form with `fetch`
8
-
9
- ```html
10
- <form id="login-form">
11
- <input name="email" type="email" autocomplete="email" required />
12
- <input name="password" type="password" autocomplete="current-password" required />
13
- <button type="submit">Sign in</button>
14
- </form>
15
- ```
16
-
17
- ```ts
18
- import { form } from '@samline/forms'
19
-
20
- const login = form('login-form', {
21
- validators: {
22
- email: {
23
- required: { value: true, message: 'Email is required.' },
24
- pattern: {
25
- value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
26
- message: 'Enter a valid email.'
27
- }
28
- },
29
- password: { required: true, minLength: 8 }
30
- }
31
- })
32
-
33
- login.onSubmit(async (_element, _data, formData) => {
34
- const response = await fetch('/api/login', {
35
- method: 'POST',
36
- body: formData
37
- })
38
-
39
- if (!response.ok) {
40
- login.setErrors({ password: ['Wrong email or password.'] })
41
- return
42
- }
43
-
44
- window.location.assign('/dashboard')
45
- })
46
- ```
47
-
48
- What this does:
49
-
50
- - Validates with custom messages.
51
- - Intercepts the submit (default `preventDefault: true`) so the browser does not navigate.
52
- - Surfaces a single error on the password field when the API rejects the credentials.
53
-
54
- ---
55
-
56
- ## 2. Surface server-side validation errors
57
-
58
- ```ts
59
- import { form } from '@samline/forms'
60
-
61
- const profile = form('profile-form', {
62
- autoValidate: false,
63
- clearManualErrorsOnChange: false,
64
- validators: {
65
- email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
66
- }
67
- })
68
-
69
- profile.onSubmit(async (_element, _data, formData) => {
70
- const response = await fetch('/api/profile', {
71
- method: 'POST',
72
- body: formData
73
- })
74
-
75
- if (response.status === 422) {
76
- const { fieldErrors } = (await response.json()) as {
77
- fieldErrors: Record<string, string[]>
78
- }
79
- profile.setErrors(fieldErrors)
80
- return
81
- }
82
-
83
- if (response.ok) {
84
- profile.clearErrors()
85
- profile.reset()
86
- }
87
- })
88
- ```
89
-
90
- Why `clearManualErrorsOnChange: false`: the server’s errors should stay visible while the user fixes them.
91
-
92
- ---
93
-
94
- ## 3. Multi-step / wizard
95
-
96
- ```html
97
- <form id="wizard">
98
- <fieldset data-step="1">
99
- <input name="name" />
100
- <input name="email" type="email" />
101
- </fieldset>
102
- <fieldset data-step="2" hidden>
103
- <input name="address" />
104
- <input name="city" />
105
- </fieldset>
106
- <button type="button" id="next">Next</button>
107
- <button type="submit" id="submit" hidden>Submit</button>
108
- </form>
109
- ```
110
-
111
- ```ts
112
- import { form } from '@samline/forms'
113
-
114
- const wizard = form('wizard', {
115
- validators: {
116
- name: { required: true },
117
- email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
118
- address: { required: true },
119
- city: { required: true }
120
- }
121
- })
122
-
123
- const stepFields: Record<1 | 2, string[]> = {
124
- 1: ['name', 'email'],
125
- 2: ['address', 'city']
126
- }
127
-
128
- let currentStep: 1 | 2 = 1
129
-
130
- document.querySelector('#next')?.addEventListener('click', () => {
131
- const ok = wizard.validate(stepFields[currentStep]).isValid
132
- if (!ok) return
133
-
134
- currentStep = 2
135
- wizard.element?.querySelector('[data-step="1"]')?.setAttribute('hidden', '')
136
- wizard.element?.querySelector('[data-step="2"]')?.removeAttribute('hidden')
137
- document.querySelector('#next')!.setAttribute('hidden', '')
138
- document.querySelector('#submit')!.removeAttribute('hidden')
139
- })
140
-
141
- wizard.onSubmit(async (_element, _data, formData) => {
142
- await fetch('/api/wizard', { method: 'POST', body: formData })
143
- })
144
- ```
145
-
146
- Each step validates only its own fields, so the user can progress without being blocked by later fields.
147
-
148
- ---
149
-
150
- ## 4. Autosave drafts
151
-
152
- ```html
153
- <form id="draft">
154
- <input name="title" />
155
- <textarea name="body"></textarea>
156
- </form>
157
- ```
158
-
159
- ```ts
160
- import { form } from '@samline/forms'
161
-
162
- const draft = form('draft', {
163
- autoSubmit: { debounce: 800 }
164
- })
165
-
166
- draft.onSubmit(async (_element, _data, formData) => {
167
- await fetch('/api/draft', { method: 'POST', body: formData })
168
- })
169
- ```
170
-
171
- For a JSON endpoint, switch the body to `JSON.stringify(data)` and set the right `Content-Type` header.
172
-
173
- To pause autosave while the user is typing into a “bulk edit” textarea, toggle it explicitly:
174
-
175
- ```ts
176
- const bodyField = draft.getField('body') as HTMLTextAreaElement | null
177
-
178
- bodyField?.addEventListener('focus', () => draft.disableAutoSubmit())
179
- bodyField?.addEventListener('blur', () => draft.autoSubmit({ debounce: 800 }))
180
- ```
181
-
182
- ---
183
-
184
- ## 5. Dynamic fields added at runtime
185
-
186
- ```html
187
- <form id="builder">
188
- <div id="rows"></div>
189
- <button type="button" id="add">Add row</button>
190
- <button type="submit">Save</button>
191
- </form>
192
- ```
193
-
194
- ```ts
195
- import { form } from '@samline/forms'
196
-
197
- const builder = form('builder', {
198
- validators: {
199
- 'rows[].name': {
200
- each: {
201
- required: { value: true, message: 'Every row needs a name.' }
202
- }
203
- }
204
- }
205
- })
206
-
207
- document.querySelector('#add')?.addEventListener('click', () => {
208
- const rows = document.querySelector('#rows')!
209
- const row = document.createElement('div')
210
- row.innerHTML = `
211
- <label>Name <input name="rows[].name" /></label>
212
- <label>Value <input name="rows[].value" /></label>
213
- `
214
- rows.appendChild(row)
215
- })
216
- ```
217
-
218
- Validator keys match literal HTML names, not wildcard paths. Reusing `rows[].name` gives the validator an array once multiple rows exist. `each` validates every row independently, so only an empty row receives `css-error` and `aria-invalid="true"`; `getState().errors['rows[].name']` remains a flat `string[]`. The form-subtree `MutationObserver` clears field lookups and, because the default controller is already validated, re-runs validation when each row is appended.
219
-
220
- ---
221
-
222
- ## 6. Bind to a ref-like target after async mount
223
-
224
- ```ts
225
- import { form } from '@samline/forms'
226
-
227
- const target = { current: null as HTMLFormElement | null }
228
-
229
- const controller = form(target)
230
-
231
- async function mount() {
232
- const formElement = document.createElement('form')
233
- formElement.innerHTML = `<input name="email" type="email" />`
234
- document.querySelector('#app')!.appendChild(formElement)
235
-
236
- target.current = formElement
237
- controller.destroy() // The controller captured `current` at construction time.
238
- form(target)
239
- }
240
-
241
- mount()
242
- ```
243
-
244
- `form()` reads `target.current` once. If the form is mounted later, recreate the controller (or call `destroy()` and re-bind).
245
-
246
- ---
247
-
248
- ## 7. Manual-only validation
249
-
250
- When you want validation to run only when you ask (not on every change), disable `autoValidate` and call `validate()` yourself. Initial `css-filled` state still synchronizes for prefilled controls without running any rules.
251
-
252
- ```ts
253
- import { form } from '@samline/forms'
254
-
255
- const checkout = form('checkout-form', {
256
- autoValidate: false,
257
- validators: {
258
- card: { required: true, minLength: 12 },
259
- expiry: { required: true }
260
- }
261
- })
262
-
263
- document.querySelector('#pay')?.addEventListener('click', () => {
264
- const result = checkout.validate()
265
- if (!result.isValid) {
266
- checkout.setErrors({ card: ['Please review your details.'] })
267
- return
268
- }
269
-
270
- checkout.element?.requestSubmit()
271
- })
272
- ```
273
-
274
- Useful when you want to defer validation until a specific event (e.g. a “Review order” button).
275
-
276
- ---
277
-
278
- ## 8. Keep manual errors until the user explicitly clears them
279
-
280
- ```ts
281
- import { form } from '@samline/forms'
282
-
283
- const profile = form('profile-form', {
284
- clearManualErrorsOnChange: false,
285
- validators: {
286
- email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
287
- }
288
- })
289
-
290
- profile.setErrors({ email: ['This email is already in use.'] })
291
-
292
- // The error stays visible even after the user edits the field.
293
- profile.clearErrors(['email']) // Or clearErrors() to clear everything.
294
- ```
295
-
296
- ---
297
-
298
- ## 9. Native server-rendered submit (Blade, ERB, classic Rails)
299
-
300
- ```html
301
- <form id="profile" action="/profile" method="post">
302
- @csrf
303
- <input name="email" type="email" />
304
- <input name="name" />
305
- <button type="submit">Save</button>
306
- </form>
307
- ```
308
-
309
- ```ts
310
- import { form } from '@samline/forms'
311
-
312
- const profile = form('profile', {
313
- validators: {
314
- email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
315
- }
316
- })
317
-
318
- profile.onSubmit(() => {
319
- console.log('client-side hooks ran')
320
- }, false)
321
- ```
322
-
323
- When validation passes, the browser performs the native form submit to the `action` URL. When validation fails, the submit is prevented and the user sees the inline errors.
324
-
325
- ---
326
-
327
- ## 10. SPA cleanup on route change
328
-
329
- ```ts
330
- import { form } from '@samline/forms'
331
-
332
- const controllers = new Map<string, ReturnType<typeof form>>()
333
-
334
- router.on('/profile', element => {
335
- controllers.set('profile', form(element, { validators: { email: { required: true } } }))
336
- })
337
-
338
- router.off('/profile', () => {
339
- controllers.get('profile')?.destroy()
340
- controllers.delete('profile')
341
- })
342
- ```
343
-
344
- `destroy()` is idempotent — calling it more than once is safe.
345
-
346
- ---
347
-
348
- ## 11. Sync state into a view layer
349
-
350
- ```ts
351
- import { form } from '@samline/forms'
352
-
353
- const signup = form('signup-form', {
354
- validators: {
355
- email: { required: true },
356
- password: { required: true, minLength: 8 }
357
- }
358
- })
359
-
360
- signup.subscribe(state => {
361
- const submit = signup.element?.querySelector<HTMLButtonElement>('button[type="submit"]')
362
- if (submit) submit.disabled = !state.isValid
363
- })
364
- ```
365
-
366
- Use `subscribe` when a single source of truth should drive your UI (button enabled state, live previews, dependent field visibility, etc.).
367
-
368
- ---
369
-
370
- ## 12. Confirm a password with `sameAs`
371
-
372
- ```ts
373
- import { form } from '@samline/forms'
374
-
375
- const signup = form('signup-form', {
376
- validators: {
377
- password: { required: true, minLength: 8 },
378
- password_confirmation: {
379
- required: true,
380
- sameAs: {
381
- value: 'password',
382
- message: 'Passwords do not match.'
383
- }
384
- }
385
- }
386
- })
387
- ```
388
-
389
- Declare the rule on the confirmation field so the mismatch has one owner. The controller automatically revalidates it when `password` changes; do not add reciprocal `sameAs` rules or `watch()` calls unless you intentionally want errors on both fields. Empty values are left to `required`.
390
-
391
- ---
392
-
393
- ## 13. Combine `setValue` with custom validation
394
-
395
- ```ts
396
- import { form } from '@samline/forms'
397
-
398
- const shipping = form('shipping-form', {
399
- validators: {
400
- method: { required: true },
401
- country: {
402
- dependsOn: 'method',
403
- validate: ({ value, values }) => {
404
- if (values.method === 'international' && value !== 'US') return null
405
- if (values.method === 'domestic' && value === 'US') return null
406
- return 'Country and shipping method must be consistent.'
407
- }
408
- }
409
- }
410
- })
411
-
412
- // Programmatic update triggers the delegated pipeline and revalidates country.
413
- shipping.setValue('method', 'international')
414
- ```
415
-
416
- Custom validators receive the full values map, so cross-field rules read like plain JavaScript. `dependsOn` tells the controller that `country` must be revalidated whenever `method` changes; exact-name dependency chains and cycles are deduplicated.
417
-
418
- ---
419
-
420
- ## 14. Format inputs with `@samline/formatter`
421
-
422
- When you need phone masks, credit-card grouping, numeral delimiters, or any other input formatting, pair `@samline/forms` with `@samline/formatter`. The `format()` and `formatAll()` methods bind a formatter pipeline to one or many fields, manage the hidden raw mirror, and preserve the caret like cleave.js.
423
-
424
- ```html
425
- <form id="checkout">
426
- <input name="phone" autocomplete="tel" />
427
- <input name="card" autocomplete="cc-number" />
428
- <input name="amount" inputmode="decimal" />
429
- </form>
430
- ```
431
-
432
- ```ts
433
- import { form } from '@samline/forms'
434
-
435
- const checkout = form('checkout', {
436
- formats: {
437
- phone: { type: 'phone', field: 'phone', options: { country: 'MX' } },
438
- card: { type: 'creditCard', field: 'card' },
439
- amount: { type: 'numeral', field: 'amount', options: { prefix: '$' } }
440
- }
441
- })
442
-
443
- checkout.onSubmit((_form, _data, formData) => {
444
- // formData carries the raw value at the canonical name and the
445
- // formatted value at the display name. The backend only needs
446
- // the canonical (raw) keys:
447
- // phone = '5512345678'
448
- // phone_displayed = '55 1234 5678'
449
- // card = '4111111111111111'
450
- // card_displayed = '4111 1111 1111 1111'
451
- // amount = '1234'
452
- // amount_displayed= '$1,234'
453
- fetch('/api/checkout', { method: 'POST', body: formData })
454
- })
455
- ```
456
-
457
- Things to know:
458
-
459
- - You write the HTML with the **canonical** name only (e.g. `<input name="phone" />`). `format()` renames the visible to `phone_displayed` on first run and appends a hidden `<input type="hidden" name="phone">` that holds the raw value. The hidden carries `data-formatter-raw-for="phone"` so the controller can identify it as owned and remove it on `destroy()`. The hidden is appended to the form root — outside any label wrapper. The controller never writes the visual attributes (`css-filled` / `css-error`) to the hidden; it always targets the visible, so the `:has()` recipe in [CSS styling](css-styling.md) keeps working for formatted fields.
460
- - Both names are first-class in the controller's API. `getValue('phone')` returns the raw, `getValue('phone_displayed')` returns the formatted, `watch('phone', cb)` fires with the raw, and so on. See the [full mirror contract in `format.md`](api/format.md#the-mirror-convention).
461
- - If you authored your own `<input type="hidden" name="<field>">` (or `<input data-formatter-raw-for="<field>">`) before wiring the controller, `format()` reuses it instead of duplicating it. Pre-existing mirrors survive `destroy()`; mirrors created by `format()` are removed.
462
- - If you prefer to pre-author the visible with the display name (e.g. `<input name="phone_displayed" />`) instead of letting `format()` rename it, `format()` picks that up and skips the rename. Both authoring styles end up with the same DOM.
463
- - Use `formatAll({ type, field: ['a', 'b', 'c'], options })` to bind the same configuration to several fields in one call. Each field gets its own pair (`a` + `a_displayed`, `b` + `b_displayed`, `c` + `c_displayed`).
464
- - Date/time initial values and canonical mirror writes use the formatter's `auto` interpretation; live visible-input events and re-binds use `display`. This preserves server-prefilled raw values without misreading a full-length delimiter-less paste in the visible field. An explicit `options.interpretInputAs` always wins.
465
- - `@samline/formatter` is an **optional peer dependency** for module builds. When it is missing, the module instance logs one cached `console.error` and asynchronously restores affected fields so the rest of the form keeps working. The standalone global IIFE bundles formatter behavior.
466
-
467
- ---
468
-
469
- ## 15. Validate numeric ranges
470
-
471
- ```ts
472
- import { form } from '@samline/forms'
473
-
474
- const invoice = form('invoice-form', {
475
- validators: {
476
- amount: {
477
- required: true,
478
- numeric: { value: true, message: 'Enter an unformatted decimal amount.' },
479
- min: { value: 0, message: 'Amount cannot be negative.' },
480
- max: { value: 100000, message: 'Amount is above the limit.' }
481
- }
482
- }
483
- })
484
- ```
485
-
486
- The bounds are inclusive. Ordinary signed decimals such as `-1`, `+2.5`, `.75`, and `10.` are accepted; exponent notation, hexadecimal, `Infinity`, `NaN`, and values containing formatting separators are rejected. Empty input skips numeric rules, so use `required` when it must fail.
487
-
488
- ---
489
-
490
- ## 16. Track async submission state
491
-
492
- ```ts
493
- const profile = form('profile-form')
494
-
495
- profile.subscribe(state => {
496
- saveButton.disabled = state.isSubmitting
497
- saveButton.textContent = state.isSubmitting ? 'Saving...' : 'Save'
498
- })
499
-
500
- profile.onSubmit(async (_form, _data, formData) => {
501
- await fetch('/api/profile', { method: 'POST', body: formData })
502
- })
503
- ```
504
-
505
- Multiple async handlers start in registration order without awaiting one another. Overlapping submissions keep `isSubmitting` true until all tracked work settles. Rejections settle the state and are not converted into validation errors.
506
-
507
- ---
508
-
509
- ## 17. Tie third-party resources to controller cleanup
510
-
511
- ```ts
512
- const checkout = form('checkout-form')
513
- const abortController = new AbortController()
514
-
515
- window.addEventListener('resize', updateCheckoutLayout, {
516
- signal: abortController.signal
517
- })
518
-
519
- checkout.addCleanup(() => abortController.abort())
520
- ```
521
-
522
- Active cleanups run once in reverse registration order on `destroy()`. The returned unregister function removes only its own registration, duplicate callback registrations remain independent, and a cleanup registered after destruction runs immediately.
523
-
524
- ---
525
-
526
- ## Next steps
527
-
528
- - Need a full options reference? See [docs/options.md](options.md).
529
- - Looking up an exact method signature? See [docs/api/index.md](api/index.md).
530
- - Working with TypeScript types? See [docs/typescript.md](typescript.md).
531
- - Setting up styling for `css-filled` / `css-error`? See [docs/css-styling.md](css-styling.md).