actual-css 0.3.0 → 0.4.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 (205) hide show
  1. package/CHANGELOG.md +78 -3
  2. package/README.md +35 -30
  3. package/dist/actual.css +98 -4882
  4. package/dist/actual.full.css +7090 -0
  5. package/dist/actual.full.js +1 -0
  6. package/dist/actual.full.min.css +1 -0
  7. package/dist/actual.js +1 -1
  8. package/dist/actual.min.css +1 -1
  9. package/docs/design-notes/cascade-layer.md +35 -13
  10. package/docs/design-notes/column-layout.md +677 -0
  11. package/docs/design-notes/forced-colors.md +3 -0
  12. package/docs/design-notes/grid.md +612 -0
  13. package/docs/design-notes/naming.md +15 -0
  14. package/docs/design-notes/widget-primitives.md +1 -0
  15. package/docs/navigation.json +5 -1
  16. package/docs/pages/components/alert.md +16 -14
  17. package/docs/pages/components/aura.md +1 -1
  18. package/docs/pages/components/avatar.md +6 -6
  19. package/docs/pages/components/badge.md +7 -7
  20. package/docs/pages/components/button.md +22 -17
  21. package/docs/pages/components/card.md +29 -13
  22. package/docs/pages/components/chat.md +8 -8
  23. package/docs/pages/components/dialog.md +7 -7
  24. package/docs/pages/components/drawer.md +3 -3
  25. package/docs/pages/components/fab.md +7 -7
  26. package/docs/pages/components/navbar.md +9 -8
  27. package/docs/pages/components/pagination.md +4 -3
  28. package/docs/pages/components/skeleton.md +4 -4
  29. package/docs/pages/components/spinner.md +5 -5
  30. package/docs/pages/components/table.md +30 -5
  31. package/docs/pages/enhancements/flyout.md +13 -13
  32. package/docs/pages/enhancements/overview.md +1 -1
  33. package/docs/pages/enhancements/scrollspy.md +6 -6
  34. package/docs/pages/enhancements/status-bar.md +5 -1
  35. package/docs/pages/enhancements/tabs.md +6 -6
  36. package/docs/pages/enhancements/tooltip.md +1 -1
  37. package/docs/pages/examples/overview.md +41 -22
  38. package/docs/pages/forms/choice-cards.md +11 -6
  39. package/docs/pages/forms/controls.md +1 -1
  40. package/docs/pages/forms/detached-actions.md +1 -1
  41. package/docs/pages/forms/floating-field.md +10 -5
  42. package/docs/pages/forms/form-actions.md +1 -1
  43. package/docs/pages/forms/input-policy.md +1 -1
  44. package/docs/pages/forms/joined-controls.md +4 -4
  45. package/docs/pages/forms/labels.md +6 -6
  46. package/docs/pages/forms/otp.md +1 -1
  47. package/docs/pages/forms/overview.md +19 -19
  48. package/docs/pages/forms/password.md +1 -1
  49. package/docs/pages/forms/range.md +1 -1
  50. package/docs/pages/forms/states-and-sizes.md +41 -45
  51. package/docs/pages/forms/sticky-actions.md +3 -3
  52. package/docs/pages/forms/switch.md +110 -2
  53. package/docs/pages/forms/validation.md +9 -9
  54. package/docs/pages/foundations/philosophy.md +1 -1
  55. package/docs/pages/foundations/theming.md +29 -7
  56. package/docs/pages/foundations/tokens.md +22 -10
  57. package/docs/pages/foundations/typography.md +50 -20
  58. package/docs/pages/guides/bootstrap.md +11 -11
  59. package/docs/pages/guides/integrating-actual-css.md +186 -0
  60. package/docs/pages/guides/introduction.md +23 -26
  61. package/docs/pages/guides/modular-import.md +173 -0
  62. package/docs/pages/guides/progressive-enhancement.md +81 -78
  63. package/docs/pages/guides/tailwind.md +6 -8
  64. package/docs/pages/layout/app-shell.md +1 -1
  65. package/docs/pages/layout/center.md +1 -1
  66. package/docs/pages/layout/choosing.md +39 -0
  67. package/docs/pages/layout/cluster.md +4 -2
  68. package/docs/pages/layout/column-layout.md +206 -0
  69. package/docs/pages/layout/frame.md +6 -1
  70. package/docs/pages/layout/grid.md +100 -102
  71. package/docs/pages/layout/list.md +1 -1
  72. package/docs/pages/layout/media.md +10 -3
  73. package/docs/pages/layout/overview.md +15 -2
  74. package/docs/pages/layout/scroll-snap.md +2 -2
  75. package/docs/pages/layout/scroll-target.md +45 -0
  76. package/docs/pages/layout/sidebar-layout.md +20 -19
  77. package/docs/pages/layout/stack.md +90 -1
  78. package/docs/pages/layout/switcher.md +10 -26
  79. package/docs/pages/layout/topbar.md +2 -2
  80. package/docs/pages/patterns/actions.md +1 -1
  81. package/docs/pages/patterns/code-block.md +1 -1
  82. package/docs/pages/patterns/exclusive-accordion.md +1 -1
  83. package/docs/pages/patterns/hierarchical-nav.md +1 -1
  84. package/docs/pages/patterns/icon-input.md +1 -1
  85. package/docs/pages/patterns/intent-nesting.md +1 -1
  86. package/docs/pages/patterns/navigation-list.md +1 -1
  87. package/docs/pages/patterns/overview.md +1 -1
  88. package/docs/pages/patterns/structured-lists.md +1 -1
  89. package/docs/pages/utilities/accessibility.md +3 -3
  90. package/docs/pages/utilities/flex.md +15 -15
  91. package/docs/pages/utilities/links.md +4 -4
  92. package/docs/pages/utilities/list.md +8 -7
  93. package/docs/pages/utilities/overflow.md +8 -8
  94. package/docs/pages/utilities/overview.md +1 -1
  95. package/docs/pages/utilities/shape.md +3 -3
  96. package/docs/pages/utilities/sizing-wrapping.md +23 -16
  97. package/docs/pages/utilities/spacing.md +20 -20
  98. package/docs/pages/utilities/text.md +10 -10
  99. package/llms.txt +46 -4
  100. package/package.json +43 -13
  101. package/scripts/reserved-classes.json +261 -0
  102. package/src/cli/actual-css.js +133 -0
  103. package/src/css/actual.css +1 -28
  104. package/src/css/actual.full.css +11 -2
  105. package/src/css/components/alert.css +33 -10
  106. package/src/css/components/badge.css +38 -19
  107. package/src/css/components/breadcrumb.css +1 -1
  108. package/src/css/components/button.css +35 -13
  109. package/src/css/components/card.css +37 -4
  110. package/src/css/components/drawer.css +27 -2
  111. package/src/css/components/flyout.css +5 -0
  112. package/src/css/components/index.css +32 -27
  113. package/src/css/components/join.css +8 -1
  114. package/src/css/components/key.css +2 -0
  115. package/src/css/components/menu.css +11 -1
  116. package/src/css/components/meter.css +4 -1
  117. package/src/css/components/modal.css +5 -0
  118. package/src/css/components/navbar.css +13 -5
  119. package/src/css/components/pagination.css +11 -0
  120. package/src/css/components/spinner.css +5 -0
  121. package/src/css/components/status-bar.css +5 -0
  122. package/src/css/components/surface.css +5 -0
  123. package/src/css/components/table.css +6 -0
  124. package/src/css/components/tooltip.css +5 -0
  125. package/src/css/core/index.css +9 -0
  126. package/src/css/core/print.css +18 -0
  127. package/src/css/{theme.css → core/theme.css} +20 -10
  128. package/src/css/{tokens.css → core/tokens.css} +15 -2
  129. package/src/css/{variants.css → core/variants.css} +27 -13
  130. package/src/css/effects/index.css +2 -0
  131. package/src/css/forms/base.css +8 -0
  132. package/src/css/forms/choice-card.css +5 -4
  133. package/src/css/forms/choice.css +49 -37
  134. package/src/css/forms/control.css +6 -1
  135. package/src/css/forms/custom-select.css +2 -0
  136. package/src/css/{optional → forms}/floating-field.css +25 -7
  137. package/src/css/forms/index.css +10 -13
  138. package/src/css/forms/native.css +15 -2
  139. package/src/css/{optional → forms}/otp.css +20 -16
  140. package/src/css/forms/switch.css +31 -29
  141. package/src/css/layout/app-shell.css +6 -0
  142. package/src/css/layout/center.css +13 -0
  143. package/src/css/layout/cluster.css +21 -0
  144. package/src/css/layout/column-layout.css +144 -0
  145. package/src/css/layout/container-query.css +13 -0
  146. package/src/css/layout/frame.css +19 -0
  147. package/src/css/layout/grid.css +141 -0
  148. package/src/css/layout/index.css +42 -0
  149. package/src/css/layout/measure.css +9 -0
  150. package/src/css/layout/media.css +10 -0
  151. package/src/css/layout/scroll-target.css +15 -0
  152. package/src/css/layout/scroller.css +41 -0
  153. package/src/css/layout/sidebar-layout.css +31 -0
  154. package/src/css/layout/stack.css +21 -0
  155. package/src/css/layout/switcher.css +23 -0
  156. package/src/css/{optional/layout-extra.css → layout/topbar.css} +0 -20
  157. package/src/css/typography/index.css +5 -0
  158. package/src/css/typography/lead.css +7 -0
  159. package/src/css/{components → typography}/overline.css +10 -4
  160. package/src/css/{prose.css → typography/prose.css} +53 -38
  161. package/src/css/{utilities.css → utilities/base.css} +2 -0
  162. package/src/css/utilities/index.css +3 -0
  163. package/src/js/escape.js +36 -0
  164. package/src/js/flyout.js +22 -12
  165. package/src/js/full.js +26 -0
  166. package/src/js/index.js +5 -21
  167. package/src/js/surface.js +4 -32
  168. package/src/js/tooltip.js +65 -24
  169. package/src/js/validation.js +1 -1
  170. package/src/tooling/css-bundle.js +308 -0
  171. package/dist/actual-themes.min.css +0 -1
  172. package/dist/optional.css +0 -1048
  173. package/dist/optional.min.css +0 -1
  174. package/docs/pages/guides/optional-components.md +0 -33
  175. package/src/css/grid.css +0 -96
  176. package/src/css/layout.css +0 -108
  177. package/src/css/optional/index.css +0 -10
  178. package/src/css/optional/scroller.css +0 -63
  179. package/src/css/print.css +0 -47
  180. package/src/css/themes/corporate.css +0 -60
  181. package/src/css/themes/cyberpunk.css +0 -60
  182. package/src/css/themes/dim.css +0 -33
  183. package/src/css/themes/edge.css +0 -117
  184. package/src/css/themes/forest.css +0 -65
  185. package/src/css/themes/gradient.css +0 -97
  186. package/src/css/themes/index.css +0 -43
  187. package/src/css/themes/indigo.css +0 -33
  188. package/src/css/themes/lavender.css +0 -66
  189. package/src/css/themes/mono.css +0 -60
  190. package/src/css/themes/neon.css +0 -66
  191. package/src/css/themes/ocean.css +0 -60
  192. package/src/css/themes/petrol.css +0 -64
  193. package/src/css/themes/spruce.css +0 -64
  194. package/src/css/themes/square.css +0 -65
  195. package/src/css/themes/sunset.css +0 -66
  196. /package/src/css/{optional → components}/chat.css +0 -0
  197. /package/src/css/{optional → components}/fab.css +0 -0
  198. /package/src/css/{base.css → core/base.css} +0 -0
  199. /package/src/css/{focus.css → core/focus.css} +0 -0
  200. /package/src/css/{intents.css → core/intents.css} +0 -0
  201. /package/src/css/{reset.css → core/reset.css} +0 -0
  202. /package/src/css/{optional → effects}/aura.css +0 -0
  203. /package/src/css/{optional → layout}/scroll-snap.css +0 -0
  204. /package/src/css/{optional/typography-fluid.css → typography/fluid.css} +0 -0
  205. /package/src/css/{optional/utilities-extra.css → utilities/extra.css} +0 -0
