@samline/forms 2.3.4 → 2.6.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 (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +8 -7
  3. package/dist/browser/global.d.ts +10 -16
  4. package/dist/browser/global.global.js +328 -51
  5. package/dist/browser/global.global.js.map +1 -1
  6. package/dist/browser/index.cjs +1309 -0
  7. package/dist/browser/index.cjs.map +1 -0
  8. package/dist/browser/index.d.cts +14 -0
  9. package/dist/browser/index.d.ts +14 -0
  10. package/dist/browser/index.js +23 -0
  11. package/dist/browser/index.js.map +1 -0
  12. package/dist/chunk-L4GRKSQ5.js +1270 -0
  13. package/dist/chunk-L4GRKSQ5.js.map +1 -0
  14. package/dist/index.cjs +143 -25
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +4 -158
  17. package/dist/index.d.ts +4 -158
  18. package/dist/index.js +7 -1141
  19. package/dist/index.js.map +1 -1
  20. package/dist/registry-BpqJetAt.d.cts +148 -0
  21. package/dist/registry-BpqJetAt.d.ts +148 -0
  22. package/docs/api/append.md +96 -0
  23. package/docs/api/auto-submit.md +91 -0
  24. package/docs/api/clear-errors.md +69 -0
  25. package/docs/api/destroy.md +65 -0
  26. package/docs/api/disable-auto-submit.md +65 -0
  27. package/docs/api/element.md +78 -0
  28. package/docs/api/form.md +154 -0
  29. package/docs/api/format.md +270 -0
  30. package/docs/api/get-data.md +90 -0
  31. package/docs/api/get-field.md +87 -0
  32. package/docs/api/get-state.md +95 -0
  33. package/docs/api/get-value.md +114 -0
  34. package/docs/api/index.md +54 -0
  35. package/docs/api/observe.md +126 -0
  36. package/docs/api/on-submit.md +90 -0
  37. package/docs/api/parse-form-data.md +94 -0
  38. package/docs/api/prefill.md +89 -0
  39. package/docs/api/regex.md +155 -0
  40. package/docs/api/reset.md +60 -0
  41. package/docs/api/revalidate.md +50 -0
  42. package/docs/api/set-errors.md +97 -0
  43. package/docs/api/set-value.md +120 -0
  44. package/docs/api/subscribe.md +91 -0
  45. package/docs/api/unwatch.md +74 -0
  46. package/docs/api/validate-field-value.md +108 -0
  47. package/docs/api/validate-values.md +100 -0
  48. package/docs/api/validate.md +100 -0
  49. package/docs/api/watch.md +117 -0
  50. package/docs/browser.md +156 -0
  51. package/docs/css-styling.md +187 -0
  52. package/docs/getting-started.md +217 -0
  53. package/docs/options.md +259 -0
  54. package/docs/recipes.md +477 -0
  55. package/docs/typescript.md +576 -0
  56. package/docs/vanilla.md +12 -0
  57. package/package.json +60 -16
@@ -0,0 +1,477 @@
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
+ required: true,
201
+ validate: ({ value }) => {
202
+ const names = Array.isArray(value) ? value : [value]
203
+ return names.every(name => typeof name === 'string' && name.trim() !== '')
204
+ ? null
205
+ : 'Every row needs a name.'
206
+ }
207
+ }
208
+ }
209
+ })
210
+
211
+ document.querySelector('#add')?.addEventListener('click', () => {
212
+ const rows = document.querySelector('#rows')!
213
+ const row = document.createElement('div')
214
+ row.innerHTML = `
215
+ <label>Name <input name="rows[].name" /></label>
216
+ <label>Value <input name="rows[].value" /></label>
217
+ `
218
+ rows.appendChild(row)
219
+ })
220
+ ```
221
+
222
+ Validator keys match literal HTML names, not wildcard paths. Reusing `rows[].name` gives the validator an array once multiple rows exist. The form-subtree `MutationObserver` clears field lookups and, because the default controller is already validated, re-runs validation when each row is appended.
223
+
224
+ ---
225
+
226
+ ## 6. Bind to a ref-like target after async mount
227
+
228
+ ```ts
229
+ import { form } from '@samline/forms'
230
+
231
+ const target = { current: null as HTMLFormElement | null }
232
+
233
+ const controller = form(target)
234
+
235
+ async function mount() {
236
+ const formElement = document.createElement('form')
237
+ formElement.innerHTML = `<input name="email" type="email" />`
238
+ document.querySelector('#app')!.appendChild(formElement)
239
+
240
+ target.current = formElement
241
+ controller.destroy() // The controller captured `current` at construction time.
242
+ form(target)
243
+ }
244
+
245
+ mount()
246
+ ```
247
+
248
+ `form()` reads `target.current` once. If the form is mounted later, recreate the controller (or call `destroy()` and re-bind).
249
+
250
+ ---
251
+
252
+ ## 7. Manual-only validation
253
+
254
+ When you want validation to run only when you ask (not on every change), disable `autoValidate` and call `validate()` yourself.
255
+
256
+ ```ts
257
+ import { form } from '@samline/forms'
258
+
259
+ const checkout = form('checkout-form', {
260
+ autoValidate: false,
261
+ validators: {
262
+ card: { required: true, minLength: 12 },
263
+ expiry: { required: true }
264
+ }
265
+ })
266
+
267
+ document.querySelector('#pay')?.addEventListener('click', () => {
268
+ const result = checkout.validate()
269
+ if (!result.isValid) {
270
+ checkout.setErrors({ card: ['Please review your details.'] })
271
+ return
272
+ }
273
+
274
+ checkout.element?.requestSubmit()
275
+ })
276
+ ```
277
+
278
+ Useful when you want to defer validation until a specific event (e.g. a “Review order” button).
279
+
280
+ ---
281
+
282
+ ## 8. Keep manual errors until the user explicitly clears them
283
+
284
+ ```ts
285
+ import { form } from '@samline/forms'
286
+
287
+ const profile = form('profile-form', {
288
+ clearManualErrorsOnChange: false,
289
+ validators: {
290
+ email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
291
+ }
292
+ })
293
+
294
+ profile.setErrors({ email: ['This email is already in use.'] })
295
+
296
+ // The error stays visible even after the user edits the field.
297
+ profile.clearErrors(['email']) // Or clearErrors() to clear everything.
298
+ ```
299
+
300
+ ---
301
+
302
+ ## 9. Native server-rendered submit (Blade, ERB, classic Rails)
303
+
304
+ ```html
305
+ <form id="profile" action="/profile" method="post">
306
+ @csrf
307
+ <input name="email" type="email" />
308
+ <input name="name" />
309
+ <button type="submit">Save</button>
310
+ </form>
311
+ ```
312
+
313
+ ```ts
314
+ import { form } from '@samline/forms'
315
+
316
+ const profile = form('profile', {
317
+ validators: {
318
+ email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ }
319
+ }
320
+ })
321
+
322
+ profile.onSubmit(() => {
323
+ console.log('client-side hooks ran')
324
+ }, false)
325
+ ```
326
+
327
+ 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.
328
+
329
+ ---
330
+
331
+ ## 10. SPA cleanup on route change
332
+
333
+ ```ts
334
+ import { form } from '@samline/forms'
335
+
336
+ const controllers = new Map<string, ReturnType<typeof form>>()
337
+
338
+ router.on('/profile', element => {
339
+ controllers.set('profile', form(element, { validators: { email: { required: true } } }))
340
+ })
341
+
342
+ router.off('/profile', () => {
343
+ controllers.get('profile')?.destroy()
344
+ controllers.delete('profile')
345
+ })
346
+ ```
347
+
348
+ `destroy()` is idempotent — calling it more than once is safe.
349
+
350
+ ---
351
+
352
+ ## 11. Sync state into a view layer
353
+
354
+ ```ts
355
+ import { form } from '@samline/forms'
356
+
357
+ const signup = form('signup-form', {
358
+ validators: {
359
+ email: { required: true },
360
+ password: { required: true, minLength: 8 }
361
+ }
362
+ })
363
+
364
+ signup.subscribe(state => {
365
+ const submit = signup.element?.querySelector<HTMLButtonElement>('button[type="submit"]')
366
+ if (submit) submit.disabled = !state.isValid
367
+ })
368
+ ```
369
+
370
+ Use `subscribe` when a single source of truth should drive your UI (button enabled state, live previews, dependent field visibility, etc.).
371
+
372
+ ---
373
+
374
+ ## 12. Confirm a password with `sameAs`
375
+
376
+ ```ts
377
+ import { form } from '@samline/forms'
378
+
379
+ const signup = form('signup-form', {
380
+ validators: {
381
+ password: { required: true, minLength: 8 },
382
+ password_confirmation: {
383
+ required: true,
384
+ sameAs: {
385
+ value: 'password',
386
+ message: 'Passwords do not match.'
387
+ }
388
+ }
389
+ }
390
+ })
391
+ ```
392
+
393
+ 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`.
394
+
395
+ ---
396
+
397
+ ## 13. Combine `setValue` with custom validation
398
+
399
+ ```ts
400
+ import { form } from '@samline/forms'
401
+
402
+ const shipping = form('shipping-form', {
403
+ validators: {
404
+ method: { required: true },
405
+ country: {
406
+ validate: ({ value, values }) => {
407
+ if (values.method === 'international' && value !== 'US') return null
408
+ if (values.method === 'domestic' && value === 'US') return null
409
+ return 'Country and shipping method must be consistent.'
410
+ }
411
+ }
412
+ }
413
+ })
414
+
415
+ // Programmatic update still triggers the delegated event pipeline.
416
+ shipping.setValue('method', 'international')
417
+ shipping.validate(['method', 'country'])
418
+ ```
419
+
420
+ Custom validators receive the full values map, so cross-field rules read like plain JavaScript.
421
+
422
+ ---
423
+
424
+ ## 14. Format inputs with `@samline/formatter`
425
+
426
+ 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.
427
+
428
+ ```html
429
+ <form id="checkout">
430
+ <input name="phone" autocomplete="tel" />
431
+ <input name="card" autocomplete="cc-number" />
432
+ <input name="amount" inputmode="decimal" />
433
+ </form>
434
+ ```
435
+
436
+ ```ts
437
+ import { form } from '@samline/forms'
438
+
439
+ const checkout = form('checkout', {
440
+ formats: {
441
+ phone: { type: 'phone', field: 'phone', options: { country: 'MX' } },
442
+ card: { type: 'creditCard', field: 'card' },
443
+ amount: { type: 'numeral', field: 'amount', options: { prefix: '$' } }
444
+ }
445
+ })
446
+
447
+ checkout.onSubmit((_form, _data, formData) => {
448
+ // formData carries the raw value at the canonical name and the
449
+ // formatted value at the display name. The backend only needs
450
+ // the canonical (raw) keys:
451
+ // phone = '5512345678'
452
+ // phone_displayed = '55 1234 5678'
453
+ // card = '4111111111111111'
454
+ // card_displayed = '4111 1111 1111 1111'
455
+ // amount = '1234'
456
+ // amount_displayed= '$1,234'
457
+ fetch('/api/checkout', { method: 'POST', body: formData })
458
+ })
459
+ ```
460
+
461
+ Things to know:
462
+
463
+ - 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.
464
+ - 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).
465
+ - 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.
466
+ - 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.
467
+ - 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`).
468
+ - `@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.
469
+
470
+ ---
471
+
472
+ ## Next steps
473
+
474
+ - Need a full options reference? See [docs/options.md](options.md).
475
+ - Looking up an exact method signature? See [docs/api/index.md](api/index.md).
476
+ - Working with TypeScript types? See [docs/typescript.md](typescript.md).
477
+ - Setting up styling for `css-filled` / `css-error`? See [docs/css-styling.md](css-styling.md).