@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
+ # `revalidate(fields?)`
2
+
3
+ Alias of [`validate`](validate.md) kept separate for readability at call sites that want to express “re-run validation now”.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ revalidate(): ValidationResult
9
+ revalidate(fields: string[]): ValidationResult
10
+ ```
11
+
12
+ ## Parameters
13
+
14
+ Same as [`validate`](validate.md).
15
+
16
+ ## Returns
17
+
18
+ Same as [`validate`](validate.md) — a [`ValidationResult`](../typescript.md#validationresult).
19
+
20
+ ## Behaviour
21
+
22
+ Identical to [`validate`](validate.md). The two methods share the same internal implementation.
23
+
24
+ This includes strict decimal `numeric` / inclusive `min` / `max` rules, per-member `each` validation and element-level error styling. Explicit field lists are not expanded through `sameAs` or `dependsOn`; those relationships drive revalidation after input events.
25
+
26
+ ## When to use which
27
+
28
+ Use `validate` when you are running validation as part of an initial check. Use `revalidate` when you are explicitly asking the controller to recompute validation after a state change (e.g. after `setErrors`, after async data loads, after resetting partial form state).
29
+
30
+ ```ts
31
+ import { form } from '@samline/forms'
32
+
33
+ const profile = form('profile-form', {
34
+ validators: { email: { required: true } }
35
+ })
36
+
37
+ // Initial check.
38
+ profile.validate()
39
+
40
+ // After a manual state change:
41
+ profile.setValue('email', '')
42
+ profile.revalidate(['email'])
43
+ ```
44
+
45
+ ## Edge cases
46
+
47
+ - **`revalidate` is not a different operation from `validate`.** Pick whichever name reads better at the call site.
48
+ - **Both methods mark the form as validated** the first time they are called.
49
+
50
+ ## Related
51
+
52
+ - [`validate`](validate.md) — same implementation, different name.
@@ -0,0 +1,97 @@
1
+ # `setErrors(fields)`
2
+
3
+ Pushes manual errors into the form. The typical use case is surfacing server-side validation feedback after a failed `fetch`.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ setErrors(fields: string[]): FormController
9
+ setErrors(fields: FormErrors): FormController
10
+ ```
11
+
12
+ ## Parameters
13
+
14
+ | Overload | Type | Description |
15
+ | --- | --- | --- |
16
+ | Array form | `string[]` | Each entry is treated as a field name. The error message defaults to `'Invalid value.'`. |
17
+ | Map form | [`FormErrors`](../typescript.md#formerrors) | Map from field name to one or more error messages. |
18
+
19
+ ## Returns
20
+
21
+ The same [`FormController`](../typescript.md#formcontroller) — chainable.
22
+
23
+ ## Behaviour
24
+
25
+ - Manual errors are merged with existing manual errors — existing entries for other fields are preserved.
26
+ - Validation errors from rules are not affected: validation errors and manual errors live in separate maps and are merged only when reading state.
27
+ - Visual attributes are re-synced for the affected fields, so `css-error` is added to each.
28
+ - Subscribers are notified.
29
+
30
+ ## Examples
31
+
32
+ ### Array form (generic message)
33
+
34
+ ```ts
35
+ import { form } from '@samline/forms'
36
+
37
+ const profile = form('profile-form')
38
+
39
+ profile.setErrors(['email', 'password'])
40
+ // state.errors.email -> ['Invalid value.']
41
+ // state.errors.password -> ['Invalid value.']
42
+ ```
43
+
44
+ ### Map form (per-field messages)
45
+
46
+ ```ts
47
+ profile.setErrors({
48
+ email: ['This email is already in use.'],
49
+ password: ['Too weak. Use at least 8 characters.']
50
+ })
51
+ ```
52
+
53
+ ### Map form with multiple errors per field
54
+
55
+ ```ts
56
+ profile.setErrors({
57
+ password: [
58
+ 'Too weak.',
59
+ 'Must contain a number.',
60
+ 'Must contain a symbol.'
61
+ ]
62
+ })
63
+ ```
64
+
65
+ ### Surface server errors after a failed submit
66
+
67
+ ```ts
68
+ import { form } from '@samline/forms'
69
+
70
+ const signup = form('signup-form', {
71
+ clearManualErrorsOnChange: false
72
+ })
73
+
74
+ signup.onSubmit(async (_element, _data, formData) => {
75
+ const response = await fetch('/signup', { method: 'POST', body: formData })
76
+
77
+ if (!response.ok) {
78
+ const { fieldErrors } = await response.json()
79
+ signup.setErrors(fieldErrors)
80
+ } else {
81
+ signup.clearErrors()
82
+ }
83
+ })
84
+ ```
85
+
86
+ ## Edge cases
87
+
88
+ - **Manual errors are cleared by default on the next change** of the affected field. Pass `clearManualErrorsOnChange: false` to keep them until you call [`clearErrors`](clear-errors.md).
89
+ - **The array form uses the literal string `'Invalid value.'`.** Use the map form when you need custom messages.
90
+ - **`setErrors` does not run validation.** Validation errors are computed independently by [`validate`](validate.md).
91
+ - **Setting an empty object `setErrors({})` clears nothing** — it just doesn’t add new errors. Use [`clearErrors`](clear-errors.md) to remove existing manual errors.
92
+
93
+ ## Related
94
+
95
+ - [`clearErrors`](clear-errors.md) — remove manual errors.
96
+ - [`validate`](validate.md) — run validation and read the result.
97
+ - [`options.clearManualErrorsOnChange`](../options.md#clearmanualerrorsonchange).
@@ -0,0 +1,120 @@
1
+ # `setValue(name, value)`
2
+
3
+ Writes a value into a field and dispatches the correct DOM event so watchers, validators, and visual state run as if a user typed.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ setValue(name: string, value: unknown): FormController
9
+ ```
10
+
11
+ ## Parameters
12
+
13
+ | Name | Type | Required | Description |
14
+ | --- | --- | --- | --- |
15
+ | `name` | `string` | yes | The `name` attribute of the target field. |
16
+ | `value` | `unknown` | yes | The value to write. Coerced per field type. |
17
+
18
+ ## Returns
19
+
20
+ The same [`FormController`](../typescript.md#formcontroller) — chainable. Returns the controller unchanged when the field does not exist.
21
+
22
+ ## Behaviour by field type
23
+
24
+ | Field type | How `value` is applied |
25
+ | --- | --- |
26
+ | `<input>` (text-like: `text`, `email`, `password`, …) | `String(value)` is assigned to `field.value`. |
27
+ | `<input>` (text-like) with `name="foo[]"` receiving an array | One array element per input, in order: `input[i].value = String(value[i])`. Inputs beyond the array length are cleared to `''`; extra array elements (more elements than inputs) are dropped. |
28
+ | `<input type="checkbox">` (single) | `field.checked = String(value) === field.value`. |
29
+ | `<input type="checkbox">` (group with the same `name`) | Each checkbox is checked if `Array.isArray(value)` includes its `value`, otherwise all unchecked. |
30
+ | `<input type="radio">` (group with the same `name`) | The radio whose `value` matches `String(value)` is checked. |
31
+ | `<input type="file">` | File inputs are read-only — `setValue` cannot assign a file. Passing `[]` clears the input. |
32
+ | `<select>` (single) | `String(value)` must match an option value. |
33
+ | `<select multiple>` | Every option whose value occurs in the supplied array is selected. A scalar selects its matching option. |
34
+ | `<select>` (group with `name="foo[]"`) receiving an array | One array element per select, in order: `select[i].value = String(value[i])`. Inputs beyond the array length are cleared to `''`; extra array elements are dropped. |
35
+ | `<textarea>` | `String(value)` is assigned to `field.value`. |
36
+ | `<textarea>` (group with `name="foo[]"`) receiving an array | One array element per textarea, in order: `textarea[i].value = String(value[i])`. Inputs beyond the array length are cleared to `''`; extra array elements are dropped. |
37
+
38
+ After writing, the controller dispatches one synthetic `input` event on the **first** matching field.
39
+
40
+ The synthetic event flows through the form-level delegated listener, so the rest of the pipeline runs as if the user had typed: manual errors are cleared (default), validation runs (if `autoValidate`), watchers fire, subscribers are notified, and `autoSubmit` is scheduled.
41
+
42
+ ## Examples
43
+
44
+ ### Set a text value
45
+
46
+ ```ts
47
+ import { form } from '@samline/forms'
48
+
49
+ const contact = form('contact-form')
50
+ contact.setValue('email', 'hello@example.com')
51
+ ```
52
+
53
+ ### Toggle checkboxes
54
+
55
+ ```ts
56
+ const signup = form('signup-form')
57
+
58
+ // Multiple values — array form.
59
+ signup.setValue('interests', ['design', 'code'])
60
+
61
+ // Single value — string form.
62
+ signup.setValue('terms', 'yes')
63
+ ```
64
+
65
+ ### Select a radio
66
+
67
+ ```ts
68
+ const shipping = form('shipping-form')
69
+ shipping.setValue('method', 'express')
70
+ ```
71
+
72
+ ### Reset a file input
73
+
74
+ ```ts
75
+ const upload = form('upload-form')
76
+ upload.setValue('attachments', [])
77
+ ```
78
+
79
+ ### Chain with other setup
80
+
81
+ ```ts
82
+ form('contact-form')
83
+ .setValue('subject', 'Question about pricing')
84
+ .setValue('message', 'Hello,')
85
+ ```
86
+
87
+ ### Distribute values across a `name="foo[]"` group
88
+
89
+ ```ts
90
+ const signup = form('signup-form')
91
+
92
+ // Each input gets one array element, in order. With two inputs and
93
+ // `name="docusign_email[]"`, this writes the first email to the first
94
+ // input and the second email to the second input.
95
+ signup.setValue('docusign_email[]', ['a@example.com', 'b@example.com'])
96
+
97
+ // When the array is shorter than the input count, the surplus inputs
98
+ // are cleared to ''.
99
+ signup.setValue('docusign_email[]', ['a@example.com'])
100
+
101
+ // When the array is longer than the input count, the extra array
102
+ // elements are dropped — there's no input to write to.
103
+ signup.setValue('docusign_email[]', ['a@example.com', 'b@example.com', 'c@example.com'])
104
+ ```
105
+
106
+ ## Edge cases
107
+
108
+ - **The synthetic event is dispatched only on the first matching field.** For radio/checkbox groups, this is intentional — the event bubbles, and the controller only needs to react once.
109
+ - **Passing `null` or `undefined` writes an empty string.** This is treated as a value, not as “skip”, so the field becomes empty and watchers fire.
110
+ - **`setValue` is a no-op for unknown field names** — the chain still works, but no event fires and no watchers run.
111
+ - **`setValue` cannot assign to `<input type="file">`.** Browsers do not allow programmatic file assignment for security reasons. Use a DataTransfer or a real file picker.
112
+ - **The `name="foo[]"` suffix is the explicit opt-in for multi-value inputs.** A field with the suffix that receives an array distributes one element per input (surplus inputs are cleared, surplus array elements are dropped). Bare names — `name="foo"` with multiple inputs — keep the original broadcast semantics and receive the same value on every matching input.
113
+ - **Manual errors for the field are cleared by default** (because the synthetic event triggers the delegated handler). Pass `clearManualErrorsOnChange: false` to keep them.
114
+
115
+ ## Related
116
+
117
+ - [`getValue`](get-value.md) — read the current value.
118
+ - [`getField`](get-field.md) — read the underlying DOM element.
119
+ - [`reset`](reset.md) — restore the form’s initial values.
120
+ - [`format`](format.md) — when a field is formatted, `setValue('<field>', x)` writes the raw to the hidden mirror and reformats the visible; `setValue('<field>_displayed', x)` writes the formatted to the visible and recomputes the raw. See [the mirror convention](format.md#the-mirror-convention).
@@ -0,0 +1,107 @@
1
+ # `subscribe(listener)`
2
+
3
+ Registers a listener that fires immediately with the current state and at controller notification points. Returns an unsubscribe function.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ subscribe(listener: FormStateListener): () => void
9
+ ```
10
+
11
+ ## Parameters
12
+
13
+ | Name | Type | Required | Description |
14
+ | --- | --- | --- | --- |
15
+ | `listener` | [`FormStateListener`](../typescript.md#formstatelistener) | yes | Callback invoked with the full state snapshot. |
16
+
17
+ ## Returns
18
+
19
+ A function. Calling it removes the listener.
20
+
21
+ ## Behaviour
22
+
23
+ - The listener fires **once synchronously** on registration with the current [`FormStateSnapshot`](../typescript.md#formstatesnapshot).
24
+ - It then fires at explicit controller notification points. This is not a proxy around every internal assignment.
25
+ - Multiple subscribers are supported. They run in registration order.
26
+
27
+ State mutations that fire subscribers include:
28
+
29
+ - Any `input` event on a tracked field.
30
+ - [`setValue`](set-value.md).
31
+ - [`setErrors`](set-errors.md) and [`clearErrors`](clear-errors.md).
32
+ - [`autoSubmit`](auto-submit.md) and [`disableAutoSubmit`](disable-auto-submit.md).
33
+ - [`reset`](reset.md).
34
+ - Submit attempts, after their validation pass and `submitCount` update.
35
+ - Async submit state transitions: once when promise-returning handlers make `isSubmitting` true, and again after their submission batch settles. Overlapping batches keep it true until all settle.
36
+ - `MutationObserver`-detected DOM changes.
37
+
38
+ Direct [`validate`](validate.md) and [`revalidate`](revalidate.md) calls update validation state and visual attributes but do not independently notify subscribers. Use their returned result when synchronous rendering is required.
39
+
40
+ ## Examples
41
+
42
+ ### Mirror state into a view layer
43
+
44
+ ```ts
45
+ import { form } from '@samline/forms'
46
+
47
+ const profile = form('profile-form')
48
+
49
+ const unsubscribe = profile.subscribe(state => {
50
+ document.querySelector('#status')!.textContent = state.isValid ? 'Ready' : 'Errors'
51
+ document.querySelector('#counter')!.textContent = String(state.submitCount)
52
+ })
53
+
54
+ window.addEventListener('beforeunload', unsubscribe)
55
+ ```
56
+
57
+ ### React to a specific field
58
+
59
+ ```ts
60
+ const signup = form('signup-form')
61
+
62
+ signup.subscribe(state => {
63
+ const password = state.values.password
64
+ const passwordError = state.errors.password?.[0]
65
+ document.querySelector('#password-hint')!.textContent =
66
+ passwordError ?? (typeof password === 'string' && password.length > 0 ? 'OK' : 'Empty')
67
+ })
68
+ ```
69
+
70
+ ### Track submit attempts
71
+
72
+ ```ts
73
+ const profile = form('profile-form')
74
+
75
+ profile.subscribe(state => {
76
+ if (state.submitCount > 0 && !state.isValid) {
77
+ console.warn('submit attempt failed:', state.submitCount)
78
+ }
79
+ })
80
+ ```
81
+
82
+ ### Track asynchronous submission
83
+
84
+ ```ts
85
+ const profile = form('profile-form')
86
+
87
+ profile.subscribe(state => {
88
+ submitButton.disabled = state.isSubmitting
89
+ submitButton.textContent = state.isSubmitting ? 'Saving...' : 'Save'
90
+ })
91
+
92
+ profile.onSubmit(async (_form, _data, formData) => {
93
+ await fetch('/profile', { method: 'POST', body: formData })
94
+ })
95
+ ```
96
+
97
+ ## Edge cases
98
+
99
+ - **The listener is invoked with a fresh snapshot** on every fire — it is safe to store the reference for the duration of one call.
100
+ - **Subscribers do not receive partial / incremental updates.** Each fire carries the whole snapshot.
101
+ - **Subscribers and watchers are independent.** A field-level change fires both the watchers for that field and every subscriber.
102
+ - **Unsubscribing does not detach the listener immediately if it is currently iterating.** JavaScript’s `Set` semantics guarantee no skips — once the unsubscribe returns, future mutations will not invoke that listener.
103
+
104
+ ## Related
105
+
106
+ - [`watch`](watch.md) and [`observe`](observe.md) — per-field reactions.
107
+ - [`getState`](get-state.md) — read the current snapshot synchronously.
@@ -0,0 +1,74 @@
1
+ # `unwatch(field?, callback?)`
2
+
3
+ Removes watched callbacks. Three overloads, distinguished by the arguments you pass.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ unwatch(): FormController
9
+ unwatch(field: string): FormController
10
+ unwatch(field: string, callback: FormFieldWatcher): FormController
11
+ ```
12
+
13
+ ## Parameters
14
+
15
+ | Overload | Description |
16
+ | --- | --- |
17
+ | `unwatch()` | Removes every watcher from every field. |
18
+ | `unwatch(field)` | Removes every watcher for the given field. |
19
+ | `unwatch(field, callback)` | Removes only the matching callback for the given field. |
20
+
21
+ ## Returns
22
+
23
+ The same [`FormController`](../typescript.md#formcontroller) — chainable.
24
+
25
+ ## Behaviour
26
+
27
+ - Watchers added via [`watch`](watch.md) and [`observe`](observe.md) share the same internal registry.
28
+ - For exact-match removal (`field` + `callback`), the same function reference must be passed.
29
+ - For bulk removal (`field` only or no args), all matching watchers are removed.
30
+
31
+ ## Examples
32
+
33
+ ### Remove a specific watcher
34
+
35
+ ```ts
36
+ import { form } from '@samline/forms'
37
+
38
+ const profile = form('profile-form')
39
+
40
+ const callback = (value: unknown) => console.log(value)
41
+ profile.watch('email', callback)
42
+
43
+ profile.unwatch('email', callback)
44
+ ```
45
+
46
+ ### Remove all watchers for a field
47
+
48
+ ```ts
49
+ profile.unwatch('email')
50
+ ```
51
+
52
+ ### Remove every watcher
53
+
54
+ ```ts
55
+ profile.unwatch()
56
+ ```
57
+
58
+ ### Use `observe`’s unsubscribe
59
+
60
+ ```ts
61
+ const stop = profile.observe('email', value => console.log(value))
62
+ stop() // Equivalent to profile.unwatch('email', theCallback)
63
+ ```
64
+
65
+ ## Edge cases
66
+
67
+ - **`unwatch` does not affect subscribers** added via [`subscribe`](subscribe.md). Use the unsubscribe returned by `subscribe`.
68
+ - **Watchers added with the same callback twice are deduplicated** — calling `unwatch` with that callback removes only one entry.
69
+ - **Bulk removal is safe to call at any time** — it does not throw if there are no watchers.
70
+
71
+ ## Related
72
+
73
+ - [`watch`](watch.md) — add a watcher.
74
+ - [`observe`](observe.md) — add a watcher with an unsubscribe function.
@@ -0,0 +1,144 @@
1
+ # `validateFieldValue(field, value, rules, values)`
2
+
3
+ Pure validator. Runs the rule set against a single value. Does not require a controller — useful in tests, custom submission pipelines, or anywhere you need to evaluate validation rules outside of a bound form.
4
+
5
+ ## Signature
6
+
7
+ ```ts
8
+ function validateFieldValue(
9
+ field: string,
10
+ value: FormFieldValue,
11
+ rules: FieldValidationRules,
12
+ values: FormValues
13
+ ): string[]
14
+ ```
15
+
16
+ ## Parameters
17
+
18
+ | Name | Type | Required | Description |
19
+ | --- | --- | --- | --- |
20
+ | `field` | `string` | yes | The field name (used for error messages and to seed `FieldValidationContext`). |
21
+ | `value` | [`FormFieldValue`](../typescript.md#formfieldvalue) | yes | The value to validate. |
22
+ | `rules` | [`FieldValidationRules`](../typescript.md#fieldvalidationrules) | yes | The rule set to apply. |
23
+ | `values` | [`FormValues`](../typescript.md#formvalues) | yes | All current values, exposed to custom validators via `context.values`. |
24
+
25
+ ## Returns
26
+
27
+ A `string[]` of error messages. Empty when the field is valid.
28
+
29
+ ## Behaviour
30
+
31
+ Runs each rule in order:
32
+
33
+ 1. `required` — value must be non-empty (non-empty string, non-empty array, non-undefined).
34
+ 2. `minLength` — string length (or array length) must be ≥ the threshold.
35
+ 3. `maxLength` — string length (or array length) must be ≤ the threshold.
36
+ 4. `pattern` — the string form of the value must match the regex (skipped when the value is empty).
37
+ 5. `numeric`, `min`, and `max` — non-empty values must be strict decimals and satisfy the inclusive bounds.
38
+ 6. `validate` — each field-level custom validator runs in registration order. Returning a string pushes an error; returning `false` pushes `"Validation failed."`; returning `null`, `undefined`, or `true` is treated as a pass.
39
+ 7. `sameAs` — the aggregate value must equal the named value (skipped until both values are non-empty).
40
+ 8. `each` — the value-level rules run independently for every collection member, including any member-level custom validators.
41
+
42
+ Default error messages are produced when the rule was configured without an explicit `message`. Custom messages come from the `{ value, message }` form of `RuleConfig`.
43
+
44
+ ## Examples
45
+
46
+ ### Validate a single value
47
+
48
+ ```ts
49
+ import { validateFieldValue } from '@samline/forms'
50
+
51
+ const errors = validateFieldValue(
52
+ 'email',
53
+ 'invalid',
54
+ {
55
+ required: true,
56
+ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
57
+ },
58
+ { email: 'invalid' }
59
+ )
60
+
61
+ console.log(errors) // ['Value does not match the required pattern.']
62
+ ```
63
+
64
+ ### Match two fields
65
+
66
+ ```ts
67
+ import { validateFieldValue } from '@samline/forms'
68
+
69
+ const errors = validateFieldValue(
70
+ 'confirm',
71
+ 'foo',
72
+ {
73
+ sameAs: { value: 'password', message: 'Passwords do not match.' }
74
+ },
75
+ { password: 'bar', confirm: 'foo' }
76
+ )
77
+
78
+ console.log(errors) // ['Passwords do not match.']
79
+ ```
80
+
81
+ ### Custom error messages
82
+
83
+ ```ts
84
+ import { validateFieldValue } from '@samline/forms'
85
+
86
+ const errors = validateFieldValue(
87
+ 'password',
88
+ 'short',
89
+ {
90
+ minLength: { value: 8, message: 'Use at least 8 characters.' }
91
+ },
92
+ { password: 'short' }
93
+ )
94
+
95
+ console.log(errors) // ['Use at least 8 characters.']
96
+ ```
97
+
98
+ ### Validate strict decimal values and bounds
99
+
100
+ ```ts
101
+ const errors = validateFieldValue(
102
+ 'amount',
103
+ '100.25',
104
+ { numeric: true, min: 0, max: 500 },
105
+ { amount: '100.25' }
106
+ )
107
+
108
+ console.log(errors) // []
109
+ ```
110
+
111
+ ### Validate every collection member
112
+
113
+ ```ts
114
+ const errors = validateFieldValue(
115
+ 'emails',
116
+ ['valid@example.com', 'invalid'],
117
+ {
118
+ each: {
119
+ required: true,
120
+ pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
121
+ }
122
+ },
123
+ { emails: ['valid@example.com', 'invalid'] }
124
+ )
125
+
126
+ console.log(errors) // ['Value does not match the required pattern.']
127
+ ```
128
+
129
+ ## Edge cases
130
+
131
+ - **The pattern rule is skipped for empty values.** This is the standard “required + pattern” pattern: required runs first, then pattern runs only when there is a value.
132
+ - **Numeric rules are skipped for empty values.** Use `required` as well when the field is mandatory.
133
+ - **Numeric parsing is strict.** Surrounding whitespace is trimmed, and ordinary signed decimals such as `-2`, `+3.5`, `.5`, and `10.` are accepted. Exponents, hexadecimal, `Infinity`, `NaN`, and formatted separators are rejected. `min` and `max` are inclusive and also enforce numeric input when `numeric` is omitted.
134
+ - **The `sameAs` rule is skipped until both values are non-empty.** Add `required` to each mandatory field; `sameAs` does not imply requiredness.
135
+ - **Pure validation does not track dependencies.** `dependsOn` is controller metadata and has no effect here; `validateFieldValue` evaluates the supplied values once.
136
+ - **`validateFieldValue` does not read from any DOM.** Pass everything in as arguments.
137
+ - **`validate` runs after the built-in rules.** It receives the full context, including the current value and the other field values.
138
+ - **Multiple custom validators** can be passed as an array — they all run, and any error from any of them is collected.
139
+ - **`each` keeps the public return type flat.** Member errors are appended to the returned `string[]` in member order. Member custom validators receive a zero-based `index`; `element` is unavailable in this DOM-free helper.
140
+
141
+ ## Related
142
+
143
+ - [`validateValues`](validate-values.md) — run a schema against a whole values map.
144
+ - [`validate`](validate.md) — controller-bound equivalent.