@@ -0,0 +1,5 @@
1
+ /* Typography — prose scope and typographic treatments. */
2
+ @import "./prose.css";
3
+ @import "./lead.css";
4
+ @import "./overline.css";
5
+ @import "./fluid.css";
@@ -0,0 +1,7 @@
1
+ /* Lead paragraph: a slightly larger, more relaxed reading-introduction text.
2
+ Non-fluid and always available — use .text-lead from the optional fluid
3
+ module for marketing/display surfaces. */
4
+ .lead {
5
+ font-size: 1.125em;
6
+ line-height: var(--line-height-relaxed);
7
+ }
@@ -4,6 +4,9 @@
4
4
  * Base form is unstyled chrome that takes its surrounding color. Add .pill for
5
5
  * the bordered rounded shape, and combine with an intent class (.primary,
6
6
  * .success, etc.) to tint the text, border, and background.
7
+ *
8
+ * Public hooks:
9
+ * --overline-radius
7
10
  */
8
11
  /* Intent boundary — inherited intents do not tint this component.
9
12
  Local intent classes still win because :where() has zero specificity. */
@@ -12,7 +15,6 @@
12
15
  }
13
16
 
14
17
  .overline {
15
- align-self: flex-start;
16
18
  color: var(--text-muted);
17
19
  font-size: var(--font-size-sm);
18
20
  font-weight: var(--font-weight-medium);
@@ -21,14 +23,18 @@
21
23
  }
