@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.
- package/LICENSE +21 -0
- package/README.md +8 -7
- package/dist/browser/global.d.ts +10 -16
- package/dist/browser/global.global.js +319 -49
- package/dist/browser/global.global.js.map +1 -1
- package/dist/browser/index.cjs +1309 -0
- package/dist/browser/index.cjs.map +1 -0
- package/dist/browser/index.d.cts +14 -0
- package/dist/browser/index.d.ts +14 -0
- package/dist/browser/index.js +23 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/chunk-L4GRKSQ5.js +1270 -0
- package/dist/chunk-L4GRKSQ5.js.map +1 -0
- package/dist/index.cjs +134 -23
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -158
- package/dist/index.d.ts +4 -158
- package/dist/index.js +7 -1148
- package/dist/index.js.map +1 -1
- package/dist/registry-BpqJetAt.d.cts +148 -0
- package/dist/registry-BpqJetAt.d.ts +148 -0
- package/docs/api/append.md +96 -0
- package/docs/api/auto-submit.md +91 -0
- package/docs/api/clear-errors.md +69 -0
- package/docs/api/destroy.md +65 -0
- package/docs/api/disable-auto-submit.md +65 -0
- package/docs/api/element.md +78 -0
- package/docs/api/form.md +154 -0
- package/docs/api/format.md +270 -0
- package/docs/api/get-data.md +90 -0
- package/docs/api/get-field.md +87 -0
- package/docs/api/get-state.md +95 -0
- package/docs/api/get-value.md +114 -0
- package/docs/api/index.md +54 -0
- package/docs/api/observe.md +126 -0
- package/docs/api/on-submit.md +90 -0
- package/docs/api/parse-form-data.md +94 -0
- package/docs/api/prefill.md +89 -0
- package/docs/api/regex.md +155 -0
- package/docs/api/reset.md +60 -0
- package/docs/api/revalidate.md +50 -0
- package/docs/api/set-errors.md +97 -0
- package/docs/api/set-value.md +120 -0
- package/docs/api/subscribe.md +91 -0
- package/docs/api/unwatch.md +74 -0
- package/docs/api/validate-field-value.md +108 -0
- package/docs/api/validate-values.md +100 -0
- package/docs/api/validate.md +100 -0
- package/docs/api/watch.md +117 -0
- package/docs/browser.md +156 -0
- package/docs/css-styling.md +187 -0
- package/docs/getting-started.md +217 -0
- package/docs/options.md +259 -0
- package/docs/recipes.md +477 -0
- package/docs/typescript.md +576 -0
- package/docs/vanilla.md +12 -0
- package/package.json +58 -14
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# `validate(fields?)`
|
|
2
|
+
|
|
3
|
+
Runs validation against the configured [`validators`](../options.md#validators). Returns the result and marks the form as validated.
|
|
4
|
+
|
|
5
|
+
## Signature
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
validate(): ValidationResult
|
|
9
|
+
validate(fields: string[]): ValidationResult
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
interface ValidationResult {
|
|
14
|
+
isValid: boolean
|
|
15
|
+
errors: FormErrors
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Parameters
|
|
20
|
+
|
|
21
|
+
| Name | Type | Required | Description |
|
|
22
|
+
| --- | --- | --- | --- |
|
|
23
|
+
| `fields` | `string[]` | no | When provided, only the listed fields are validated. When omitted, every field with rules is validated. |
|
|
24
|
+
|
|
25
|
+
## Returns
|
|
26
|
+
|
|
27
|
+
A [`ValidationResult`](../typescript.md#validationresult):
|
|
28
|
+
|
|
29
|
+
- `isValid` — `true` when the merged error map (validation + manual) is empty.
|
|
30
|
+
- `errors` — the merged error map (validation + manual). The map is a fresh object — safe to mutate.
|
|
31
|
+
|
|
32
|
+
## Behaviour
|
|
33
|
+
|
|
34
|
+
- Marks the form as validated (`state.isValidated = true`). This means future field changes will run validation again, even when the controller was created with `autoValidate: false`.
|
|
35
|
+
- Re-syncs visual attributes (`css-filled` / `css-error`) for the affected fields.
|
|
36
|
+
- After the form is validated, an input event also revalidates fields whose `sameAs` rule references the changed field. Cycles are deduplicated and each affected field runs once.
|
|
37
|
+
- Subscribers are not notified — this method is a pure read of the rule engine. To run validation and notify subscribers, use [`subscribe`](subscribe.md) and read [`getState()`](get-state.md).
|
|
38
|
+
|
|
39
|
+
## Examples
|
|
40
|
+
|
|
41
|
+
### Validate the whole form
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { form } from '@samline/forms'
|
|
45
|
+
|
|
46
|
+
const signup = form('signup-form', {
|
|
47
|
+
validators: {
|
|
48
|
+
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
|
|
49
|
+
password: { required: true, minLength: 8 }
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const result = signup.validate()
|
|
54
|
+
|
|
55
|
+
if (!result.isValid) {
|
|
56
|
+
console.warn('errors:', result.errors)
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Validate a single field
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
signup.validate(['email'])
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Validate a sub-group of fields (e.g. a wizard step)
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const wizard = form('wizard-form', {
|
|
70
|
+
validators: {
|
|
71
|
+
'step-1.name': { required: true },
|
|
72
|
+
'step-1.email': { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
|
|
73
|
+
'step-2.address': { required: true },
|
|
74
|
+
'step-2.city': { required: true }
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
function nextStep(step: 1 | 2) {
|
|
79
|
+
const fields = step === 1
|
|
80
|
+
? ['step-1.name', 'step-1.email']
|
|
81
|
+
: ['step-2.address', 'step-2.city']
|
|
82
|
+
|
|
83
|
+
return wizard.validate(fields).isValid
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Edge cases
|
|
88
|
+
|
|
89
|
+
- **`validate` ignores manual errors** during computation, but the returned `errors` includes them (merged with validation errors). The `isValid` flag reflects the merged map.
|
|
90
|
+
- **Validation rules must be configured** via `options.validators`. Fields without rules are always considered valid.
|
|
91
|
+
- **An explicit partial call validates exactly the requested fields.** `validate(['password'])` does not expand `sameAs` dependencies; dependency expansion happens for input events, including those dispatched by `setValue()`.
|
|
92
|
+
- **Custom validators receive `{ field, value, values }`** — see [`FieldValidationContext`](../typescript.md#fieldvalidationcontext).
|
|
93
|
+
- **A custom validator that returns `false` pushes the generic message `"Validation failed."`.**
|
|
94
|
+
- **`validate()` is the same as calling [`revalidate()`](revalidate.md)`** — they are aliases for readability.
|
|
95
|
+
|
|
96
|
+
## Related
|
|
97
|
+
|
|
98
|
+
- [`revalidate`](revalidate.md) — alias of `validate`.
|
|
99
|
+
- [`setErrors`](set-errors.md) and [`clearErrors`](clear-errors.md) — manual errors.
|
|
100
|
+
- [`validateValues`](../api/validate-values.md) — pure helper for use without a controller.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# `watch(field, callback)`
|
|
2
|
+
|
|
3
|
+
Registers a callback that fires every time a field changes. Chainable alias of [`observe`](observe.md); use it when you want fluent setup without holding the unsubscribe function.
|
|
4
|
+
|
|
5
|
+
## Signature
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
watch(
|
|
9
|
+
field: string,
|
|
10
|
+
callback: FormFieldWatcher
|
|
11
|
+
): FormController
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Parameters
|
|
15
|
+
|
|
16
|
+
| Name | Type | Required | Description |
|
|
17
|
+
| --- | --- | --- | --- |
|
|
18
|
+
| `field` | `string` | yes | The `name` attribute of the field to watch. |
|
|
19
|
+
| `callback` | [`FormFieldWatcher`](../typescript.md#formfieldwatcher) | yes | Invoked with `(value, field, form, state)` on every change. `field` is the observed DOM element(s) (same shape as [`getField`](../typescript.md#formfieldelement--formfieldelement--null) returns), handy for manipulating the actual `<input>` / `<select>` / `<textarea>` without re-querying it. |
|
|
20
|
+
|
|
21
|
+
## Returns
|
|
22
|
+
|
|
23
|
+
The same [`FormController`](../typescript.md#formcontroller) — chainable.
|
|
24
|
+
|
|
25
|
+
## Behaviour
|
|
26
|
+
|
|
27
|
+
- The callback fires immediately on registration and after every change, matching `observe()`; only the return type differs.
|
|
28
|
+
- A field can have multiple watchers.
|
|
29
|
+
- Watchers persist for the lifetime of the controller. Use [`unwatch`](unwatch.md) to remove them, or [`observe`](observe.md) if you need explicit unsubscribe semantics.
|
|
30
|
+
- The callback fires after the controller has processed the change — manual errors are cleared (when applicable), validation has run for fields with rules, visual state has been re-synced, and subscribers have been notified.
|
|
31
|
+
|
|
32
|
+
## Examples
|
|
33
|
+
|
|
34
|
+
### Show a live preview
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { form } from '@samline/forms'
|
|
38
|
+
|
|
39
|
+
const profile = form('profile-form')
|
|
40
|
+
|
|
41
|
+
profile.watch('bio', value => {
|
|
42
|
+
const preview = document.querySelector('#bio-preview')
|
|
43
|
+
if (preview && typeof value === 'string') preview.textContent = value
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Reactive password strength
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { form } from '@samline/forms'
|
|
51
|
+
|
|
52
|
+
const signup = form('signup-form')
|
|
53
|
+
|
|
54
|
+
signup.watch('password', value => {
|
|
55
|
+
const meter = document.querySelector('#password-strength')
|
|
56
|
+
if (meter && typeof value === 'string') {
|
|
57
|
+
meter.textContent = scorePassword(value)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Share a single handler across fields
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import { form } from '@samline/forms'
|
|
66
|
+
|
|
67
|
+
const search = form('search-form')
|
|
68
|
+
|
|
69
|
+
const logField = (
|
|
70
|
+
value: unknown,
|
|
71
|
+
field: HTMLInputElement | HTMLInputElement[] | null
|
|
72
|
+
) => {
|
|
73
|
+
if (field && 'name' in field) {
|
|
74
|
+
console.log(`${field.name} =`, value)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
search.watch('q', logField).watch('category', logField)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Interact with the field element directly
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { form } from '@samline/forms'
|
|
85
|
+
|
|
86
|
+
const checkout = form('checkout-form')
|
|
87
|
+
|
|
88
|
+
checkout.watch('country', (_value, field, _form, _state) => {
|
|
89
|
+
if (field instanceof HTMLSelectElement) {
|
|
90
|
+
// Toggle a CSS class on the actual <select> without re-querying it.
|
|
91
|
+
field.classList.toggle('has-zip', field.value !== 'US')
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Chain with other setup
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
form('search-form')
|
|
100
|
+
.watch('q', value => console.log('q =', value))
|
|
101
|
+
.watch('category', value => console.log('category =', value))
|
|
102
|
+
.onSubmit((_form, data) => console.log('submit', data))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Edge cases
|
|
106
|
+
|
|
107
|
+
- **The callback receives the value, not the event object.** Use [`getField`](get-field.md) if you need the underlying DOM element.
|
|
108
|
+
- **`watch` fires immediately**, just like [`observe`](observe.md); it differs only by returning the controller instead of an unsubscribe function.
|
|
109
|
+
- **A field name that does not match any DOM field** receives one initial call with `undefined` and `null`. It runs again only if an `input` event later comes from a field with that name.
|
|
110
|
+
- **Watchers are not invoked from [`setValue`](set-value.md) directly.** `setValue` writes the value and dispatches one `input` event, which goes through the same delegated listener path as user input.
|
|
111
|
+
|
|
112
|
+
## Related
|
|
113
|
+
|
|
114
|
+
- [`observe`](observe.md) — same immediate callback, but returns an unsubscribe function.
|
|
115
|
+
- [`unwatch`](unwatch.md) — remove watched callbacks.
|
|
116
|
+
- [`subscribe`](subscribe.md) — react to the whole form state.
|
|
117
|
+
- [`format`](format.md) — when a field is formatted, `watch('<field>', cb)` fires with the raw value and `watch('<field>_displayed', cb)` fires with the formatted value. Both fire on the same keystroke. See [the mirror convention](format.md#the-mirror-convention).
|
package/docs/browser.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Browser
|
|
2
|
+
|
|
3
|
+
## When to use this variant
|
|
4
|
+
|
|
5
|
+
Use the browser build when you do not have a bundler and need to integrate the package directly into HTML, Shopify, WordPress, or any traditional template that does not run through a build step.
|
|
6
|
+
|
|
7
|
+
For every other case (modern apps, bundlers, TypeScript projects), use the main vanilla entrypoint — see [docs/getting-started.md](getting-started.md).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Script tag
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<script src="https://unpkg.com/@samline/forms@2.6.0/dist/browser/global.global.js"></script>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
> Pin the version in production. Replace `2.6.0` with the version you ship.
|
|
18
|
+
|
|
19
|
+
The bundle is a single IIFE that registers a global object.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Global object
|
|
24
|
+
|
|
25
|
+
The browser build exposes `window.Forms` (also reachable via `globalThis.Forms`).
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
window.Forms = {
|
|
29
|
+
form,
|
|
30
|
+
newForm,
|
|
31
|
+
destroyForm,
|
|
32
|
+
available
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- `form` is the same factory exported by `@samline/forms` — see [`docs/api/form.md`](api/form.md) for the signature.
|
|
37
|
+
- `newForm` and `destroyForm` are ergonomic wrappers that keep a registry under `Forms.available`, keyed by the form id.
|
|
38
|
+
|
|
39
|
+
The factory returns a `FormController` with the same signatures, semantics, and behaviours as the main vanilla entrypoint — every per-method page under [docs/api/](api/index.md) applies.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Minimal example
|
|
44
|
+
|
|
45
|
+
```html
|
|
46
|
+
<form id="contact-form">
|
|
47
|
+
<input name="email" type="email" />
|
|
48
|
+
<input name="message" />
|
|
49
|
+
<button type="submit">Send</button>
|
|
50
|
+
</form>
|
|
51
|
+
|
|
52
|
+
<script src="https://unpkg.com/@samline/forms@2.6.0/dist/browser/global.global.js"></script>
|
|
53
|
+
<script>
|
|
54
|
+
const contactForm = window.Forms.newForm({
|
|
55
|
+
id: 'contact-form',
|
|
56
|
+
options: {
|
|
57
|
+
validators: {
|
|
58
|
+
email: {
|
|
59
|
+
required: true,
|
|
60
|
+
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
61
|
+
},
|
|
62
|
+
message: { required: true, minLength: 10 }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
contactForm.onSubmit(async (_element, _data, formData) => {
|
|
68
|
+
await fetch('/api/contact', { method: 'POST', body: formData })
|
|
69
|
+
contactForm.reset()
|
|
70
|
+
})
|
|
71
|
+
</script>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The controller returned by `newForm` is the same instance stored under `Forms.available['contact-form']`. Use `window.Forms.destroyForm('contact-form')` later to call `destroy()` and remove it from the registry.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Registry helpers
|
|
79
|
+
|
|
80
|
+
| Helper | Purpose |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `Forms.newForm({ id, options })` | Destroy any existing controller under the id, build a replacement via `Forms.form(id, options)`, and store it in `Forms.available[id]`. Logs `Form ID is required` and returns early if `id` is missing. |
|
|
83
|
+
| `Forms.destroyForm(id)` | Look up `Forms.available[id]`, call `destroy()`, and delete the entry. Missing ids log an error; absent entries log a warning. |
|
|
84
|
+
| `Forms.available` | Shared mutable registry: `{ [id: string]: FormController }`. Prefer `newForm()` and `destroyForm()` to manage it. |
|
|
85
|
+
|
|
86
|
+
Use `Forms.form` directly when you do not want the registry side-effect (for example, transient controllers in tests).
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## Surface reference
|
|
91
|
+
|
|
92
|
+
The browser bundle ships only the registry helpers plus the `form` factory. Every controller method is documented under [docs/api/](api/index.md).
|
|
93
|
+
|
|
94
|
+
| Global | Purpose |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `Forms.form(target, options?)` | Bind a controller to a form (recommended). See [`docs/api/form.md`](api/form.md). |
|
|
97
|
+
| `Forms.newForm({ id, options? })` | Build + register a controller in `Forms.available[id]`. |
|
|
98
|
+
| `Forms.destroyForm(id)` | Destroy + unregister a controller by id. |
|
|
99
|
+
| `Forms.available` | Registry of active controllers keyed by id. |
|
|
100
|
+
|
|
101
|
+
### Controller methods
|
|
102
|
+
|
|
103
|
+
The controller returned by `form` / `newForm` exposes the methods documented under [docs/api/](api/index.md):
|
|
104
|
+
|
|
105
|
+
- Lifecycle: [`element`](api/element.md), [`reset`](api/reset.md), [`destroy`](api/destroy.md).
|
|
106
|
+
- Submission: [`onSubmit`](api/on-submit.md), [`autoSubmit`](api/auto-submit.md), [`disableAutoSubmit`](api/disable-auto-submit.md).
|
|
107
|
+
- Field observation: [`watch`](api/watch.md), [`observe`](api/observe.md), [`unwatch`](api/unwatch.md), [`subscribe`](api/subscribe.md).
|
|
108
|
+
- Field values: [`setValue`](api/set-value.md), [`getValue`](api/get-value.md), [`getField`](api/get-field.md), [`prefill`](api/prefill.md).
|
|
109
|
+
- Validation: [`validate`](api/validate.md), [`revalidate`](api/revalidate.md), [`setErrors`](api/set-errors.md), [`clearErrors`](api/clear-errors.md).
|
|
110
|
+
- State and data: [`getData`](api/get-data.md), [`getState`](api/get-state.md), [`append`](api/append.md).
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## TypeScript users
|
|
115
|
+
|
|
116
|
+
The module entrypoint `@samline/forms/browser` ships types, registers the `Window.Forms` declaration, and exposes the same API as module exports. The direct IIFE subpath does not expose a typed module export.
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import Forms from '@samline/forms/browser'
|
|
120
|
+
|
|
121
|
+
Forms.newForm({ id: 'contact-form' })
|
|
122
|
+
window.Forms.destroyForm('contact-form')
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Common pitfalls
|
|
128
|
+
|
|
129
|
+
- **Pin the version.** The CDN URL above is `2.6.0`. Replace it whenever you upgrade.
|
|
130
|
+
- **The script must be loaded before any code that uses `window.Forms`.** Place the `<script>` tag in `<head>` with `defer`, or before the user script in `<body>`.
|
|
131
|
+
- **No bundler means no tree-shaking.** The global browser bundle includes the controller and formatter peer. Inspect the published artifact when bundle size is a constraint.
|
|
132
|
+
- **CSP:** if your site uses a strict Content Security Policy, allow `unpkg.com` in `script-src` (or self-host the file).
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Using the same shape from a bundler
|
|
137
|
+
|
|
138
|
+
If you have a bundler but still want the `newForm` / `destroyForm` / `available` ergonomics (without the IIFE and without `window.Forms` auto-installed), use the vanilla `browser` singleton instead:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { browser } from '@samline/forms'
|
|
142
|
+
|
|
143
|
+
browser.newForm({ id: 'contact-form' })
|
|
144
|
+
browser.destroyForm('contact-form')
|
|
145
|
+
browser.available // { 'contact-form': FormController }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`browser` is a module-level singleton with a shared `available` registry. Spreading it copies references but does not create an independent registry because its methods close over the same `available` object. For isolated registries, call `form()` and keep controllers in your own `Map`. See [Browser registry helpers](getting-started.md#browser-registry-helpers) for the composition pattern, including how to attach `regex` from `@samline/formatter`.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## Next steps
|
|
153
|
+
|
|
154
|
+
- Concepts and lifecycle: [docs/getting-started.md](getting-started.md).
|
|
155
|
+
- Full options reference: [docs/options.md](options.md).
|
|
156
|
+
- End-to-end patterns: [docs/recipes.md](recipes.md).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# CSS Styling
|
|
2
|
+
|
|
3
|
+
The controller does not ship styles. It only toggles boolean attributes on fields so your CSS can react. By default those attributes are `css-filled` and `css-error`. You can rename them with [`options.attributes`](options.md#attributes).
|
|
4
|
+
|
|
5
|
+
This page shows how the attributes behave, which elements they apply to, and how to write CSS that pairs cleanly with them.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How attributes are applied
|
|
10
|
+
|
|
11
|
+
| Attribute | Added when | Removed when |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `css-filled` | The field has a non-empty value (for checkboxes/radios, when checked; for files, when one or more files are selected). | The field becomes empty. |
|
|
14
|
+
| `css-error` | The field has at least one error — either from built-in validation rules or from [`setErrors`](api/set-errors.md). | All errors for that field are cleared (via [`clearErrors`](api/clear-errors.md), [`reset`](api/reset.md), or a successful re-validation). |
|
|
15
|
+
|
|
16
|
+
The attributes are added and removed on:
|
|
17
|
+
|
|
18
|
+
- Mount (initial sync).
|
|
19
|
+
- Every delegated `input` event, including controls associated through `form="id"`.
|
|
20
|
+
- Every explicit call to [`validate`](api/validate.md), [`revalidate`](api/revalidate.md), [`setErrors`](api/set-errors.md), or [`clearErrors`](api/clear-errors.md).
|
|
21
|
+
- DOM mutations detected by the controller’s `MutationObserver` (new fields, changed `name` / `type` attributes).
|
|
22
|
+
|
|
23
|
+
The attributes are also removed everywhere when you call [`reset`](api/reset.md).
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Default selectors
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
/* any field that has a value */
|
|
31
|
+
[css-filled] { /* ... */ }
|
|
32
|
+
|
|
33
|
+
/* any field with at least one error */
|
|
34
|
+
[css-error] { /* ... */ }
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
These attributes are boolean: present or absent. They never have a value. The selector `[css-filled]` matches both `[css-filled]` and `[css-filled=""]`.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Recipes
|
|
42
|
+
|
|
43
|
+
### Text inputs
|
|
44
|
+
|
|
45
|
+
```css
|
|
46
|
+
input[css-filled] {
|
|
47
|
+
border-color: var(--color-border-emphasis);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
input[css-error] {
|
|
51
|
+
border-color: var(--color-danger);
|
|
52
|
+
background-color: var(--color-danger-soft);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
input[css-error]:focus-visible {
|
|
56
|
+
outline-color: var(--color-danger);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Textarea
|
|
61
|
+
|
|
62
|
+
```css
|
|
63
|
+
textarea[css-filled] {
|
|
64
|
+
background-color: var(--color-surface-soft);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
textarea[css-error] {
|
|
68
|
+
border-color: var(--color-danger);
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Selects
|
|
73
|
+
|
|
74
|
+
```css
|
|
75
|
+
select[css-filled] {
|
|
76
|
+
background-color: var(--color-surface-soft);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
select[css-error] {
|
|
80
|
+
border-color: var(--color-danger);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Checkboxes and radios
|
|
85
|
+
|
|
86
|
+
The attribute is set on the input itself when it’s checked.
|
|
87
|
+
|
|
88
|
+
```css
|
|
89
|
+
input[type="checkbox"][css-filled],
|
|
90
|
+
input[type="radio"][css-filled] {
|
|
91
|
+
outline: 2px solid var(--color-accent);
|
|
92
|
+
outline-offset: 2px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
input[type="checkbox"][css-error],
|
|
96
|
+
input[type="radio"][css-error] {
|
|
97
|
+
outline: 2px solid var(--color-danger);
|
|
98
|
+
outline-offset: 2px;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### File inputs
|
|
103
|
+
|
|
104
|
+
```css
|
|
105
|
+
input[type="file"][css-filled] {
|
|
106
|
+
border-color: var(--color-success);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
input[type="file"][css-error] {
|
|
110
|
+
border-color: var(--color-danger);
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Styling the wrapper
|
|
115
|
+
|
|
116
|
+
A common pattern is to style a parent label that contains the input:
|
|
117
|
+
|
|
118
|
+
```html
|
|
119
|
+
<label class="field">
|
|
120
|
+
<span>Email</span>
|
|
121
|
+
<input name="email" type="email" />
|
|
122
|
+
</label>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```css
|
|
126
|
+
.field {
|
|
127
|
+
display: grid;
|
|
128
|
+
gap: 0.25rem;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.field:has([css-filled]) > span {
|
|
132
|
+
color: var(--color-text-emphasis);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.field:has([css-error]) > span {
|
|
136
|
+
color: var(--color-danger);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
> `:has()` is supported in evergreen browsers. If you need older browser support, attach the attributes to the wrapper from your own code or use class-based selectors instead.
|
|
141
|
+
|
|
142
|
+
> **Formatted fields.** The hidden raw mirror created by [`format()`](api/format.md) lives at the end of the form, outside any label. The controller always applies `css-filled` and `css-error` to the **visible** input (the one inside the label) for fields wired through `format()` — the hidden is intentionally excluded from the `:has()` selector. This is what makes the recipe above work without changes for formatted fields.
|
|
143
|
+
|
|
144
|
+
### Custom attribute names
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
form('signup-form', {
|
|
148
|
+
attributes: { filled: 'is-filled', error: 'is-invalid' }
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```css
|
|
153
|
+
.is-filled { /* ... */ }
|
|
154
|
+
.is-invalid { /* ... */ }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Accessibility
|
|
160
|
+
|
|
161
|
+
The controller sets `aria-invalid="true"` while a field has validation or manual errors and removes it when the field becomes valid. Invalid submission focuses the first connected, enabled, non-hidden invalid field. Provide `aria-describedby` and an error message element in your template:
|
|
162
|
+
|
|
163
|
+
```html
|
|
164
|
+
<label class="field">
|
|
165
|
+
<span>Email</span>
|
|
166
|
+
<input name="email" type="email" aria-describedby="email-error" />
|
|
167
|
+
<small id="email-error" hidden></small>
|
|
168
|
+
</label>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The controller’s [`getState()`](api/get-state.md) returns the current errors per field so you can reveal and populate the message from a single source of truth:
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const state = profileForm.getState()
|
|
175
|
+
|
|
176
|
+
emailErrorEl.hidden = !state.errors.email
|
|
177
|
+
emailErrorEl.textContent = state.errors.email?.[0] ?? ''
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Common pitfalls
|
|
183
|
+
|
|
184
|
+
- **The attributes have no value.** A selector like `[css-filled="true"]` will not match. Use `[css-filled]` instead.
|
|
185
|
+
- **`css-error` is set whenever there is any error**, including manual errors from [`setErrors`](api/set-errors.md). Combine your selector with the actual message you render.
|
|
186
|
+
- **Field labels do not automatically receive the attributes.** Apply styles to the input itself, or use `:has()` / a wrapper class.
|
|
187
|
+
- **CSS is not the only place the attributes appear.** Treat them as a styling hook, not as state. Read [`getState()`](api/get-state.md) if you need the authoritative state.
|