@samline/forms 2.4.0 → 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 +319 -49
  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 +134 -23
  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 -1148
  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 +58 -14
@@ -0,0 +1,96 @@
1
+ # `append(options)`
2
+
3
+ Inserts a DOM node into the bound form. Useful for rendering banners, hints, or summary blocks that should live inside the `<form>` element so they are reset together with the form.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ append(options: AppendContentOptions): HTMLElement | null
9
+ ```
10
+
11
+ ```ts
12
+ interface AppendContentOptions {
13
+ tag: keyof HTMLElementTagNameMap
14
+ content: string
15
+ class?: string
16
+ atStart?: boolean
17
+ }
18
+ ```
19
+
20
+ ## Parameters
21
+
22
+ | Name | Type | Required | Default | Description |
23
+ | --- | --- | --- | --- | --- |
24
+ | `options.tag` | `keyof HTMLElementTagNameMap` | yes | — | The HTML tag to create (e.g. `'div'`, `'p'`, `'output'`). |
25
+ | `options.content` | `string` | yes | — | `innerHTML` content for the new node. |
26
+ | `options.class` | `string` | no | — | Class name. If a node with the same first class already exists inside the form, it is removed first. |
27
+ | `options.atStart` | `boolean` | no | `false` | When `true`, the node is inserted at the start of the form. When `false`, it is appended at the end. |
28
+
29
+ ## Returns
30
+
31
+ The created `HTMLElement`, or `null` when the controller has no bound form.
32
+
33
+ ## Behaviour
34
+
35
+ - Clears the field cache (because the form subtree changed).
36
+ - If `class` is provided and a descendant of the form matches the first class in the string, that descendant is removed before the new node is inserted. This gives you a single “render or replace” pattern.
37
+ - The created node receives `innerHTML = content`. The content is trusted HTML; escape it yourself if it comes from user input.
38
+
39
+ ## Examples
40
+
41
+ ### Render a static hint
42
+
43
+ ```ts
44
+ import { form } from '@samline/forms'
45
+
46
+ const signup = form('signup-form')
47
+
48
+ signup.append({
49
+ tag: 'p',
50
+ content: 'We will never share your email.',
51
+ class: 'signup-hint'
52
+ })
53
+ ```
54
+
55
+ ### Render a live region for validation messages
56
+
57
+ ```ts
58
+ const profile = form('profile-form')
59
+
60
+ profile.append({
61
+ tag: 'output',
62
+ content: '',
63
+ class: 'profile-status',
64
+ atStart: true
65
+ })
66
+
67
+ profile.subscribe(state => {
68
+ const status = profile.element?.querySelector('.profile-status')
69
+ if (status) status.textContent = state.isValid ? 'Looks good' : 'Please fix errors'
70
+ })
71
+ ```
72
+
73
+ ### Replace a previous banner
74
+
75
+ ```ts
76
+ import { form } from '@samline/forms'
77
+
78
+ const contact = form('contact-form')
79
+
80
+ contact.append({ tag: 'div', content: 'Draft saved.', class: 'banner banner-info' })
81
+
82
+ // Some time later — replaces the previous banner instead of stacking.
83
+ contact.append({ tag: 'div', content: 'Draft published.', class: 'banner banner-success' })
84
+ ```
85
+
86
+ ## Edge cases
87
+
88
+ - **Only the first class token is used for deduplication.** If you pass `'a b'`, the controller searches for `.a` inside the form and removes it. The other classes are still applied to the new node.
89
+ - **The returned element is a live reference.** It stays in the DOM until you remove it or until [`reset`](reset.md) is called (which strips `css-filled` / `css-error` attributes but does not remove nodes you added).
90
+ - **Appending does not trigger validation, watchers, or autoSubmit.** It only mutates the DOM.
91
+ - **Calling `append` from a non-form controller** returns `null` and does nothing.
92
+
93
+ ## Related
94
+
95
+ - [`reset`](reset.md) — strips visual attributes but not custom nodes.
96
+ - [`destroy`](destroy.md) — removes listeners and observer but does not remove custom nodes.
@@ -0,0 +1,91 @@
1
+ # `autoSubmit(options?)`
2
+
3
+ Enables native auto-submit. Every change to a tracked field triggers `form.requestSubmit()`. Optionally debounced.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ autoSubmit(options?: boolean | AutoSubmitOptions): FormController
9
+ ```
10
+
11
+ ## Parameters
12
+
13
+ | Name | Type | Required | Default | Description |
14
+ | --- | --- | --- | --- | --- |
15
+ | `options` | `boolean \| AutoSubmitOptions` | no | `true` | Pass `true` to enable, `false` to disable, or an `{ debounce }` object to delay. |
16
+
17
+ ```ts
18
+ interface AutoSubmitOptions {
19
+ debounce?: number
20
+ }
21
+ ```
22
+
23
+ ## Returns
24
+
25
+ The same [`FormController`](../typescript.md#formcontroller) — chainable.
26
+
27
+ ## Behaviour
28
+
29
+ - `autoSubmit()` or `autoSubmit(true)` — enables auto-submit immediately on every change.
30
+ - `autoSubmit({ debounce: ms })` — schedules `form.requestSubmit()` `ms` milliseconds after the most recent change. Subsequent changes within the debounce window reset the timer.
31
+ - `autoSubmit(false)` — disables auto-submit (equivalent to [`disableAutoSubmit()`](disable-auto-submit.md)).
32
+
33
+ When auto-submit fires, the native submit is invoked. If the form is invalid, the controller’s `submit` listener prevents the browser from navigating. If the form is valid, the registered [`onSubmit`](on-submit.md) handlers run as usual.
34
+
35
+ Subscribers are notified when autoSubmit is enabled, disabled, or its debounce changes (because the state’s `autoSubmit` field changes).
36
+
37
+ ## Examples
38
+
39
+ ### Autosave on every change
40
+
41
+ ```ts
42
+ import { form } from '@samline/forms'
43
+
44
+ const draft = form('draft-form', {
45
+ validators: { title: { required: true } }
46
+ })
47
+
48
+ draft.autoSubmit()
49
+ ```
50
+
51
+ ### Debounced search
52
+
53
+ ```ts
54
+ import { form } from '@samline/forms'
55
+
56
+ const search = form('search-form', {
57
+ autoSubmit: { debounce: 300 }
58
+ })
59
+
60
+ search.onSubmit(async (_form, data) => {
61
+ await fetch('/search', {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify(data)
65
+ })
66
+ })
67
+ ```
68
+
69
+ ### Toggle at runtime
70
+
71
+ ```ts
72
+ const formEl = form('search-form')
73
+
74
+ document.querySelector('#realtime')?.addEventListener('change', event => {
75
+ const enabled = (event.target as HTMLInputElement).checked
76
+ formEl.autoSubmit(enabled ? { debounce: 250 } : false)
77
+ })
78
+ ```
79
+
80
+ ## Edge cases
81
+
82
+ - **The submit is invoked via `form.requestSubmit()`** when available, falling back to clicking a submit button or `form.submit()` otherwise.
83
+ - **`autoSubmit` does not validate.** Validation runs as part of the submit pipeline, so an invalid form will not navigate and your [`onSubmit`](on-submit.md) handler will not run.
84
+ - **The debounce timer is cancelled by [`disableAutoSubmit()`](disable-auto-submit.md) and [`destroy()`](destroy.md).**
85
+ - **Setting `autoSubmit` from `options` and from the method can both run at mount time** — the method is called last, so it wins.
86
+
87
+ ## Related
88
+
89
+ - [`disableAutoSubmit`](disable-auto-submit.md) — turn auto-submit off.
90
+ - [`onSubmit`](on-submit.md) — submit handlers.
91
+ - [`options.autoSubmit`](../options.md#autosubmit) — mount-time configuration.
@@ -0,0 +1,69 @@
1
+ # `clearErrors(fields?)`
2
+
3
+ Removes manual errors. Validation errors from rules are not touched.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ clearErrors(): FormController
9
+ clearErrors(fields: string[]): FormController
10
+ ```
11
+
12
+ ## Parameters
13
+
14
+ | Name | Type | Required | Description |
15
+ | --- | --- | --- | --- |
16
+ | `fields` | `string[]` | no | When provided, only the listed fields are cleared. When omitted, every manual error is cleared. |
17
+
18
+ ## Returns
19
+
20
+ The same [`FormController`](../typescript.md#formcontroller) — chainable.
21
+
22
+ ## Behaviour
23
+
24
+ - Only manual errors (set via [`setErrors`](set-errors.md)) are removed. Validation errors from rules are recomputed the next time validation runs.
25
+ - Visual attributes are re-synced for the affected fields — `css-error` is removed when no errors remain for a field.
26
+ - Subscribers are notified.
27
+
28
+ ## Examples
29
+
30
+ ### Clear a single field
31
+
32
+ ```ts
33
+ import { form } from '@samline/forms'
34
+
35
+ const profile = form('profile-form')
36
+
37
+ profile.setErrors({ email: ['Already in use.'] })
38
+
39
+ profile.clearErrors(['email'])
40
+ ```
41
+
42
+ ### Clear everything
43
+
44
+ ```ts
45
+ profile.setErrors({
46
+ email: ['Already in use.'],
47
+ password: ['Too weak.']
48
+ })
49
+
50
+ profile.clearErrors()
51
+ ```
52
+
53
+ ### Clear before re-running validation
54
+
55
+ ```ts
56
+ profile.clearErrors()
57
+ profile.validate()
58
+ ```
59
+
60
+ ## Edge cases
61
+
62
+ - **Validation errors are not cleared by `clearErrors`.** They live in a separate map and are only cleared by [`reset`](reset.md) or by a successful re-validation of the field.
63
+ - **`clearErrors` on a field with no manual error is a no-op** — the chain still returns the controller.
64
+ - **Passing an empty array `clearErrors([])` does nothing.** Use `clearErrors()` to clear everything.
65
+
66
+ ## Related
67
+
68
+ - [`setErrors`](set-errors.md) — push manual errors.
69
+ - [`reset`](reset.md) — clear every error and reset field values.
@@ -0,0 +1,65 @@
1
+ # `destroy()`
2
+
3
+ Tears the controller down. Removes all DOM listeners, disconnects the `MutationObserver`, clears caches, drops subscribers and submit handlers, and resets internal state.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ destroy(): void
9
+ ```
10
+
11
+ ## Returns
12
+
13
+ `void`. Idempotent — calling `destroy()` more than once is a no-op.
14
+
15
+ ## Behaviour
16
+
17
+ When invoked, the controller:
18
+
19
+ - Removes every listener it added (`input`, `submit`, formatter listeners, and document delegation for external controls).
20
+ - Cancels any pending auto-submit debounce timer.
21
+ - Disconnects the form `MutationObserver`.
22
+ - Clears `watchedFields`, `subscribers`, `submitHandlers`, and `fieldCache`.
23
+ - Resets manual and validation error maps.
24
+
25
+ After `destroy()` the controller object still exists and its methods remain callable. Direct reads and writes can still inspect or mutate the bound DOM, but the removed controller listeners no longer react to resulting events.
26
+
27
+ ## Examples
28
+
29
+ ### SPA route unmount
30
+
31
+ ```ts
32
+ import { form } from '@samline/forms'
33
+
34
+ let profile: ReturnType<typeof form> | null = null
35
+
36
+ function mountProfileForm(element: HTMLFormElement) {
37
+ profile = form(element, { validators: { email: { required: true } } })
38
+ }
39
+
40
+ function unmountProfileForm() {
41
+ profile?.destroy()
42
+ profile = null
43
+ }
44
+ ```
45
+
46
+ ### Cleanup on script teardown
47
+
48
+ ```ts
49
+ const profile = form('profile-form')
50
+
51
+ window.addEventListener('beforeunload', () => {
52
+ profile.destroy()
53
+ })
54
+ ```
55
+
56
+ ## Edge cases
57
+
58
+ - **`destroy()` does not remove `css-filled` / `css-error` attributes** from fields. If you need that, call [`reset()`](reset.md) before destroy, or strip the attributes yourself.
59
+ - **Subscribers and watchers stop receiving notifications immediately**, but their closures remain until you release them.
60
+ - **Reusing the controller reference after destroy is possible but usually misleading.** Reads still inspect the bound form and methods such as `setValue()` can still write fields, but watchers, subscribers, validation-on-input, and auto-submit no longer run.
61
+
62
+ ## Related
63
+
64
+ - [`reset`](reset.md) — restore form values without removing listeners.
65
+ - [`form`](form.md) — bind a new controller.
@@ -0,0 +1,65 @@
1
+ # `disableAutoSubmit()`
2
+
3
+ Turns auto-submit off and cancels any pending debounce timer.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ disableAutoSubmit(): FormController
9
+ ```
10
+
11
+ ## Returns
12
+
13
+ The same [`FormController`](../typescript.md#formcontroller) — chainable.
14
+
15
+ ## Behaviour
16
+
17
+ - Sets `state.autoSubmitEnabled` to `false`.
18
+ - Cancels `state.autoSubmitTimer` if one is pending.
19
+ - Notifies subscribers (because the state’s `autoSubmit` field changes).
20
+
21
+ Equivalent to calling [`autoSubmit(false)`](auto-submit.md).
22
+
23
+ ## Examples
24
+
25
+ ### Pause autosave while typing resumes
26
+
27
+ ```ts
28
+ import { form } from '@samline/forms'
29
+
30
+ const draft = form('draft-form', { autoSubmit: { debounce: 800 } })
31
+
32
+ document.querySelector('#pause')?.addEventListener('click', () => {
33
+ draft.disableAutoSubmit()
34
+ })
35
+
36
+ document.querySelector('#resume')?.addEventListener('click', () => {
37
+ draft.autoSubmit({ debounce: 800 })
38
+ })
39
+ ```
40
+
41
+ ### Disable on blur
42
+
43
+ ```ts
44
+ import { form } from '@samline/forms'
45
+
46
+ const search = form('search-form', { autoSubmit: { debounce: 250 } })
47
+
48
+ search.element?.addEventListener('focusin', () => {
49
+ search.autoSubmit({ debounce: 250 })
50
+ })
51
+
52
+ search.element?.addEventListener('focusout', () => {
53
+ search.disableAutoSubmit()
54
+ })
55
+ ```
56
+
57
+ ## Edge cases
58
+
59
+ - **Re-enabling later requires calling [`autoSubmit()`](auto-submit.md) again.** The previous debounce configuration is not preserved.
60
+ - **A pending debounce timer is removed**, but the timer was already scheduled; nothing fires for it.
61
+
62
+ ## Related
63
+
64
+ - [`autoSubmit`](auto-submit.md) — enable / configure.
65
+ - [`destroy`](destroy.md) — also cancels any pending debounce.
@@ -0,0 +1,78 @@
1
+ # `element` and `f`
2
+
3
+ Read-only getters that return the bound `HTMLFormElement`, or `null` if the binding target was unresolved at construction time.
4
+
5
+ ## Signatures
6
+
7
+ ```ts
8
+ interface FormController {
9
+ readonly element: HTMLFormElement | null
10
+ readonly f: HTMLFormElement | null
11
+ readonly options: FormControllerOptions
12
+ }
13
+ ```
14
+
15
+ - `element` — the bound form.
16
+ - `f` — alias of `element`, kept short for fluent setup.
17
+ - `options` — the resolved options object (defaults merged with user-provided values).
18
+
19
+ ## Returns
20
+
21
+ `HTMLFormElement | null`. `null` when the target passed to [`form()`](form.md) did not resolve to a form.
22
+
23
+ ## Examples
24
+
25
+ ### Read the bound form
26
+
27
+ ```ts
28
+ import { form } from '@samline/forms'
29
+
30
+ const profile = form('profile-form')
31
+
32
+ if (profile.element) {
33
+ profile.element.classList.add('mounted')
34
+ }
35
+ ```
36
+
37
+ ### Use the short alias
38
+
39
+ ```ts
40
+ import { form } from '@samline/forms'
41
+
42
+ const profile = form('profile-form')
43
+
44
+ profile.f?.querySelector('[data-status]')?.setAttribute('data-state', 'ready')
45
+ ```
46
+
47
+ ### Read the resolved options
48
+
49
+ ```ts
50
+ import { form } from '@samline/forms'
51
+
52
+ const profile = form('profile-form', {
53
+ autoValidate: false,
54
+ attributes: { error: 'is-invalid' },
55
+ formats: {
56
+ phone: { type: 'phone', field: 'phone', options: { country: 'MX' } }
57
+ }
58
+ })
59
+
60
+ profile.options.autoValidate // false (overridden)
61
+ profile.options.autoSubmit // false (default)
62
+ profile.options.clearErrorsOnSubmit // true (default)
63
+ profile.options.attributes.error // 'is-invalid'
64
+ profile.options.attributes.filled // 'css-filled' (default)
65
+ profile.options.formats.phone // FieldFormatConfig (overridden)
66
+ ```
67
+
68
+ The `formats` key is the declarative counterpart to [`format()`](format.md) — see [the option reference](../options.md#formats) for the full shape.
69
+
70
+ ## Edge cases
71
+
72
+ - The getter returns `null` when the target was `null`, `undefined`, a non-existent id, or an element that is not an `HTMLFormElement`.
73
+ - The reference is the same one the controller uses internally — do not replace the form node, or the controller will keep listening to the original.
74
+
75
+ ## Related
76
+
77
+ - [`form(target, options?)`](form.md) — bind a controller.
78
+ - [`destroy`](destroy.md) — tear the controller down.
@@ -0,0 +1,154 @@
1
+ # `form(target, options?)`
2
+
3
+ Creates a new controller bound to a form. This is the main entry point of `@samline/forms`.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ function form(
9
+ target: FormTarget,
10
+ options?: FormControllerOptions
11
+ ): FormController
12
+ ```
13
+
14
+ ## Parameters
15
+
16
+ | Name | Type | Required | Description |
17
+ | --- | --- | --- | --- |
18
+ | `target` | [`FormTarget`](../typescript.md#formtarget) | yes | What to bind to: a string id, an `HTMLFormElement`, a ref-like `{ current }` object, or `null` / `undefined`. |
19
+ | `options` | [`FormControllerOptions`](../typescript.md#formcontrolleroptions) | no | Controller configuration. See [docs/options.md](../options.md). |
20
+
21
+ ## Returns
22
+
23
+ A [`FormController`](../typescript.md#formcontroller) — chainable for every method that does not return data or an unsubscribe function.
24
+
25
+ ## Binding modes
26
+
27
+ ### By id string
28
+
29
+ ```ts
30
+ import { form } from '@samline/forms'
31
+
32
+ const profile = form('profile-form')
33
+ ```
34
+
35
+ The string is passed to `document.getElementById`. If no element is found, or the found element is not an `HTMLFormElement`, the controller is created but `controller.element` is `null` and most methods become no-ops.
36
+
37
+ ### By `HTMLFormElement`
38
+
39
+ ```ts
40
+ const element = document.querySelector<HTMLFormElement>('#profile-form')
41
+ const profile = form(element)
42
+ ```
43
+
44
+ Useful when you already have the element from a `querySelector` call.
45
+
46
+ ### By ref-like object
47
+
48
+ ```ts
49
+ const ref = { current: document.querySelector<HTMLFormElement>('#profile-form') }
50
+ const profile = form(ref)
51
+ ```
52
+
53
+ The controller reads `target.current` only at construction time. If you mount the form later, recreate the controller or call `destroy()` and re-bind.
54
+
55
+ ### By `null` / `undefined`
56
+
57
+ ```ts
58
+ const profile = form()
59
+ ```
60
+
61
+ Allowed for type-system ergonomics. The resulting controller has `element === null` and is effectively inert.
62
+
63
+ ## Behaviour
64
+
65
+ On creation the controller:
66
+
67
+ 1. Resolves the form element.
68
+ 2. Merges the provided options with defaults (`autoValidate: true`, `clearErrorsOnSubmit: true`, `clearManualErrorsOnChange: true`, `attributes: { filled: 'css-filled', error: 'css-error' }`).
69
+ 3. Wires delegated `input` and `submit` listeners, including controls associated through `form="id"`.
70
+ 4. Starts a `MutationObserver` on the form subtree to track dynamic fields.
71
+ 5. Optionally enables [`autoSubmit`](../api/auto-submit.md) when `options.autoSubmit` is truthy.
72
+ 6. Runs an initial validation pass if `autoValidate` is enabled.
73
+ 7. Notifies subscribers with the initial state.
74
+
75
+ ## Examples
76
+
77
+ ### Minimal setup
78
+
79
+ ```ts
80
+ import { form } from '@samline/forms'
81
+
82
+ const contact = form('contact-form')
83
+
84
+ contact.onSubmit(async (_element, _data, formData) => {
85
+ await fetch('/api/contact', { method: 'POST', body: formData })
86
+ })
87
+ ```
88
+
89
+ ### With validators and options
90
+
91
+ ```ts
92
+ import { form } from '@samline/forms'
93
+
94
+ const signup = form('signup-form', {
95
+ attributes: { filled: 'is-filled', error: 'is-invalid' },
96
+ autoValidate: true,
97
+ autoSubmit: { debounce: 600 },
98
+ clearManualErrorsOnChange: false,
99
+ validators: {
100
+ email: {
101
+ required: { value: true, message: 'Email is required.' },
102
+ pattern: {
103
+ value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
104
+ message: 'Enter a valid email address.'
105
+ }
106
+ },
107
+ password: { required: true, minLength: 8 }
108
+ },
109
+ formats: {
110
+ phone: { type: 'phone', field: 'phone', options: { country: 'MX' } }
111
+ }
112
+ })
113
+ ```
114
+
115
+ ### Bind later via ref
116
+
117
+ ```ts
118
+ import { form } from '@samline/forms'
119
+
120
+ const profileRef = { current: null as HTMLFormElement | null }
121
+
122
+ const profile = form(profileRef)
123
+
124
+ document.querySelector('#mount')?.appendChild(document.createElement('form'))
125
+ profileRef.current = document.querySelector<HTMLFormElement>('#mount form')
126
+
127
+ // Re-bind because the controller captured `current` at construction time.
128
+ profile.destroy()
129
+ form(profileRef)
130
+ ```
131
+
132
+ ## Edge cases
133
+
134
+ - **The id is resolved once.** If the DOM element with that id is replaced, the controller keeps listening to the original element. Recreate the controller if you re-render the form.
135
+ - **`null` / `undefined` targets** are accepted and produce an inert controller. The chainable methods still return the controller, so they are safe to call as no-ops.
136
+ - **Reusing a target across multiple controllers** is supported but they each install their own listeners — call [`destroy()`](destroy.md) on the previous one to avoid duplicate work.
137
+ - **`createFormController` is also exported** as the lower-level factory; prefer `form` for ergonomic imports.
138
+
139
+ ## `createFormController`
140
+
141
+ ```ts
142
+ function createFormController(
143
+ target: FormTarget,
144
+ options?: FormControllerOptions
145
+ ): FormController
146
+ ```
147
+
148
+ This is the factory used by `form()` and accepts the same arguments. It is exported for integrations that prefer an explicit constructor-style name.
149
+
150
+ ## Related
151
+
152
+ - [`FormController`](../typescript.md#formcontroller) — the returned controller type.
153
+ - [`destroy`](destroy.md) — tear the controller down.
154
+ - [docs/options.md](../options.md) — every option in detail.