22
24
 
23
25
  .overline.pill {
26
+ --overline-radius: var(--radius-full);
27
+
24
28
  display: inline-flex;
25
29
  align-items: center;
26
30
  gap: 0.5em;
27
31
  padding-inline: 0.6em;
28
32
  padding-block: 0.25em;
33
+ inline-size: fit-content;
34
+ max-inline-size: 100%;
29
35
  color: var(--intent, var(--text-muted));
30
36
  border: var(--border-width) solid var(--border);
31
- border-radius: var(--radius-full);
37
+ border-radius: var(--overline-radius);
32
38
  background: var(--surface);
33
39
  }
34
40
 
@@ -36,7 +42,7 @@
36
42
  keeps a readable bordered pill everywhere else. */
37
43
  @supports (color: color-mix(in oklch, red, white)) {
38
44
  .overline.pill {
39
- border-color: color-mix(in oklch, var(--intent, var(--border)) 35%, var(--border));
40
- background: color-mix(in oklch, var(--intent, transparent) 6%, var(--surface));
45
+ border-color: color-mix(in oklab, var(--intent, var(--border)) 35%, var(--border));
46
+ background: color-mix(in oklab, var(--intent, transparent) 6%, var(--surface));
41
47
  }
42
48
  }
@@ -14,6 +14,7 @@
14
14
  * --prose-h2
