actual-css 0.2.0 → 0.3.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/CHANGELOG.md +38 -1
- package/README.md +24 -9
- package/dist/actual-themes.min.css +1 -0
- package/dist/actual.css +1244 -915
- package/dist/actual.js +1 -3
- package/dist/actual.min.css +1 -1
- package/dist/optional.css +1048 -0
- package/dist/optional.min.css +1 -0
- package/docs/design-notes/browser-support.md +94 -14
- package/docs/design-notes/cascade-layer.md +1 -1
- package/docs/design-notes/enhancement-contract.md +11 -14
- package/docs/design-notes/forced-colors.md +105 -0
- package/docs/design-notes/naming.md +2 -1
- package/docs/design-notes/platform-alignment.md +492 -0
- package/docs/design-notes/references.md +8 -0
- package/docs/design-notes/widget-primitives.md +37 -1
- package/docs/navigation.json +135 -0
- package/docs/pages/components/accordion.md +80 -0
- package/docs/pages/components/alert.md +203 -0
- package/docs/pages/components/aura.md +35 -0
- package/docs/pages/components/avatar.md +95 -0
- package/docs/pages/components/badge.md +102 -0
- package/docs/pages/components/breadcrumb.md +19 -0
- package/docs/pages/components/button.md +205 -0
- package/docs/pages/components/card.md +183 -0
- package/docs/pages/components/chat.md +50 -0
- package/docs/pages/components/dialog.md +367 -0
- package/docs/pages/components/drawer.md +113 -0
- package/docs/pages/components/fab.md +118 -0
- package/docs/pages/components/key.md +33 -0
- package/docs/pages/components/meter.md +23 -0
- package/docs/pages/components/navbar.md +92 -0
- package/docs/pages/components/pagination.md +55 -0
- package/docs/pages/components/progress.md +29 -0
- package/docs/pages/components/skeleton.md +38 -0
- package/docs/pages/components/spinner.md +93 -0
- package/docs/pages/components/table.md +102 -0
- package/docs/pages/enhancements/flyout.md +391 -0
- package/docs/pages/enhancements/overview.md +36 -0
- package/docs/pages/enhancements/scrollspy.md +71 -0
- package/docs/pages/enhancements/status-bar.md +72 -0
- package/docs/pages/enhancements/tabs.md +80 -0
- package/docs/pages/enhancements/tooltip.md +130 -0
- package/docs/pages/examples/overview.md +41 -0
- package/docs/pages/forms/choice-cards.md +102 -0
- package/docs/pages/forms/controls.md +32 -0
- package/docs/pages/forms/detached-actions.md +59 -0
- package/docs/pages/forms/floating-field.md +88 -0
- package/docs/pages/forms/form-actions.md +56 -0
- package/docs/pages/forms/input-policy.md +91 -0
- package/docs/pages/forms/joined-controls.md +37 -0
- package/docs/pages/forms/labels.md +42 -0
- package/docs/pages/forms/otp.md +50 -0
- package/docs/pages/forms/overview.md +188 -0
- package/docs/pages/forms/password.md +40 -0
- package/docs/pages/forms/range.md +17 -0
- package/docs/pages/forms/states-and-sizes.md +321 -0
- package/docs/pages/forms/sticky-actions.md +187 -0
- package/docs/pages/forms/switch.md +31 -0
- package/docs/pages/forms/validation.md +241 -0
- package/docs/{mental-model.md → pages/foundations/philosophy.md} +33 -18
- package/docs/pages/foundations/theming.md +92 -0
- package/docs/pages/foundations/tokens.md +421 -0
- package/docs/{typography.md → pages/foundations/typography.md} +27 -9
- package/docs/pages/guides/bootstrap.md +438 -0
- package/docs/pages/guides/introduction.md +157 -0
- package/docs/pages/guides/optional-components.md +33 -0
- package/docs/pages/guides/progressive-enhancement.md +686 -0
- package/docs/pages/guides/tailwind.md +455 -0
- package/docs/pages/layout/app-shell.md +25 -0
- package/docs/pages/layout/center.md +29 -0
- package/docs/pages/layout/cluster.md +57 -0
- package/docs/pages/layout/frame.md +30 -0
- package/docs/pages/layout/grid.md +142 -0
- package/docs/pages/layout/list.md +41 -0
- package/docs/pages/layout/media.md +21 -0
- package/docs/pages/layout/overview.md +37 -0
- package/docs/pages/layout/scroll-snap.md +54 -0
- package/docs/pages/layout/sidebar-layout.md +27 -0
- package/docs/pages/layout/stack.md +18 -0
- package/docs/pages/layout/switcher.md +36 -0
- package/docs/pages/layout/topbar.md +23 -0
- package/docs/pages/patterns/actions.md +52 -0
- package/docs/pages/patterns/code-block.md +36 -0
- package/docs/pages/patterns/exclusive-accordion.md +16 -0
- package/docs/pages/patterns/hierarchical-nav.md +26 -0
- package/docs/pages/patterns/icon-input.md +25 -0
- package/docs/pages/patterns/intent-nesting.md +3 -0
- package/docs/pages/patterns/navigation-list.md +235 -0
- package/docs/pages/patterns/overview.md +58 -0
- package/docs/pages/patterns/structured-lists.md +47 -0
- package/docs/pages/utilities/accessibility.md +34 -0
- package/docs/pages/utilities/flex.md +105 -0
- package/docs/pages/utilities/links.md +18 -0
- package/docs/pages/utilities/list.md +31 -0
- package/docs/pages/utilities/overflow.md +43 -0
- package/docs/pages/utilities/overview.md +18 -0
- package/docs/pages/utilities/shape.md +25 -0
- package/docs/pages/utilities/sizing-wrapping.md +115 -0
- package/docs/pages/utilities/spacing.md +79 -0
- package/docs/pages/utilities/text.md +43 -0
- package/llms.txt +4 -4
- package/package.json +20 -12
- package/src/css/actual.css +12 -1
- package/src/css/base.css +3 -5
- package/src/css/components/accordion.css +18 -30
- package/src/css/components/actions.css +4 -1
- package/src/css/components/alert.css +126 -99
- package/src/css/components/avatar.css +16 -10
- package/src/css/components/badge.css +55 -35
- package/src/css/components/breadcrumb.css +7 -15
- package/src/css/components/busy.css +6 -0
- package/src/css/components/button.css +78 -32
- package/src/css/components/card.css +17 -37
- package/src/css/components/drawer.css +30 -13
- package/src/css/components/flyout.css +39 -109
- package/src/css/components/index.css +1 -0
- package/src/css/components/join.css +14 -2
- package/src/css/components/key.css +2 -0
- package/src/css/components/menu.css +125 -0
- package/src/css/components/meter.css +0 -23
- package/src/css/components/modal.css +162 -7
- package/src/css/components/navbar.css +17 -18
- package/src/css/components/overline.css +0 -7
- package/src/css/components/pagination.css +1 -1
- package/src/css/components/progress.css +8 -26
- package/src/css/components/skeleton.css +7 -0
- package/src/css/components/spinner.css +6 -7
- package/src/css/components/status-bar.css +15 -24
- package/src/css/components/surface.css +2 -8
- package/src/css/components/tab.css +19 -48
- package/src/css/components/table.css +7 -5
- package/src/css/components/tooltip.css +14 -7
- package/src/css/focus.css +14 -38
- package/src/css/forms/choice-card.css +13 -14
- package/src/css/forms/choice.css +21 -27
- package/src/css/forms/control.css +16 -17
- package/src/css/forms/custom-select.css +9 -11
- package/src/css/forms/form-actions.css +14 -3
- package/src/css/forms/form.css +7 -4
- package/src/css/forms/index.css +4 -4
- package/src/css/forms/input-icon.css +30 -12
- package/src/css/forms/native.css +50 -11
- package/src/css/forms/range.css +17 -9
- package/src/css/forms/select.css +8 -8
- package/src/css/forms/switch.css +14 -12
- package/src/css/forms/validation.css +32 -5
- package/src/css/grid.css +19 -18
- package/src/css/intents.css +2 -2
- package/src/css/layout.css +29 -1
- package/src/css/optional/aura.css +93 -0
- package/src/css/optional/chat.css +105 -0
- package/src/css/optional/fab.css +104 -0
- package/src/css/optional/floating-field.css +87 -0
- package/src/css/optional/index.css +5 -0
- package/src/css/optional/layout-extra.css +7 -4
- package/src/css/optional/otp.css +115 -0
- package/src/css/optional/scroll-snap.css +6 -0
- package/src/css/optional/scroller.css +13 -7
- package/src/css/optional/typography-fluid.css +5 -0
- package/src/css/optional/utilities-extra.css +17 -2
- package/src/css/prose.css +18 -8
- package/src/css/reset.css +0 -10
- package/src/css/theme.css +21 -5
- package/src/css/themes/cyberpunk.css +1 -1
- package/src/css/themes/dim.css +3 -3
- package/src/css/themes/edge.css +8 -10
- package/src/css/themes/index.css +3 -3
- package/src/css/themes/{dark.css → indigo.css} +5 -4
- package/src/css/themes/neon.css +5 -5
- package/src/css/themes/square.css +0 -3
- package/src/css/tokens.css +74 -20
- package/src/css/utilities.css +8 -10
- package/src/css/variants.css +37 -65
- package/src/js/command.js +1 -3
- package/src/js/context-menu.js +67 -35
- package/src/js/dialog.js +68 -79
- package/src/js/dismiss.js +15 -0
- package/src/js/enhance.js +54 -23
- package/src/js/enhancement-loader.js +301 -0
- package/src/js/events.js +3 -0
- package/src/js/filter.js +3 -3
- package/src/js/floating.js +101 -131
- package/src/js/flyout.js +104 -87
- package/src/js/focus-group.js +116 -0
- package/src/js/focus.js +6 -1
- package/src/js/index.js +4 -2
- package/src/js/input.js +1 -1
- package/src/js/keys.js +27 -0
- package/src/js/mask.js +7 -6
- package/src/js/menu.js +87 -34
- package/src/js/parse-config.js +47 -0
- package/src/js/password.js +9 -1
- package/src/js/scrollspy.js +12 -4
- package/src/js/selectors.js +0 -7
- package/src/js/status.js +3 -1
- package/src/js/surface.js +185 -136
- package/src/js/tab.js +36 -53
- package/src/js/tooltip.js +53 -31
- package/src/js/validation.js +34 -8
- package/dist/actual.js.map +0 -31
- package/docs/README.md +0 -28
- package/docs/components.md +0 -860
- package/docs/design-notes/previous-work.md +0 -5
- package/docs/forms.md +0 -1265
- package/docs/javascript.md +0 -496
- package/docs/layout.md +0 -480
- package/docs/patterns.md +0 -504
- package/docs/reserved.md +0 -7
- package/docs/tokens.md +0 -455
- package/docs/ui.md +0 -929
- package/docs/utilities.md +0 -384
- package/src/css/dialog-fallback.css +0 -56
- package/src/js/dialog-fallback.js +0 -163
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
# The JavaScript Runtime
|
|
2
|
+
|
|
3
|
+
> The optional JavaScript runtime that progressively enhances Actual's semantic markup — self-registering side-effect modules, the `enhance()` DOM lifecycle, stateless command routing, and the floating and surface contracts for custom widgets.
|
|
4
|
+
|
|
5
|
+
Actual's JavaScript modules are optional. Importing `actual-css/js` registers the
|
|
6
|
+
complete default runtime — every built-in enhancer, including validation and
|
|
7
|
+
status; importing a specific module registers only that behavior. The runtime
|
|
8
|
+
does not require `DOMContentLoaded` or manual init calls, and it never touches
|
|
9
|
+
`window`: there is no global object to call into.
|
|
10
|
+
|
|
11
|
+
## Browser support
|
|
12
|
+
|
|
13
|
+
The runtime targets the **Minimal** browser tier. Browsers in the Degraded tier
|
|
14
|
+
may still run some enhancements, but JavaScript behavior below Minimal is
|
|
15
|
+
unsupported — it is not tested, documented as supported, or preserved when the
|
|
16
|
+
runtime evolves. The runtime assumes the modern platform primitives available
|
|
17
|
+
across Minimal and ships no compatibility layer or polyfill.
|
|
18
|
+
|
|
19
|
+
| Tier | Firefox | Safari | Chromium |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| Degraded | 78+ | 14+ | 88+ |
|
|
22
|
+
| **Minimal** | **98+** | **15.4+** | **99+** |
|
|
23
|
+
| Intermediate | 121+ | 16+ | 106+ |
|
|
24
|
+
| Recommended | 129+ | 17.5+ | 123+ |
|
|
25
|
+
|
|
26
|
+
The tier definitions live in the browser-support design note.
|
|
27
|
+
|
|
28
|
+
## Overview
|
|
29
|
+
|
|
30
|
+
All JS modules are side-effect modules: importing them registers behavior
|
|
31
|
+
automatically. There is no init call, public global object, or configuration.
|
|
32
|
+
This keeps imports declarative and tree-shakeable.
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import "actual-css/js";
|
|
36
|
+
import "actual-css/js/dialog";
|
|
37
|
+
import "actual-css/js/filter";
|
|
38
|
+
import "actual-css/js/mask";
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Components with a real element lifecycle use the shared `enhance()` helper:
|
|
42
|
+
initial matching elements are connected, inserted matching elements are connected
|
|
43
|
+
later, and removed elements run cleanup. Declarative `command` / `commandfor`
|
|
44
|
+
actions use a single delegated click listener instead; triggers and targets are
|
|
45
|
+
never tracked.
|
|
46
|
+
|
|
47
|
+
`enhance()` observes DOM insertions and removals, not attribute changes. If an
|
|
48
|
+
already-connected element receives a behavior attribute later, call the returned
|
|
49
|
+
`refresh(node)` handle. Behavior attributes are setup-time contracts: removing
|
|
50
|
+
one does not disconnect an active behavior. If an application needs a live
|
|
51
|
+
enable/disable switch, keep that state inside its enhancer and clean it up when
|
|
52
|
+
the element leaves the DOM.
|
|
53
|
+
|
|
54
|
+
Modules are safe to import during SSR — registration is a no-op when there is no
|
|
55
|
+
DOM. The runtime remains active until the page unloads; there is no teardown
|
|
56
|
+
handle, and cleanup happens per element when an element leaves the DOM.
|
|
57
|
+
|
|
58
|
+
## The default runtime
|
|
59
|
+
|
|
60
|
+
Loading `dist/actual.js` as a plain `<script>` (no `type="module"`, no bundler,
|
|
61
|
+
no `import`) gets the full runtime. Static HTML that needs to trigger something
|
|
62
|
+
from its own inline `<script>` dispatches the module's public event instead of
|
|
63
|
+
calling into a global — `actual:status` for the status bar, for example:
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<script src="actual.js"></script>
|
|
67
|
+
<script>
|
|
68
|
+
document.dispatchEvent(new CustomEvent("actual:status", {
|
|
69
|
+
bubbles: true,
|
|
70
|
+
detail: { message: "Saved.", intent: "success" },
|
|
71
|
+
}));
|
|
72
|
+
</script>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| Module | Behavior |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `actual-css/js/flyout` | Trigger → panel surface: positioning, open/close, sheet mode (token `flyout`) |
|
|
78
|
+
| `actual-css/js/context-menu` | Context menus on `data-context-menu` |
|
|
79
|
+
| `actual-css/js/focus-group` | Reusable roving-focus controller; no automatic discovery |
|
|
80
|
+
| `actual-css/js/dialog` | Native `<dialog>` behavior, focus management, dismissal |
|
|
81
|
+
| `actual-css/js/dismiss` | The generic `--dismiss` command |
|
|
82
|
+
| `actual-css/js/tab` | Tab panels and arrow-key navigation (token `tabs`) |
|
|
83
|
+
| `actual-css/js/tooltip` | Tooltips on `data-tooltip` |
|
|
84
|
+
| `actual-css/js/scrollspy` | Scroll-driven nav highlighting (token `scrollspy`) |
|
|
85
|
+
| `actual-css/js/filter` | Input value filtering on `data-filter` |
|
|
86
|
+
| `actual-css/js/mask` | Input masks on `data-mask` |
|
|
87
|
+
| `actual-css/js/password` | Password reveal toggle |
|
|
88
|
+
| `actual-css/js/validation` | Form validation (token `validation`) |
|
|
89
|
+
| `actual-css/js/status` | Singleton status bar on `[data-status][role="status"]` |
|
|
90
|
+
| `actual-css/js/enhancement-loader` | Declared enhancement manifests (`data-enhance-modules`, `Enhance-Modules`) |
|
|
91
|
+
|
|
92
|
+
Use `actual-css/js` for demos, playgrounds, and smoke tests where one import
|
|
93
|
+
enables every built-in behavior. Production apps can import individual modules
|
|
94
|
+
such as `actual-css/js/validation` or `actual-css/js/status` when only those are
|
|
95
|
+
needed.
|
|
96
|
+
|
|
97
|
+
## Using the runtime with another CSS framework
|
|
98
|
+
|
|
99
|
+
The JS is designed to run on its own, without Actual's stylesheet.
|
|
100
|
+
|
|
101
|
+
- **`data-enhance` tokens** register root-controller behaviors. The runtime
|
|
102
|
+
discovers components through `data-enhance="tabs"`, `data-enhance="flyout"`,
|
|
103
|
+
`data-enhance="scrollspy"`, and `data-enhance="validation"` — no Actual
|
|
104
|
+
presentation class needed. Self-describing attributes (`data-mask`,
|
|
105
|
+
`data-tooltip`, `data-context-menu`) stay framework-neutral by construction.
|
|
106
|
+
- **`selectors.js`** lists the state classes the runtime *writes* (`is-open`,
|
|
107
|
+
`was-validated`, `is-sheet`, …). Edit this file (or alias it in a bundler) to
|
|
108
|
+
match a different CSS framework's state vocabulary. It is a read-side-only
|
|
109
|
+
adapter — it does not bridge CSS class names to JS initialization.
|
|
110
|
+
- **One documented exception:** validation reads `.field-error` (or its alias
|
|
111
|
+
`[data-field-error]`) and the optional `.field` ancestor to connect error
|
|
112
|
+
slots and toggle danger state. Both are read-side presentation adapters that
|
|
113
|
+
degrade to no-op under foreign CSS — `aria-invalid`, focus management, custom
|
|
114
|
+
rules, and server errors all keep working without them.
|
|
115
|
+
- **status.js** uses `[data-status][role="status"]` — semantics, not a class.
|
|
116
|
+
Its `intent` option applies whatever class names the caller passes, so
|
|
117
|
+
`.danger`, `.success`, and the rest of intents.css are never hardcoded.
|
|
118
|
+
|
|
119
|
+
## The runtime as a primitive kit
|
|
120
|
+
|
|
121
|
+
For building custom widgets (select, tags, date picker) on Actual's primitives,
|
|
122
|
+
see the widget-primitives design note. Every primitive subpath is published in
|
|
123
|
+
`package.json#exports`:
|
|
124
|
+
|
|
125
|
+
| Subpath | Purpose |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `actual-css/js/enhance` | DOM lifecycle engine: `enhance()`, `registerEnhancement`, tokens |
|
|
128
|
+
| `actual-css/js/events` | The `actual:*` event name constants |
|
|
129
|
+
| `actual-css/js/floating` | Positioning engine (`reposition`, `repositionAt`, `autoUpdate`) |
|
|
130
|
+
| `actual-css/js/focus` | Focusable-item lookup and helpers |
|
|
131
|
+
| `actual-css/js/keys` | Roving-focus item navigation helpers |
|
|
132
|
+
| `actual-css/js/menu` | Menu keyboard/click wiring, item vocabulary |
|
|
133
|
+
| `actual-css/js/surface` | Surface lifecycle (`openSurface`, `closeSurface`, …) |
|
|
134
|
+
|
|
135
|
+
The exports map is explicit — it *is* the statement of what is public.
|
|
136
|
+
|
|
137
|
+
## Floating contract
|
|
138
|
+
|
|
139
|
+
`floating` is a positioning-only primitive with zero CSS dependency: no
|
|
140
|
+
`classList`, no `selectors.js` import, no Actual class anywhere. `reposition()`
|
|
141
|
+
computes and writes inline `left`/`top`, `data-placement`,
|
|
142
|
+
`--available-height`, and `--arrow-x`/`--arrow-y` on the floating element. The
|
|
143
|
+
caller owns the floating element's presentation entirely.
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { autoUpdate, reposition, repositionAt } from "actual-css/js/floating";
|
|
147
|
+
|
|
148
|
+
const stop = autoUpdate(float, () => {
|
|
149
|
+
reposition(trigger, float, { placement: "bottom-start", distance: 4 });
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Options passed to `reposition()`:
|
|
154
|
+
|
|
155
|
+
| Option | Default | Contract |
|
|
156
|
+
|---|---|---|
|
|
157
|
+
| `placement` | `"bottom-start"` | Preferred side and alignment (`-start` / `-end`) |
|
|
158
|
+
| `distance` | `0` | Gap between the reference and the floating element |
|
|
159
|
+
| `flip` | `true` | Flip to the opposite side when the preferred side is out of view |
|
|
160
|
+
| `shift` | `true` | Shift along the main axis to stay within the boundary |
|
|
161
|
+
| `shiftPadding` | `4` | Minimum space kept from the boundary |
|
|
162
|
+
| `scope` | viewport | Boundary element for overflow decisions |
|
|
163
|
+
|
|
164
|
+
`autoUpdate(element, callback)` batches scroll, resize, and element-resize
|
|
165
|
+
callbacks per document and returns a stop handle; it does not keep a closed or
|
|
166
|
+
hidden element positioned. `repositionAt(x, y, float, opts)` positions relative
|
|
167
|
+
to a fixed point — used by context menus.
|
|
168
|
+
|
|
169
|
+
A third-party widget importing only `floating` with no Actual stylesheet must be
|
|
170
|
+
able to position a listbox under an input — that is the test.
|
|
171
|
+
|
|
172
|
+
## Surface contract
|
|
173
|
+
|
|
174
|
+
`surface` has documented presentation coupling. A foreign consumer must supply
|
|
175
|
+
or account for:
|
|
176
|
+
|
|
177
|
+
```css
|
|
178
|
+
.my-panel { position: absolute; z-index: 100; }
|
|
179
|
+
.my-panel[hidden] { display: none; }
|
|
180
|
+
/* surface.js sets position: fixed + inline left/top on open —
|
|
181
|
+
do not override with !important */
|
|
182
|
+
/* .is-open / .is-sheet / .surface-backdrop are written by surface.js
|
|
183
|
+
— style or ignore them */
|
|
184
|
+
/* --surface-anchor-width is set while an open surface is positioned */
|
|
185
|
+
/* data-actual-surface is written by surface.js for teardown
|
|
186
|
+
— never select on it */
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Two non-obvious requirements:
|
|
190
|
+
|
|
191
|
+
1. `[hidden] { display: none }` must not be defeated by a higher-specificity
|
|
192
|
+
`display` rule — `surface.js` toggles `hidden`, not `display`.
|
|
193
|
+
2. The open state switches to `position: fixed` with measured inline
|
|
194
|
+
coordinates.
|
|
195
|
+
|
|
196
|
+
Lifecycle: `prepareSurface` → `openSurface` → `closeSurface` →
|
|
197
|
+
`disconnectSurface`. `retainSurface(panel)` reference-counts a surface shared by
|
|
198
|
+
several triggers and returns a release function; `registerEscapeDismissal` adds
|
|
199
|
+
an element to the per-document Escape dismiss stack; `isSurfaceOpen` and
|
|
200
|
+
`getSurfaceAutoClose` read current state.
|
|
201
|
+
|
|
202
|
+
`openSurface(menu, opts)` dispatches a cancelable `actual:surface-open` event —
|
|
203
|
+
widgets can veto or decorate opens, and context menus inject their own
|
|
204
|
+
`source`/`restoreFocusTo` through it.
|
|
205
|
+
|
|
206
|
+
| Option | Default | Contract |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| `trigger` | — | Anchoring element; also the focus-restore target by default |
|
|
209
|
+
| `source` | — | Alternate anchor when there is no trigger |
|
|
210
|
+
| `x`, `y` | — | Position at a fixed viewport point instead of an anchor (context menus) |
|
|
211
|
+
| `placement` | `"bottom-start"` | Preferred placement |
|
|
212
|
+
| `distance` | `4` | Gap between anchor and surface |
|
|
213
|
+
| `flip` | `true` | Flip when the preferred side is out of view |
|
|
214
|
+
| `shift` | `true` | Shift to stay within the boundary |
|
|
215
|
+
| `shiftPadding` | `4` | Minimum space kept from the boundary |
|
|
216
|
+
| `scope` | viewport | Boundary element |
|
|
217
|
+
| `mobile` | `"auto"` | `"auto"`, `"sheet"`, `"none"`, or `"anchored"` sheet behavior |
|
|
218
|
+
| `breakpoint` | `768` | Max viewport width for an automatic sheet |
|
|
219
|
+
| `autoClose` | `"outside"` | `"true"` closes for inside and outside clicks; `"inside"` and `"outside"` limit the side; `"false"` disables automatic click closing. Invalid values use `"true"` |
|
|
220
|
+
| `dismissOnScroll` | `false` | Close when scrolling outside the surface follows a new user input; opening/focus scroll is ignored |
|
|
221
|
+
| `restoreFocusTo` | `trigger` or `source` | Element focused on close |
|
|
222
|
+
|
|
223
|
+
The click policy belongs to the surface rather than to a particular menu
|
|
224
|
+
anatomy. The generic primitive defaults to outside-click dismissal; flyout and
|
|
225
|
+
context-menu choose `"true"` as their component default. A descendant carrying
|
|
226
|
+
`data-flyout-close` closes its surface regardless of that policy. A custom
|
|
227
|
+
widget that owns selection without dismissing — such as a combobox listbox —
|
|
228
|
+
can keep the primitive default or open with `autoClose: "false"`.
|
|
229
|
+
|
|
230
|
+
Extension seam: a `[role="listbox"]` surface with `[role="option"]` children
|
|
231
|
+
gets no menu keyboard handling or inside-click dismissal by default.
|
|
232
|
+
`[role="option"]` is deliberately not in the menu-item vocabulary — a combobox
|
|
233
|
+
reuses the surface lifecycle while retaining full control of its own keyboard
|
|
234
|
+
and selection.
|
|
235
|
+
|
|
236
|
+
## Events
|
|
237
|
+
|
|
238
|
+
The runtime dispatches a small set of public `actual:*` events. All are
|
|
239
|
+
listenable on the document; cancelable events veto the associated action when
|
|
240
|
+
`preventDefault()` is called.
|
|
241
|
+
|
|
242
|
+
| Event | Dispatch | Contract |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| `actual:surface-open` | `openSurface()`, before a surface opens | Cancelable. `detail: { surface, options }` |
|
|
245
|
+
| `actual:context-menu` | context-menu, before its menu opens | Cancelable. `detail: { menu, context, origin, trigger }` |
|
|
246
|
+
| `actual:status` | status module, or any code | Show or clear the status bar. `detail: { message, intent, duration }`; omit `message` to clear |
|
|
247
|
+
| `actual:invalid` | validation, after a submit was blocked | `detail: { form, firstInvalid, message }` |
|
|
248
|
+
| `actual:dialog-cancel` | dialog, before native cancel close | Cancelable. `detail: { dialog, sourceEvent }` |
|
|
249
|
+
| `actual:dismiss` | `--dismiss` command, after hiding a target | `detail: { trigger }` |
|
|
250
|
+
|
|
251
|
+
## Extending the runtime
|
|
252
|
+
|
|
253
|
+
### Stateless commands
|
|
254
|
+
|
|
255
|
+
Use `registerCommands()` for a target-oriented action that can be fully resolved
|
|
256
|
+
when it happens. The command router keeps one handler per command name and one
|
|
257
|
+
click listener per document; it does not scan, observe, or retain buttons and
|
|
258
|
+
targets. Injected markup, changed `commandfor` values, and replacement targets
|
|
259
|
+
therefore work immediately.
|
|
260
|
+
|
|
261
|
+
Custom command names start with `--`, following the native invoker convention.
|
|
262
|
+
Keep accessibility state that must exist before interaction in the HTML itself.
|
|
263
|
+
|
|
264
|
+
```html
|
|
265
|
+
<button type="button" commandfor="details" command="--toggle-hidden"
|
|
266
|
+
aria-controls="details">Toggle details</button>
|
|
267
|
+
<section id="details" hidden>Details…</section>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```js
|
|
271
|
+
import { registerCommands } from "actual-css/js/command";
|
|
272
|
+
|
|
273
|
+
registerCommands("--toggle-hidden", {
|
|
274
|
+
handle(event, trigger, target) {
|
|
275
|
+
event.preventDefault();
|
|
276
|
+
target.hidden = !target.hidden;
|
|
277
|
+
trigger.setAttribute("aria-expanded", String(!target.hidden));
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The default resolver `targetFor(trigger)` looks up `commandfor` by ID in the
|
|
283
|
+
trigger's document or shadow root. Supply `resolve(trigger)` to validate or
|
|
284
|
+
replace that lookup. An optional idempotent `prepare(trigger, target, command)`
|
|
285
|
+
callback runs directly before `handle`; built-in behaviors use it for
|
|
286
|
+
target-derived semantics. `commandSelector(commands)` builds the matching button
|
|
287
|
+
selector. The returned handle has an idempotent `disconnect()` method for
|
|
288
|
+
applications that unload the module owning the command.
|
|
289
|
+
|
|
290
|
+
### Dismiss command
|
|
291
|
+
|
|
292
|
+
The runtime includes a generic `--dismiss` command for hiding any resolved
|
|
293
|
+
target. It prevents the trigger's default action, sets `hidden`, and emits a
|
|
294
|
+
bubbling `actual:dismiss` event with the trigger in `event.detail.trigger`.
|
|
295
|
+
|
|
296
|
+
```html demo
|
|
297
|
+
<section id="notice" class="alert">
|
|
298
|
+
<p>Changes saved.</p>
|
|
299
|
+
<button type="button" commandfor="notice" command="--dismiss">
|
|
300
|
+
Dismiss
|
|
301
|
+
</button>
|
|
302
|
+
</section>
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Listen for `actual:dismiss` when application code needs to remove persisted
|
|
306
|
+
state or perform another action after the target is hidden.
|
|
307
|
+
|
|
308
|
+
### Stateful enhancers
|
|
309
|
+
|
|
310
|
+
Use `enhance()` for project-specific behavior. It accepts a selector map where
|
|
311
|
+
each function receives the matched element and may return a cleanup function.
|
|
312
|
+
|
|
313
|
+
```js
|
|
314
|
+
import enhance from "actual-css/js/enhance";
|
|
315
|
+
|
|
316
|
+
enhance({
|
|
317
|
+
"[data-copy]": (button) => {
|
|
318
|
+
const controller = new AbortController();
|
|
319
|
+
|
|
320
|
+
button.addEventListener("click", async () => {
|
|
321
|
+
const target = document.querySelector(button.dataset.copy);
|
|
322
|
+
if (!target) return;
|
|
323
|
+
await navigator.clipboard.writeText(target.textContent ?? "");
|
|
324
|
+
}, { signal: controller.signal });
|
|
325
|
+
|
|
326
|
+
return () => controller.abort();
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Prefer explicit app attributes such as `data-copy`, `data-autogrow`, or
|
|
332
|
+
`data-ajax`. Actual's built-in attributes are intentionally small contracts; app
|
|
333
|
+
behavior can be richer without overloading them.
|
|
334
|
+
|
|
335
|
+
### Named behavior registration
|
|
336
|
+
|
|
337
|
+
Use `registerEnhancement()` when author markup opts in with a `data-enhance`
|
|
338
|
+
token instead of an app-specific attribute. A third-party behavior registers
|
|
339
|
+
exactly like a built-in one:
|
|
340
|
+
|
|
341
|
+
```js
|
|
342
|
+
import { registerEnhancement } from "actual-css/js/enhance";
|
|
343
|
+
|
|
344
|
+
registerEnhancement("autosubmit", (form) => {
|
|
345
|
+
const controller = new AbortController();
|
|
346
|
+
|
|
347
|
+
form.addEventListener("change", () => form.requestSubmit(), {
|
|
348
|
+
signal: controller.signal,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
return () => controller.abort();
|
|
352
|
+
});
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
```html
|
|
356
|
+
<form data-enhance="validation autosubmit">
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`enhancementSelector(name)` returns the matching `[data-enhance~="name"]`
|
|
360
|
+
selector (names must match `[a-z][a-z0-9-]*`), and `hasEnhancement(el, name)`
|
|
361
|
+
tests it. No core module list is modified — the regression test in
|
|
362
|
+
`tests/enhance.test.js` proves the shape.
|
|
363
|
+
|
|
364
|
+
A name is owned once per root: a second `registerEnhancement("autosubmit", …)`
|
|
365
|
+
on the same root throws, and `disconnect()` releases the name so it can be
|
|
366
|
+
registered again. A custom root passed as the third argument must be disposed
|
|
367
|
+
by its owner; the default `document.documentElement` root is not affected.
|
|
368
|
+
|
|
369
|
+
### Enhancement manifests
|
|
370
|
+
|
|
371
|
+
The enhancement-loader lets a server (or static HTML) declare document-wide
|
|
372
|
+
enhancement modules that are imported lazily. A manifest is a JSON object
|
|
373
|
+
mapping enhancement names to module URLs, declared in a script block or an
|
|
374
|
+
HTTP header. `index.js` wires `watchEnhancementManifests()` into the default
|
|
375
|
+
runtime.
|
|
376
|
+
|
|
377
|
+
```html
|
|
378
|
+
<script type="application/json" data-enhance-modules>
|
|
379
|
+
{ "autosubmit": "/js/autosubmit.js" }
|
|
380
|
+
</script>
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
import { loadEnhancement, loadEnhancements, loadResponse } from "actual-css/js/enhancement-loader";
|
|
385
|
+
|
|
386
|
+
await loadEnhancements(); // every manifest block in the document
|
|
387
|
+
await loadEnhancement("autosubmit", "/js/autosubmit.js");
|
|
388
|
+
await loadResponse(fetch("/...").then((r) => r)); // Enhance-Modules header
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The `Enhance-Modules` HTTP header carries the same JSON object. Loaded modules
|
|
392
|
+
register on the default document root and must export a default function. An
|
|
393
|
+
optional exported `prepare()` runs first for asynchronous setup and receives
|
|
394
|
+
`{ resolve, loadScript, loadStyle }`:
|
|
395
|
+
|
|
396
|
+
```js
|
|
397
|
+
// autosubmit.js — declared module
|
|
398
|
+
export async function prepare({ resolve, loadStyle }) {
|
|
399
|
+
await loadStyle(resolve("./autosubmit.css"));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export default function autosubmit(form, { config, signal, emit }) {
|
|
403
|
+
const max = config.max ?? 3; // from data-enhance-config
|
|
404
|
+
|
|
405
|
+
form.addEventListener("change", () => form.requestSubmit(), { signal });
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```html
|
|
410
|
+
<form data-enhance="autosubmit"
|
|
411
|
+
data-enhance-config='{ "max": 3 }'>
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
`data-enhance-config` is parsed with `parseConfig`: strict JSON first, with a
|
|
415
|
+
tiny relaxed subset (unquoted keys, single-quoted strings) as a fallback. The
|
|
416
|
+
connect function receives the element plus `{ config, signal, emit }`; `emit`
|
|
417
|
+
dispatches `actual:<name>:<type>` events. Connect must be synchronous and may
|
|
418
|
+
return a cleanup function or nothing; the loader owns the AbortController.
|
|
419
|
+
|
|
420
|
+
`loadScript(url)` and `loadStyle(url)` are public helpers for non-ESM assets.
|
|
421
|
+
Names are reserved once per manifest set — redeclaring a name from a different
|
|
422
|
+
URL throws, and duplicate declarations share one import promise. The
|
|
423
|
+
`__`-prefixed exports are test hooks, not public API.
|
|
424
|
+
|
|
425
|
+
## Lifecycle rules
|
|
426
|
+
|
|
427
|
+
- Keep selectors opt-in.
|
|
428
|
+
- Return cleanup for every listener, observer, timer, or pending controller.
|
|
429
|
+
- Prefer one `AbortController` per owned lifecycle; abortable event listeners
|
|
430
|
+
are the standard cleanup mechanism for enhancer-owned listeners.
|
|
431
|
+
- Make the unenhanced HTML useful first, then add behavior.
|
|
432
|
+
- Dispatch a custom event when app code may need to react.
|
|
433
|
+
- Keep modules safe to import without a DOM.
|
|
434
|
+
|
|
435
|
+
## Toggle button groups
|
|
436
|
+
|
|
437
|
+
Actual styles `.btn[aria-pressed="true"]`; application code should update the
|
|
438
|
+
attribute. Use grouped buttons when the state belongs to the current page, not
|
|
439
|
+
for navigation between pages.
|
|
440
|
+
|
|
441
|
+
```html
|
|
442
|
+
<div class="join" role="group" aria-label="Text style" data-toggle-group>
|
|
443
|
+
<button class="btn outline" type="button" aria-pressed="false">Bold</button>
|
|
444
|
+
<button class="btn outline" type="button" aria-pressed="false">Italic</button>
|
|
445
|
+
<button class="btn outline" type="button" aria-pressed="false">Underline</button>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
<div class="join" role="group" aria-label="Alignment" data-toggle-group="single">
|
|
449
|
+
<button class="btn outline" type="button" aria-pressed="true">Left</button>
|
|
450
|
+
<button class="btn outline" type="button" aria-pressed="false">Center</button>
|
|
451
|
+
<button class="btn outline" type="button" aria-pressed="false">Right</button>
|
|
452
|
+
</div>
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
```js
|
|
456
|
+
import enhance from "actual-css/js/enhance";
|
|
457
|
+
|
|
458
|
+
enhance({
|
|
459
|
+
"[data-toggle-group]": (group) => {
|
|
460
|
+
const controller = new AbortController();
|
|
461
|
+
|
|
462
|
+
group.addEventListener("click", (event) => {
|
|
463
|
+
if (!(event.target instanceof Element)) return;
|
|
464
|
+
|
|
465
|
+
const button = event.target.closest("button[aria-pressed]");
|
|
466
|
+
if (!button || !group.contains(button)) return;
|
|
467
|
+
|
|
468
|
+
if (group.dataset.toggleGroup === "single") {
|
|
469
|
+
for (const item of group.querySelectorAll("button[aria-pressed]")) {
|
|
470
|
+
item.setAttribute("aria-pressed", String(item === button));
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const pressed = button.getAttribute("aria-pressed") === "true";
|
|
476
|
+
button.setAttribute("aria-pressed", String(!pressed));
|
|
477
|
+
}, { signal: controller.signal });
|
|
478
|
+
|
|
479
|
+
return () => controller.abort();
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## Custom text filters
|
|
485
|
+
|
|
486
|
+
`data-filter` is intentionally limited to Actual's built-in filters. For domain
|
|
487
|
+
rules such as currency, signed numbers, product codes, or time ranges, create an
|
|
488
|
+
app-specific filter attribute.
|
|
489
|
+
|
|
490
|
+
Use the input helpers when you need the same composition and caret behavior as
|
|
491
|
+
Actual's built-in filters.
|
|
492
|
+
|
|
493
|
+
```js
|
|
494
|
+
import enhance from "actual-css/js/enhance";
|
|
495
|
+
import { dispatchInput, onTextInput, selectionStart, setCaret } from "actual-css/js/input";
|
|
496
|
+
|
|
497
|
+
const filters = {
|
|
498
|
+
currency(value) {
|
|
499
|
+
const normalized = value.replace(/,/g, ".");
|
|
500
|
+
const [whole, decimals = ""] = normalized.replace(/[^0-9.]/g, "").split(".");
|
|
501
|
+
return decimals ? `${whole}.${decimals.slice(0, 2)}` : whole;
|
|
502
|
+
},
|
|
503
|
+
time(value) {
|
|
504
|
+
const digits = value.replace(/\D/g, "").slice(0, 4);
|
|
505
|
+
return digits.length > 2 ? `${digits.slice(0, 2)}:${digits.slice(2)}` : digits;
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
enhance({
|
|
510
|
+
"input[data-app-filter]": (input) => {
|
|
511
|
+
const filter = filters[input.dataset.appFilter];
|
|
512
|
+
if (!filter) return;
|
|
513
|
+
|
|
514
|
+
const controller = new AbortController();
|
|
515
|
+
let filtering = false;
|
|
516
|
+
|
|
517
|
+
onTextInput(input, () => {
|
|
518
|
+
if (filtering) return;
|
|
519
|
+
|
|
520
|
+
const caret = selectionStart(input);
|
|
521
|
+
const previous = input.value;
|
|
522
|
+
const next = filter(previous);
|
|
523
|
+
if (next === previous) return;
|
|
524
|
+
|
|
525
|
+
input.value = next;
|
|
526
|
+
setCaret(input, filter(previous.slice(0, caret)).length);
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
filtering = true;
|
|
530
|
+
dispatchInput(input);
|
|
531
|
+
} finally {
|
|
532
|
+
filtering = false;
|
|
533
|
+
}
|
|
534
|
+
}, controller.signal);
|
|
535
|
+
|
|
536
|
+
return () => controller.abort();
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
```html
|
|
542
|
+
<input class="input" inputmode="decimal" data-app-filter="currency" autocomplete="off" />
|
|
543
|
+
<input class="input" inputmode="numeric" data-app-filter="time" autocomplete="off" />
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
## Textarea autogrow
|
|
547
|
+
|
|
548
|
+
Textarea autogrow is a good fit for a small local enhancer because product needs
|
|
549
|
+
vary: minimum rows, maximum height, scroll behavior, and resize handles are all
|
|
550
|
+
app decisions.
|
|
551
|
+
|
|
552
|
+
```js
|
|
553
|
+
import enhance from "actual-css/js/enhance";
|
|
554
|
+
|
|
555
|
+
enhance({
|
|
556
|
+
"textarea[data-autogrow]": (textarea) => {
|
|
557
|
+
const controller = new AbortController();
|
|
558
|
+
|
|
559
|
+
function resize() {
|
|
560
|
+
textarea.style.height = "auto";
|
|
561
|
+
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
textarea.addEventListener("input", resize, { signal: controller.signal });
|
|
565
|
+
resize();
|
|
566
|
+
|
|
567
|
+
return () => {
|
|
568
|
+
controller.abort();
|
|
569
|
+
textarea.style.height = "";
|
|
570
|
+
};
|
|
571
|
+
},
|
|
572
|
+
});
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
## Ajax forms
|
|
576
|
+
|
|
577
|
+
Keep the native form as the baseline: set `action`, `method`, fields, and submit
|
|
578
|
+
buttons normally. The enhancer can intercept submission only when JavaScript is
|
|
579
|
+
available.
|
|
580
|
+
|
|
581
|
+
```js
|
|
582
|
+
import enhance from "actual-css/js/enhance";
|
|
583
|
+
|
|
584
|
+
enhance({
|
|
585
|
+
"form[data-ajax]": (form) => {
|
|
586
|
+
const controller = new AbortController();
|
|
587
|
+
|
|
588
|
+
form.addEventListener("submit", async (event) => {
|
|
589
|
+
event.preventDefault();
|
|
590
|
+
|
|
591
|
+
const submitter = event.submitter;
|
|
592
|
+
const data = new FormData(form, submitter);
|
|
593
|
+
const response = await fetch(form.action, {
|
|
594
|
+
method: form.method || "GET",
|
|
595
|
+
body: form.method.toUpperCase() === "GET" ? null : data,
|
|
596
|
+
signal: controller.signal,
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
form.dispatchEvent(new CustomEvent("actual:ajax", {
|
|
600
|
+
bubbles: true,
|
|
601
|
+
detail: { response },
|
|
602
|
+
}));
|
|
603
|
+
}, { signal: controller.signal });
|
|
604
|
+
|
|
605
|
+
return () => controller.abort();
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
For htmx-like fragment swaps, keep the first version narrow: one trigger, one
|
|
611
|
+
target, one swap rule. If the behavior grows into history management, out-of-band
|
|
612
|
+
swaps, request queues, and event policies, use a dedicated library.
|
|
613
|
+
|
|
614
|
+
```js
|
|
615
|
+
import enhance from "actual-css/js/enhance";
|
|
616
|
+
|
|
617
|
+
enhance({
|
|
618
|
+
"[data-get][data-target]": (trigger) => {
|
|
619
|
+
const controller = new AbortController();
|
|
620
|
+
|
|
621
|
+
trigger.addEventListener("click", async () => {
|
|
622
|
+
const target = document.querySelector(trigger.dataset.target);
|
|
623
|
+
if (!target) return;
|
|
624
|
+
|
|
625
|
+
const response = await fetch(trigger.dataset.get, {
|
|
626
|
+
signal: controller.signal,
|
|
627
|
+
});
|
|
628
|
+
target.innerHTML = await response.text();
|
|
629
|
+
}, { signal: controller.signal });
|
|
630
|
+
|
|
631
|
+
return () => controller.abort();
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
## Form validation
|
|
637
|
+
|
|
638
|
+
`actual-css/js/validation` is part of the default runtime and also importable on
|
|
639
|
+
its own — it is an enhancer, not a validation framework. Forms opt in with the
|
|
640
|
+
`data-enhance="validation"` token (`.needs-validation` is a presentation class
|
|
641
|
+
for CSS-only `:user-invalid` feedback and does not by itself register the
|
|
642
|
+
behavior). Native HTML constraint validation runs first, then the enhancer adds
|
|
643
|
+
`aria-invalid="true"` to invalid fields on submit, focuses the first invalid
|
|
644
|
+
field, and dispatches a bubbling `actual:invalid` event with
|
|
645
|
+
`{ form, firstInvalid, message }`. Valid fields are not marked automatically.
|
|
646
|
+
|
|
647
|
+
```html demo
|
|
648
|
+
<form class="needs-validation" data-enhance="validation"
|
|
649
|
+
data-validation-message="Please check the fields.">
|
|
650
|
+
<input class="input" name="email" type="email" required
|
|
651
|
+
aria-describedby="email-error" />
|
|
652
|
+
<span class="field-error" id="email-error">Enter a valid email.</span>
|
|
653
|
+
<button class="btn primary" type="submit">Submit</button>
|
|
654
|
+
</form>
|
|
655
|
+
|
|
656
|
+
<div class="status-bar" data-status role="status" aria-live="polite" aria-atomic="true"></div>
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The status bar auto-wires to `actual:invalid`: the form's
|
|
660
|
+
`data-validation-message` appears with the `danger` intent, no manual listener.
|
|
661
|
+
To handle the summary yourself instead, omit the status import and listen for
|
|
662
|
+
the event:
|
|
663
|
+
|
|
664
|
+
```js
|
|
665
|
+
document.addEventListener("actual:invalid", (event) => {
|
|
666
|
+
const { firstInvalid, message } = event.detail;
|
|
667
|
+
// route message to your own surface
|
|
668
|
+
});
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
Custom rules go in `data-validation-rules` and resolve through
|
|
672
|
+
`FormValidator.registerRule`. Built-in rules are `same`, `number`, `digits`,
|
|
673
|
+
`alnum`, and `date`. The library never performs network validation; server
|
|
674
|
+
errors flow back through `FormValidator.setErrors(form, { name: message })` and
|
|
675
|
+
`FormValidator.clearFieldError(field)`.
|
|
676
|
+
|
|
677
|
+
```js
|
|
678
|
+
import { FormValidator } from "actual-css/js/validation";
|
|
679
|
+
|
|
680
|
+
FormValidator.registerRule("slug", (value) =>
|
|
681
|
+
value.length === 0 || /^[a-z0-9-]+$/.test(value)
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
// after an AJAX submit the server reports field errors
|
|
685
|
+
FormValidator.setErrors(form, { email: "Already taken" });
|
|
686
|
+
```
|