@samline/forms 2.4.0 → 2.7.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/LICENSE +21 -0
  2. package/README.md +11 -8
  3. package/dist/browser/global.d.ts +28 -18
  4. package/dist/browser/global.global.js +477 -66
  5. package/dist/browser/global.global.js.map +1 -1
  6. package/dist/browser/index.cjs +1450 -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-WGPAVJTK.js +1415 -0
  13. package/dist/chunk-WGPAVJTK.js.map +1 -0
  14. package/dist/index.cjs +296 -40
  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-GzopYPCR.d.cts +164 -0
  21. package/dist/registry-GzopYPCR.d.ts +164 -0
  22. package/docs/api/add-cleanup.md +52 -0
  23. package/docs/api/append.md +96 -0
  24. package/docs/api/auto-submit.md +91 -0
  25. package/docs/api/clear-errors.md +69 -0
  26. package/docs/api/destroy.md +80 -0
  27. package/docs/api/disable-auto-submit.md +65 -0
  28. package/docs/api/element.md +78 -0
  29. package/docs/api/form.md +156 -0
  30. package/docs/api/format.md +270 -0
  31. package/docs/api/get-data.md +90 -0
  32. package/docs/api/get-field.md +87 -0
  33. package/docs/api/get-state.md +111 -0
  34. package/docs/api/get-value.md +114 -0
  35. package/docs/api/index.md +55 -0
  36. package/docs/api/observe.md +126 -0
  37. package/docs/api/on-submit.md +94 -0
  38. package/docs/api/parse-form-data.md +94 -0
  39. package/docs/api/prefill.md +89 -0
  40. package/docs/api/regex.md +155 -0
  41. package/docs/api/reset.md +61 -0
  42. package/docs/api/revalidate.md +52 -0
  43. package/docs/api/set-errors.md +97 -0
  44. package/docs/api/set-value.md +120 -0
  45. package/docs/api/subscribe.md +107 -0
  46. package/docs/api/unwatch.md +74 -0
  47. package/docs/api/validate-field-value.md +144 -0
  48. package/docs/api/validate-values.md +103 -0
  49. package/docs/api/validate.md +102 -0
  50. package/docs/api/watch.md +117 -0
  51. package/docs/browser.md +156 -0
  52. package/docs/css-styling.md +191 -0
  53. package/docs/getting-started.md +222 -0
  54. package/docs/options.md +326 -0
  55. package/docs/recipes.md +530 -0
  56. package/docs/typescript.md +615 -0
  57. package/docs/vanilla.md +12 -0
  58. package/package.json +58 -14