15
15
  * --prose-h3
16
16
  * --prose-h4
17
+ * --link
17
18
  */
18
19
 
19
20
  .prose {
@@ -29,19 +30,23 @@
29
30
  --prose-h4: 1.125rem;
30
31
 
31
32
  max-inline-size: var(--prose-measure);
32
- color: var(--text);
33
33
  font-size: var(--prose-size);
34
34
  line-height: var(--prose-line-height);
35
35
  overflow-wrap: break-word;
36
36
  }
37
37
 
38
- .prose :where(p, ul, ol, dl, blockquote, pre, table, figure) {
39
- margin-block: var(--prose-flow) 0;
38
+ /* Vertical rhythm belongs to the direct block that participates in the
39
+ document, so it must not be injected into the internals of a nested
40
+ component (e.g. a .table-wrap around a .table would trap the margin). */
41
+ .prose > * + * {
42
+ margin-block-start: var(--prose-flow);
40
43
  }
41
44
 
45
+ /* Heading hierarchy remains low-specificity and contextual: a surface may
46
+ remap --heading without prose repainting it with the root text color. */
42
47
  .prose :where(h1, h2, h3, h4, h5, h6) {
43
48
  margin-block: 1.5em 0;
44
- color: var(--text);
49
+ color: var(--heading, var(--text));
45
50
  font-weight: var(--font-weight-strong);
46
51
  line-height: var(--prose-heading-line-height);
47
52
  }
@@ -54,48 +59,71 @@
54
59
  margin-block-start: 0;
55
60
  }
56
61
 
