actual-css 0.1.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.
Files changed (215) hide show
  1. package/CHANGELOG.md +90 -1
  2. package/README.md +25 -8
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1492 -931
  5. package/dist/actual.js +1 -3
  6. package/dist/actual.min.css +1 -1
  7. package/dist/optional.css +1048 -0
  8. package/dist/optional.min.css +1 -0
  9. package/docs/design-notes/browser-support.md +95 -6
  10. package/docs/design-notes/cascade-layer.md +1 -1
  11. package/docs/design-notes/enhancement-contract.md +101 -0
  12. package/docs/design-notes/forced-colors.md +105 -0
  13. package/docs/design-notes/naming.md +3 -1
  14. package/docs/design-notes/platform-alignment.md +492 -0
  15. package/docs/design-notes/references.md +8 -0
  16. package/docs/design-notes/widget-primitives.md +119 -0
  17. package/docs/navigation.json +135 -0
  18. package/docs/pages/components/accordion.md +80 -0
  19. package/docs/pages/components/alert.md +203 -0
  20. package/docs/pages/components/aura.md +35 -0
  21. package/docs/pages/components/avatar.md +95 -0
  22. package/docs/pages/components/badge.md +102 -0
  23. package/docs/pages/components/breadcrumb.md +19 -0
  24. package/docs/pages/components/button.md +205 -0
  25. package/docs/pages/components/card.md +183 -0
  26. package/docs/pages/components/chat.md +50 -0
  27. package/docs/pages/components/dialog.md +367 -0
  28. package/docs/pages/components/drawer.md +113 -0
  29. package/docs/pages/components/fab.md +118 -0
  30. package/docs/pages/components/key.md +33 -0
  31. package/docs/pages/components/meter.md +23 -0
  32. package/docs/pages/components/navbar.md +92 -0
  33. package/docs/pages/components/pagination.md +55 -0
  34. package/docs/pages/components/progress.md +29 -0
  35. package/docs/pages/components/skeleton.md +38 -0
  36. package/docs/pages/components/spinner.md +93 -0
  37. package/docs/pages/components/table.md +102 -0
  38. package/docs/pages/enhancements/flyout.md +391 -0
  39. package/docs/pages/enhancements/overview.md +36 -0
  40. package/docs/pages/enhancements/scrollspy.md +71 -0
  41. package/docs/pages/enhancements/status-bar.md +72 -0
  42. package/docs/pages/enhancements/tabs.md +80 -0
  43. package/docs/pages/enhancements/tooltip.md +130 -0
  44. package/docs/pages/examples/overview.md +41 -0
  45. package/docs/pages/forms/choice-cards.md +102 -0
  46. package/docs/pages/forms/controls.md +32 -0
  47. package/docs/pages/forms/detached-actions.md +59 -0
  48. package/docs/pages/forms/floating-field.md +88 -0
  49. package/docs/pages/forms/form-actions.md +56 -0
  50. package/docs/pages/forms/input-policy.md +91 -0
  51. package/docs/pages/forms/joined-controls.md +37 -0
  52. package/docs/pages/forms/labels.md +42 -0
  53. package/docs/pages/forms/otp.md +50 -0
  54. package/docs/pages/forms/overview.md +188 -0
  55. package/docs/pages/forms/password.md +40 -0
  56. package/docs/pages/forms/range.md +17 -0
  57. package/docs/pages/forms/states-and-sizes.md +321 -0
  58. package/docs/pages/forms/sticky-actions.md +187 -0
  59. package/docs/pages/forms/switch.md +31 -0
  60. package/docs/pages/forms/validation.md +241 -0
  61. package/docs/{mental-model.md → pages/foundations/philosophy.md} +33 -18
  62. package/docs/pages/foundations/theming.md +92 -0
  63. package/docs/pages/foundations/tokens.md +421 -0
  64. package/docs/{typography.md → pages/foundations/typography.md} +27 -9
  65. package/docs/pages/guides/bootstrap.md +438 -0
  66. package/docs/pages/guides/introduction.md +157 -0
  67. package/docs/pages/guides/optional-components.md +33 -0
  68. package/docs/pages/guides/progressive-enhancement.md +686 -0
  69. package/docs/pages/guides/tailwind.md +455 -0
  70. package/docs/pages/layout/app-shell.md +25 -0
  71. package/docs/pages/layout/center.md +29 -0
  72. package/docs/pages/layout/cluster.md +57 -0
  73. package/docs/pages/layout/frame.md +30 -0
  74. package/docs/pages/layout/grid.md +142 -0
  75. package/docs/pages/layout/list.md +41 -0
  76. package/docs/pages/layout/media.md +21 -0
  77. package/docs/pages/layout/overview.md +37 -0
  78. package/docs/pages/layout/scroll-snap.md +54 -0
  79. package/docs/pages/layout/sidebar-layout.md +27 -0
  80. package/docs/pages/layout/stack.md +18 -0
  81. package/docs/pages/layout/switcher.md +36 -0
  82. package/docs/pages/layout/topbar.md +23 -0
  83. package/docs/pages/patterns/actions.md +52 -0
  84. package/docs/pages/patterns/code-block.md +36 -0
  85. package/docs/pages/patterns/exclusive-accordion.md +16 -0
  86. package/docs/pages/patterns/hierarchical-nav.md +26 -0
  87. package/docs/pages/patterns/icon-input.md +25 -0
  88. package/docs/pages/patterns/intent-nesting.md +3 -0
  89. package/docs/pages/patterns/navigation-list.md +235 -0
  90. package/docs/pages/patterns/overview.md +58 -0
  91. package/docs/pages/patterns/structured-lists.md +47 -0
  92. package/docs/pages/utilities/accessibility.md +34 -0
  93. package/docs/pages/utilities/flex.md +105 -0
  94. package/docs/pages/utilities/links.md +18 -0
  95. package/docs/pages/utilities/list.md +31 -0
  96. package/docs/pages/utilities/overflow.md +43 -0
  97. package/docs/pages/utilities/overview.md +18 -0
  98. package/docs/pages/utilities/shape.md +25 -0
  99. package/docs/pages/utilities/sizing-wrapping.md +115 -0
  100. package/docs/pages/utilities/spacing.md +79 -0
  101. package/docs/pages/utilities/text.md +43 -0
  102. package/llms.txt +10 -4
  103. package/package.json +30 -16
  104. package/src/css/actual.css +12 -1
  105. package/src/css/base.css +3 -9
  106. package/src/css/components/accordion.css +18 -30
  107. package/src/css/components/actions.css +4 -1
  108. package/src/css/components/alert.css +162 -49
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +82 -39
  111. package/src/css/components/breadcrumb.css +7 -15
  112. package/src/css/components/busy.css +7 -0
  113. package/src/css/components/button.css +82 -38
  114. package/src/css/components/card.css +17 -37
  115. package/src/css/components/drawer.css +30 -13
  116. package/src/css/components/flyout.css +39 -110
  117. package/src/css/components/index.css +1 -0
  118. package/src/css/components/join.css +14 -3
  119. package/src/css/components/key.css +2 -0
  120. package/src/css/components/menu.css +125 -0
  121. package/src/css/components/meter.css +0 -23
  122. package/src/css/components/modal.css +162 -7
  123. package/src/css/components/navbar.css +17 -18
  124. package/src/css/components/overline.css +0 -7
  125. package/src/css/components/pagination.css +1 -1
  126. package/src/css/components/progress.css +8 -26
  127. package/src/css/components/scrollspy.css +2 -2
  128. package/src/css/components/skeleton.css +7 -0
  129. package/src/css/components/spinner.css +10 -11
  130. package/src/css/components/status-bar.css +18 -28
  131. package/src/css/components/surface.css +2 -8
  132. package/src/css/components/tab.css +20 -48
  133. package/src/css/components/table.css +7 -5
  134. package/src/css/components/tooltip.css +14 -7
  135. package/src/css/focus.css +16 -3
  136. package/src/css/forms/choice-card.css +13 -15
  137. package/src/css/forms/choice.css +21 -28
  138. package/src/css/forms/control.css +16 -26
  139. package/src/css/forms/custom-select.css +9 -11
  140. package/src/css/forms/form-actions.css +14 -3
  141. package/src/css/forms/form.css +7 -4
  142. package/src/css/forms/index.css +5 -4
  143. package/src/css/forms/input-icon.css +30 -12
  144. package/src/css/forms/native.css +48 -33
  145. package/src/css/forms/range.css +103 -0
  146. package/src/css/forms/select.css +8 -8
  147. package/src/css/forms/switch.css +14 -13
  148. package/src/css/forms/validation.css +32 -5
  149. package/src/css/grid.css +19 -18
  150. package/src/css/intents.css +2 -2
  151. package/src/css/layout.css +29 -1
  152. package/src/css/optional/aura.css +93 -0
  153. package/src/css/optional/chat.css +105 -0
  154. package/src/css/optional/fab.css +104 -0
  155. package/src/css/optional/floating-field.css +87 -0
  156. package/src/css/optional/index.css +5 -0
  157. package/src/css/optional/layout-extra.css +7 -4
  158. package/src/css/optional/otp.css +115 -0
  159. package/src/css/optional/scroll-snap.css +6 -0
  160. package/src/css/optional/scroller.css +19 -7
  161. package/src/css/optional/typography-fluid.css +5 -0
  162. package/src/css/optional/utilities-extra.css +17 -2
  163. package/src/css/prose.css +18 -8
  164. package/src/css/reset.css +0 -10
  165. package/src/css/theme.css +29 -13
  166. package/src/css/themes/cyberpunk.css +1 -1
  167. package/src/css/themes/dim.css +3 -3
  168. package/src/css/themes/edge.css +11 -37
  169. package/src/css/themes/index.css +3 -3
  170. package/src/css/themes/{dark.css → indigo.css} +5 -4
  171. package/src/css/themes/neon.css +5 -5
  172. package/src/css/themes/square.css +0 -3
  173. package/src/css/tokens.css +81 -21
  174. package/src/css/utilities.css +8 -10
  175. package/src/css/variants.css +37 -65
  176. package/src/js/command.js +1 -3
  177. package/src/js/context-menu.js +72 -53
  178. package/src/js/dialog.js +68 -79
  179. package/src/js/dismiss.js +15 -0
  180. package/src/js/enhance.js +76 -19
  181. package/src/js/enhancement-loader.js +301 -0
  182. package/src/js/events.js +3 -0
  183. package/src/js/filter.js +3 -3
  184. package/src/js/floating.js +101 -131
  185. package/src/js/flyout.js +106 -96
  186. package/src/js/focus-group.js +116 -0
  187. package/src/js/focus.js +6 -1
  188. package/src/js/index.js +4 -2
  189. package/src/js/input.js +1 -1
  190. package/src/js/keys.js +27 -0
  191. package/src/js/mask.js +7 -6
  192. package/src/js/menu.js +89 -35
  193. package/src/js/parse-config.js +47 -0
  194. package/src/js/password.js +9 -1
  195. package/src/js/scrollspy.js +133 -62
  196. package/src/js/selectors.js +14 -31
  197. package/src/js/status.js +16 -14
  198. package/src/js/surface.js +219 -125
  199. package/src/js/tab.js +46 -66
  200. package/src/js/tooltip.js +53 -31
  201. package/src/js/validation.js +45 -51
  202. package/dist/actual.js.map +0 -31
  203. package/docs/README.md +0 -26
  204. package/docs/components.md +0 -860
  205. package/docs/design-notes/previous-work.md +0 -5
  206. package/docs/forms.md +0 -1265
  207. package/docs/javascript.md +0 -426
  208. package/docs/layout.md +0 -480
  209. package/docs/patterns.md +0 -415
  210. package/docs/reserved.md +0 -7
  211. package/docs/tokens.md +0 -455
  212. package/docs/ui.md +0 -937
  213. package/docs/utilities.md +0 -384
  214. package/src/css/dialog-fallback.css +0 -56
  215. 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
+ ```