@@ -0,0 +1,52 @@
1
+ # `addCleanup(cleanup)`
2
+
3
+ Registers teardown work owned by the controller. The callback runs when [`destroy()`](destroy.md) tears the controller down, unless that registration is removed first.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ addCleanup(cleanup: FormCleanup): () => void
9
+
10
+ type FormCleanup = () => void
11
+ ```
12
+
13
+ ## Parameters
14
+
15
+ | Name | Type | Required | Description |
16
+ | --- | --- | --- | --- |
17
+ | `cleanup` | [`FormCleanup`](../typescript.md#formcleanup) | yes | Synchronous callback to run during controller destruction. |
18
+
19
+ ## Returns
20
+
21
+ An unregister function. Calling it removes only that registration without running the cleanup. It is idempotent.
22
+
23
+ ## Behaviour
24
+
25
+ - Active cleanups run once, in reverse registration order, when `destroy()` is first called.
26
+ - Registering the same callback more than once creates independent registrations. Each active registration runs once.
27
+ - A cleanup added after the controller is already destroyed runs immediately. Its returned unregister function is a no-op.
28
+ - If a cleanup throws, `destroy()` logs `[forms] cleanup failed`, continues with the remaining callbacks, and completes teardown.
29
+ - Calling `destroy()` again does not rerun cleanups.
30
+
31
+ ## Example
32
+
33
+ ```ts
34
+ import { form } from '@samline/forms'
35
+
36
+ const profile = form('profile-form')
37
+ const abortController = new AbortController()
38
+
39
+ const unregister = profile.addCleanup(() => abortController.abort())
40
+
41
+ // Optional: keep the resource alive beyond this controller.
42
+ unregister()
43
+
44
+ profile.destroy()
45
+ ```
46
+
47
+ This hook is useful for integration-owned event listeners, observers, subscriptions, abort controllers, and other resources that should share the form controller's lifetime.
48
+
49
+ ## Related
50
+
51
+ - [`destroy`](destroy.md) — runs all active cleanup registrations.
52
+ - [`observe`](observe.md) and [`subscribe`](subscribe.md) — return their own unsubscribe functions.
@@ -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,80 @@
1
+ # `destroy()`
2
+
3
+ Tears the controller down. Removes all DOM listeners, disconnects the `MutationObserver`, runs registered cleanups, 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
+ - Runs active callbacks registered with [`addCleanup()`](add-cleanup.md) once in reverse registration order. A thrown cleanup is logged and does not stop the others.
23
+ - Clears `watchedFields`, `subscribers`, `submitHandlers`, and `fieldCache`.
24
+ - Resets manual and validation error maps.
25
+ - Clears tracked async-submit state. Pending handler promises continue independently, but no completion notification is emitted after destruction.
26
+
27
+ 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.
28
+
29
+ ## Examples
30
+
31
+ ### SPA route unmount
32
+
33
+ ```ts
34
+ import { form } from '@samline/forms'
35
+
36
+ let profile: ReturnType<typeof form> | null = null
37
+
38
+ function mountProfileForm(element: HTMLFormElement) {
39
+ profile = form(element, { validators: { email: { required: true } } })
40
+ }
41
+
42
+ function unmountProfileForm() {
43
+ profile?.destroy()
44
+ profile = null
45
+ }
46
+ ```
47
+
48
+ ### Cleanup on script teardown
49
+
50
+ ```ts
51
+ const profile = form('profile-form')
52
+
53
+ window.addEventListener('beforeunload', () => {
54
+ profile.destroy()
55
+ })
56
+ ```
57
+
58
+ ### Own integration resources
59
+
60
+ ```ts
61
+ const profile = form('profile-form')
62
+ const observer = new ResizeObserver(() => updateLayout())
63
+
64
+ if (profile.element) observer.observe(profile.element)
65
+ profile.addCleanup(() => observer.disconnect())
66
+ ```
67
+
68
+ ## Edge cases
69
+
70
+ - **`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.
71
+ - **Subscribers and watchers stop receiving notifications immediately**, but their closures remain until you release them.
72
+ - **Cleanup registrations are independent.** Registering the same callback twice runs it twice unless one registration's unregister function is called.
73
+ - **Late cleanup registration runs immediately.** Calling `addCleanup()` after destruction executes the callback at once and returns a no-op unregister function.
74
+ - **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.
75
+
76
+ ## Related
77
+
78
+ - [`reset`](reset.md) — restore form values without removing listeners.
79
+ - [`form`](form.md) — bind a new controller.
80
+ - [`addCleanup`](add-cleanup.md) — register controller-owned teardown work.
@@ -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,156 @@
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. Synchronizes initial `css-filled` state for every tracked control, including when `autoValidate` is disabled.
73
+ 7. Runs an initial validation pass only if `autoValidate` is enabled.
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
+ - **`autoValidate: false` disables the initial rule pass, not visual initialization.** Prefilled fields still receive `css-filled`; no validation errors are produced and `isValidated` remains `false`.
138
+ - **`createFormController` is also exported** as the lower-level factory; prefer `form` for ergonomic imports.
139
+
140
+ ## `createFormController`
141
+
142
+ ```ts
143
+ function createFormController(
144
+ target: FormTarget,
145
+ options?: FormControllerOptions
146
+ ): FormController
147
+ ```
148
+
149
+ This is the factory used by `form()` and accepts the same arguments. It is exported for integrations that prefer an explicit constructor-style name.
150
+
151
+ ## Related
152
+
153
+ - [`FormController`](../typescript.md#formcontroller) — the returned controller type.
154
+ - [`destroy`](destroy.md) — tear the controller down.
155
+ - [`addCleanup`](add-cleanup.md) — attach integration cleanup to the controller lifecycle.
156
+ - [docs/options.md](../options.md) — every option in detail.