57
- .prose h1 {
62
+ /* Element recipes are wrapped in :where() so every .prose rule is 0-1-0.
63
+ An explicit component/util class can therefore override prose defaults
64
+ regardless of the underlying element — a .btn or .alert link must not be
65
+ reshaped just because it lives inside authored content. */
66
+ .prose :where(h1) {
58
67
  font-size: var(--prose-h1);
59
68
  }
60
69
 
61
- .prose h2 {
70
+ .prose :where(h2) {
62
71
  font-size: var(--prose-h2);
63
72
  }
64
73
 
65
- .prose h3 {
74
+ .prose :where(h3) {
66
75
  font-size: var(--prose-h3);
67
76
  }
68
77
 
69
- .prose h4 {
78
+ .prose :where(h4) {
70
79
  font-size: var(--prose-h4);
71
80
  }
72
81
 
73
- .prose a {
74
- color: var(--primary);
82
+ /* Link ink defaults to --primary. --link is an optional escape hatch: a
83
+ theme whose primary is too light or too vivid to read as inline text sets
84
+ it without having to restate the rule. */
85
+ .prose :where(a) {
86
+ color: var(--link, var(--primary));
75
87
  text-decoration-line: underline;
76
88
  text-decoration-thickness: 0.08em;
77
89
  text-underline-offset: 0.18em;
78
90
  }
79
91
 
80
- .prose a:hover {
92
+ .prose :where(a:hover) {
81
93
  text-decoration-thickness: 0.12em;
82
94
  }
83
95
 
84
96
  @supports (color: color-mix(in oklch, red, white)) {
85
- .prose a:hover {
86
- color: color-mix(in oklch, var(--primary), var(--text) 15%);
97
+ .prose :where(a:hover) {
98
+ color: color-mix(in oklab, var(--link, var(--primary)), var(--text) 15%);
87
99
  }
88
100
  }
89
101
 
102
+ /* Rich-text descendants are intentional here. Components and application UI
103
+ belong outside the prose boundary. */
90
104
  .prose :where(ul, ol) {
91
105
  padding-inline-start: 1.5em;
92
106
  }
93
107
 
94
- .prose li + li {
108
+ .prose :where(li + li) {
95
109
  margin-block-start: 0.35em;
96
110
  }
97
111
 
98
- .prose code {
112
+ /* Description lists keep document flow: a valid <dl> may pair several <dt>
113
+ with one <dd> or the reverse, so no grid layout is imposed here. */
114
+ .prose :where(dt) {
115
+ font-weight: var(--font-weight-strong);
116
+ }
117
+
118
+ .prose :where(dd) {
119
+ margin-inline-start: 1.5em;
120
+ }
121
+
122
+ .prose :where(dd + dt) {
123
+ margin-block-start: 0.75em;
124
+ }
125
+
126
+ .prose :where(code) {
99
127
  padding: 0.15em 0.3em;
100
128
  border-radius: var(--radius-sm);
101
129
  background: var(--surface-subtle);
@@ -104,7 +132,8 @@
104
132
  }
105
133
 
106
134
  /* Visual twin of .key (components/key.css) — keep the two recipes in sync. */
107
- .prose kbd {
135
+ /* @sync key-recipe */
136
+ .prose :where(kbd) {
108
137
  display: inline-flex;
109
138
  align-items: center;
110
139
  justify-content: center;
@@ -123,8 +152,9 @@
123
152
  white-space: nowrap;
124
153
  vertical-align: 0.08em;
125
154
  }
155
+ /* @sync key-recipe */
126
156
 
127
- .prose pre {
157
+ .prose :where(pre) {
128
158
  overflow-x: auto;
129
159
  padding: 1em;
130
160
  border: var(--border-width) solid var(--border);
@@ -132,53 +162,38 @@
132
162
  background: var(--surface-subtle);
133
163
  }
134
164
 
135
- .prose pre code {
165
+ .prose :where(pre) :where(code) {
136
166
  padding: 0;
137
167
  background: transparent;
138
168
  }
139
169
 
140
- .prose blockquote {
170
+ .prose :where(blockquote) {
141
171
  padding-inline-start: 1em;
142
172
  border-inline-start: var(--border-width) solid var(--border);
143
173
  color: var(--text-muted);
144
174
  }
145
175
 
146
- .prose figure {
176
+ .prose :where(figure) {
147
177
  margin-inline: 0;
148
178
  }
149
179
 
150
- .prose figcaption {
180
+ .prose :where(figcaption) {
151
181
  margin-block-start: 0.5em;
152
182
  color: var(--text-muted);
153
183
  font-size: 0.875em;
154
184
  }
155
185
 
156
- .prose table {
186
+ .prose :where(table) {
157
187
  inline-size: 100%;
158
188
  border-collapse: collapse;
159
189
  }
160
190
 
161
- .prose th,
162
- .prose td {
191
+ .prose :where(th, td) {
163
192
  padding: 0.5em;
164
193
  border-block-end: var(--border-width) solid var(--border);
165
194
  text-align: start;
166
195
  }
167
196
 
168
- /* Lead paragraph: a slightly larger, more relaxed reading-introduction text.
169
- Non-fluid and always available — use .text-lead from the optional fluid
170
- module for marketing/display surfaces. */
171
- .lead {
172
- font-size: 1.125em;
173
- line-height: var(--line-height-relaxed);
174
- }
175
-
176
- /* Readable content measure. Composes with .center for centering or with
177
- margin-inline: auto for a one-off centered block. */
178
- .measure {
179
- max-inline-size: var(--prose-measure, 70ch);
180
- }
181
-
182
197
  .prose.sm {
183
198
  --prose-size: 0.9375rem;
184
199
  --prose-h1: 1.75rem;
@@ -12,6 +12,8 @@
12
12
  margin: -1px;
13
13
  overflow: hidden;
14
14
  clip: rect(0, 0, 0, 0);
15
+ /* clip alone is legacy-only; clip-path is the modern hiding mechanism. */
16
+ clip-path: inset(50%);
15
17
  white-space: nowrap;
16
18
  border: 0;
17
19
  }
@@ -0,0 +1,3 @@
1
+ /* Utilities — small, single-purpose escape hatches. */
2
+ @import "./base.css";
3
+ @import "./extra.css";
@@ -0,0 +1,36 @@
1
+ /*
2
+ * Per-document LIFO dismissal for transient UI that closes on Escape.
3
+ * Consumers register only while visible, so the topmost dismissable UI owns
4
+ * one key press and pinned or hidden UI never consumes it.
5
+ */
6
+
7
+ const dismissableStacks = new WeakMap();
8
+
9
+ export function registerEscapeDismissal(element, dismiss) {
10
+ const doc = element.ownerDocument;
11
+ let stack = dismissableStacks.get(doc);
12
+ if (!stack) {
13
+ stack = [];
14
+ dismissableStacks.set(doc, stack);
15
+ doc.addEventListener("keydown", onDocumentEscape);
16
+ }
17
+
18
+ const entry = { element, dismiss };
19
+ stack.push(entry);
20
+
21
+ return () => {
22
+ const index = stack.indexOf(entry);
23
+ if (index >= 0) stack.splice(index, 1);
24
+ };
25
+ }
26
+
27
+ function onDocumentEscape(event) {
28
+ if (event.key !== "Escape" || event.ctrlKey || event.altKey || event.shiftKey) return;
29
+
30
+ const stack = dismissableStacks.get(event.currentTarget);
31
+ const entry = stack.at(-1);
32
+ if (!entry) return;
33
+
34
+ event.preventDefault();
35
+ entry.dismiss({ restoreFocus: true });
36
+ }
package/src/js/flyout.js CHANGED
@@ -144,10 +144,29 @@ function connectTrigger(trigger) {
144
144
  trigger.addEventListener("keydown", onTriggerKeydown, { signal: controller.signal });
145
145
  }
146
146
 
147
- function resolvePanel(trigger, state) {
148
- if (state.panel?.isConnected) return state.panel;
147
+ function releasePanelReference(trigger, state) {
148
+ state.releaseMenu?.();
149
+ state.releaseMenu = null;
150
+
151
+ const panel = state.panel;
152
+ state.panel = null;
153
+ if (!panel) return;
154
+
155
+ const panelEntry = panelRefs.get(panel);
156
+ if (!panelEntry) return;
157
+
158
+ panelEntry.triggers.delete(trigger);
159
+ if (panelEntry.triggers.size === 0) {
160
+ panelEntry.release();
161
+ panelRefs.delete(panel);
162
+ }
163
+ }
149
164
 
165
+ function resolvePanel(trigger, state) {
150
166
  const panel = panelFor(trigger);
167
+ if (state.panel === panel && panel?.isConnected) return panel;
168
+
169
+ releasePanelReference(trigger, state);
151
170
  if (!panel) return null;
152
171
 
153
172
  let panelEntry = panelRefs.get(panel);
@@ -174,21 +193,12 @@ function resolvePanel(trigger, state) {
174
193
  function disconnectTrigger(trigger) {
175
194
  const state = triggerMap.get(trigger);
176
195
  if (!state) return;
177
- state.releaseMenu?.();
178
196
  state.controller.abort();
179
197
  if (state.panel?.isConnected && isSurfaceOpen(state.panel)) {
180
198
  closeSurface(state.panel, { restoreFocus: false });
181
199
  }
200
+ releasePanelReference(trigger, state);
182
201
  triggerMap.delete(trigger);
183
-
184
- const panelEntry = state.panel ? panelRefs.get(state.panel) : undefined;
185
- if (panelEntry) {
186
- panelEntry.triggers.delete(trigger);
187
- if (panelEntry.triggers.size === 0) {
188
- panelEntry.release();
189
- panelRefs.delete(state.panel);
190
- }
191
- }
192
202
  }
193
203
 
194
204
  registerEnhancement("flyout", (trigger) => {
package/src/js/full.js ADDED
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Actual CSS - JavaScript runtime, full bundle entry.
3
+ *
4
+ * Each module self-registers via observer: importing this bundle enables
5
+ * common behaviors. No DOMContentLoaded ceremony, no init calls.
6
+ * Injected content wires automatically; removal cleans up automatically.
7
+ *
8
+ * A plain `<script src="actual.full.js"></script>` (no bundler, no import) still
9
+ * gets the full runtime, including declarative triggers like status's
10
+ * `command="--status"` and the `actual:status` event — no global needed to
11
+ * drive them from other inline scripts either.
12
+ */
13
+
14
+ import "./flyout.js";
15
+ import "./context-menu.js";
16
+ import "./dialog.js";
17
+ import "./dismiss.js";
18
+ import "./tab.js";
19
+ import "./tooltip.js";
20
+ import "./scrollspy.js";
21
+ import "./filter.js";
22
+ import "./mask.js";
23
+ import "./password.js";
24
+ import "./validation.js";
25
+ import "./status.js";
26
+ import "./index.js";
package/src/js/index.js CHANGED
@@ -1,28 +1,12 @@
1
1
  /*
2
- * Actual CSS - JavaScript runtime.
2
+ * Actual CSS - JavaScript runtime, core entry.
3
3
  *
4
- * Each module self-registers via observer: importing the default runtime
5
- * enables common behaviors. No DOMContentLoaded ceremony, no init calls.
6
- * Injected content wires automatically; removal cleans up automatically.
7
- *
8
- * A plain `<script src="actual.js"></script>` (no bundler, no import) still
9
- * gets the full runtime, including declarative triggers like status's
10
- * `command="--status"` and the `actual:status` event — no global needed to
11
- * drive them from other inline scripts either.
4
+ * This entry only starts the enhancement manifest loader. Built-in behaviors
5
+ * (dialogs, menus, tabs, validation, ...) ship in the full bundle entry
6
+ * (src/js/full.js). Use this module for pages that only need progressive
7
+ * enhancement via enhancement manifests.
12
8
  */
13
9
 
14
- import "./flyout.js";
15
- import "./context-menu.js";
16
- import "./dialog.js";
17
- import "./dismiss.js";
18
- import "./tab.js";
19
- import "./tooltip.js";
20
- import "./scrollspy.js";
21
- import "./filter.js";
22
- import "./mask.js";
23
- import "./password.js";
24
- import "./validation.js";
25
- import "./status.js";
26
10
  import { watchEnhancementManifests } from "./enhancement-loader.js";
27
11
 
28
12
  watchEnhancementManifests();
package/src/js/surface.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import enhance from "./enhance.js";
2
+ import { registerEscapeDismissal } from "./escape.js";
2
3
  import { EVENTS } from "./events.js";
3
4
  import { autoUpdate, reposition, repositionAt } from "./floating.js";
4
5
 
@@ -274,7 +275,9 @@ export function retainSurface(panel) {
274
275
  released = true;
275
276
  entry.count--;
276
277
  if (entry.count <= 0) {
277
- disconnectSurface(panel, { restore: false });
278
+ // A released retainer may leave a mounted panel whose original parent
279
+ // either still needs it back or has disappeared along with the trigger.
280
+ disconnectSurface(panel);
278
281
  surfaceRetainers.delete(panel);
279
282
  }
280
283
  };
@@ -416,37 +419,6 @@ function ensureDocumentClick(menu) {
416
419
  doc.addEventListener("click", onDocumentClick);
417
420
  }
418
421
 
419
- const dismissableStacks = new WeakMap();
420
-
421
- export function registerEscapeDismissal(element, dismiss) {
422
- const doc = element.ownerDocument;
423
- let stack = dismissableStacks.get(doc);
424
- if (!stack) {
425
- stack = [];
426
- dismissableStacks.set(doc, stack);
427
- doc.addEventListener("keydown", onDocumentEscape);
428
- }
429
-
430
- const entry = { element, dismiss };
431
- stack.push(entry);
432
-
433
- return () => {
434
- const index = stack.indexOf(entry);
435
- if (index >= 0) stack.splice(index, 1);
436
- };
437
- }
438
-
439
- function onDocumentEscape(event) {
440
- if (event.key !== "Escape" || event.ctrlKey || event.altKey || event.shiftKey) return;
441
-
442
- const stack = dismissableStacks.get(event.currentTarget);
443
- const entry = stack.at(-1);
444
- if (!entry) return;
445
-
446
- event.preventDefault();
447
- entry.dismiss({ restoreFocus: true });
448
- }
449
-
450
422
  export function getSurfaceAutoClose(menu) {
451
423
  return surfaceMap.get(menu)?.autoClose ?? "outside";
452
424
  }
package/src/js/tooltip.js CHANGED
@@ -14,9 +14,9 @@
14
14
  * treated as a tooltip trigger.
15
15
  *
16
16
  * Sharing: an explicit tooltip may be referenced by several triggers. The
17
- * tooltip element is wired once (floating listeners + tracking);
18
- * each trigger only registers itself and the wiring is torn down
19
- * when the last trigger leaves the DOM.
17
+ * tooltip element is wired once; each trigger only registers
18
+ * itself. Positional tracking runs only while the tip is visible,
19
+ * and all wiring is torn down when the last trigger leaves the DOM.
20
20
  *
21
21
  * Lazy: tooltip elements are created (shorthand) or wired (explicit) on
22
22
  * first mouseover / focusin. No page-load scan, no DOM overhead
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import enhance from "./enhance.js";
31
+ import { registerEscapeDismissal } from "./escape.js";
31
32
  import { autoUpdate, reposition } from "./floating.js";
32
33
  import { CLASSES } from "./selectors.js";
33
34
 
@@ -39,7 +40,9 @@ const HIDE_DELAY_MS = 100;
39
40
  * @property {Set<Element>} refs Triggers that currently reference this tip.
40
41
  * @property {Element | null} activeRef Trigger that last showed this tip.
41
42
  * @property {AbortController} controller Tip-level event listener cleanup.
42
- * @property {() => void} stopTracking Floating-position cleanup.
43
+ * @property {(() => void) | null} stopTracking Floating-position cleanup while visible.
44
+ * @property {(() => void) | null} unregisterEscape Escape-stack cleanup while visible and dismissable.
45
+ * @property {Element | null} escapeRef Trigger represented by the current Escape-stack entry.
43
46
  * @property {boolean} generated True when the tip was created from data-tooltip text.
44
47
  * @property {{ parent: Node, next: ChildNode | null } | null} mount Original DOM position for explicit tips moved into a dialog/body root.
45
48
  * @property {ReturnType<typeof setTimeout> | null} timer Pending delayed show.
@@ -52,7 +55,7 @@ const HIDE_DELAY_MS = 100;
52
55
  let uid = 0;
53
56
  const triggerStates = new WeakMap(); // trigger -> { tip, cleanup }
54
57
  /** @type {WeakMap<Element, TooltipState>} */
55
- const tipStates = new WeakMap(); // tip -> { refs, activeRef, controller, untrack, generated, mount, timer }
58
+ const tipStates = new WeakMap(); // tip -> { refs, activeRef, controller, stopTracking, generated, mount, timer }
56
59
 
57
60
  function mountTip(tip, trigger) {
58
61
  const root = trigger.closest("dialog") || trigger.ownerDocument.body;
@@ -140,6 +143,50 @@ function repositionTip(ref, tip) {
140
143
  });
141
144
  }
142
145
 
146
+ function updateTrackedTip(tip, state) {
147
+ const ref = state.activeRef;
148
+ if (!ref) return;
149
+
150
+ const alwaysVisible = isAlwaysVisible(ref);
151
+ // Reveal before measuring: an always-visible tooltip may currently be
152
+ // hidden because its trigger was outside the positioning boundary.
153
+ if (alwaysVisible) tip.hidden = false;
154
+ if (tip.hidden || repositionTip(ref, tip)) {
155
+ syncEscapeDismissal(tip, state);
156
+ return;
157
+ }
158
+
159
+ if (alwaysVisible) tip.hidden = true;
160
+ else hideTip(tip, true);
161
+ }
162
+
163
+ function startTracking(tip, state) {
164
+ if (state.stopTracking) return;
165
+
166
+ state.stopTracking = autoUpdate(tip, () => updateTrackedTip(tip, state));
167
+ }
168
+
169
+ function stopTracking(state) {
170
+ state?.stopTracking?.();
171
+ if (state) state.stopTracking = null;
172
+ }
173
+
174
+ function syncEscapeDismissal(tip, state) {
175
+ const ref = state.activeRef;
176
+ const dismissable = !tip.hidden && !isAlwaysVisible(ref);
177
+ // Position tracking calls this after every successful update. Preserve the
178
+ // current entry so a scroll/resize tick cannot reorder the LIFO stack.
179
+ if (dismissable && state.unregisterEscape && state.escapeRef === ref) return;
180
+
181
+ state.unregisterEscape?.();
182
+ state.unregisterEscape = null;
183
+ state.escapeRef = null;
184
+ if (!dismissable) return;
185
+
186
+ state.unregisterEscape = registerEscapeDismissal(tip, () => hideTip(tip, true));
187
+ state.escapeRef = ref;
188
+ }
189
+
143
190
  function hideTip(tip, force = false) {
144
191
  const state = tipStates.get(tip);
145
192
  if (!force && isAlwaysVisible(state?.activeRef)) return;
@@ -152,6 +199,12 @@ function hideTip(tip, force = false) {
152
199
  state.hideTimer = null;
153
200
  }
154
201
  tip.hidden = true;
202
+ state?.unregisterEscape?.();
203
+ if (state) {
204
+ state.unregisterEscape = null;
205
+ state.escapeRef = null;
206
+ }
207
+ stopTracking(state);
155
208
  }
156
209
 
157
210
  function clearHide(tip) {
@@ -185,12 +238,9 @@ function wireTip(tip, options = {}) {
185
238
  refs: new Set(),
186
239
  activeRef: null,
187
240
  controller,
188
- stopTracking: autoUpdate(tip, () => {
189
- if (isAlwaysVisible(state.activeRef)) tip.hidden = false;
190
- if (!tip.hidden && state.activeRef) {
191
- if (!repositionTip(state.activeRef, tip)) hideTip(tip, true);
192
- }
193
- }),
241
+ stopTracking: null,
242
+ unregisterEscape: null,
243
+ escapeRef: null,
194
244
  generated: options.generated === true,
195
245
  mount: options.mount || null,
196
246
  timer: null,
@@ -217,17 +267,6 @@ function wireTip(tip, options = {}) {
217
267
  { signal: controller.signal },
218
268
  );
219
269
 
220
- tip.ownerDocument.addEventListener(
221
- "keydown",
222
- (event) => {
223
- if (event.key !== "Escape" || event.ctrlKey || event.altKey || event.shiftKey) return;
224
- if (tip.hidden) return;
225
- event.preventDefault();
226
- hideTip(tip);
227
- },
228
- { signal: controller.signal },
229
- );
230
-
231
270
  tipStates.set(tip, state);
232
271
  return state;
233
272
  }
@@ -308,7 +347,7 @@ function ensureTip(trigger) {
308
347
 
309
348
  hideTip(tip, true);
310
349
  state.controller.abort();
311
- state.stopTracking();
350
+ stopTracking(state);
312
351
  tipStates.delete(tip);
313
352
  if (state.generated) tip.remove();
314
353
  else restoreTip(tip, state);
@@ -328,12 +367,14 @@ function show(tip, ref, immediate = false) {
328
367
  if (!state) return;
329
368
 
330
369
  state.activeRef = ref;
370
+ syncEscapeDismissal(tip, state);
331
371
  clearHide(tip);
332
372
  if (state.timer) clearTimeout(state.timer);
333
373
  const reveal = () => {
334
374
  state.timer = null;
335
375
  tip.hidden = false;
336
- repositionTip(ref, tip);
376
+ startTracking(tip, state);
377
+ updateTrackedTip(tip, state);
337
378
  };
338
379
 
339
380
  if (immediate) reveal();
@@ -391,7 +391,7 @@ export class FormValidator {
391
391
  static setErrors(form, errors) {
392
392
  if (!isFormElement(form)) return;
393
393
  for (const [name, message] of Object.entries(errors)) {
394
- const found = form.elements[name];
394
+ const found = form.elements.namedItem(name);
395
395
  if (!found) continue;
396
396
  if (found instanceof Element) {
397
397
  FormValidator.setFieldError(found, message);