actual-css 0.6.0 → 0.8.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 (143) hide show
  1. package/CHANGELOG.md +214 -1
  2. package/README.md +13 -11
  3. package/components.json +668 -0
  4. package/dist/actual.css +166 -37
  5. package/dist/actual.full.css +1005 -550
  6. package/dist/actual.full.js +1 -1
  7. package/dist/actual.full.min.css +1 -1
  8. package/dist/actual.js +1 -1
  9. package/dist/actual.min.css +1 -1
  10. package/docs/design-notes/accessibility-decisions.md +33 -0
  11. package/docs/design-notes/browser-support.md +82 -25
  12. package/docs/design-notes/column-layout.md +3 -3
  13. package/docs/design-notes/css-anchor.md +75 -21
  14. package/docs/design-notes/enhancement-contract.md +14 -6
  15. package/docs/design-notes/grid.md +4 -4
  16. package/docs/design-notes/naming.md +6 -1
  17. package/docs/design-notes/platform-alignment.md +99 -2
  18. package/docs/design-notes/popover-manual-poc.md +328 -0
  19. package/docs/design-notes/surface-reparenting.md +131 -0
  20. package/docs/design-notes/tooltip-coordinate-space.md +132 -0
  21. package/docs/design-notes/widget-primitives.md +9 -6
  22. package/docs/pages/components/accordion.md +41 -2
  23. package/docs/pages/components/alert.md +80 -30
  24. package/docs/pages/components/app-navigation.md +6 -0
  25. package/docs/pages/components/avatar.md +6 -6
  26. package/docs/pages/components/badge.md +101 -16
  27. package/docs/pages/components/button.md +16 -8
  28. package/docs/pages/components/card.md +10 -10
  29. package/docs/pages/components/dialog.md +16 -6
  30. package/docs/pages/components/fab.md +4 -3
  31. package/docs/pages/components/list.md +42 -4
  32. package/docs/pages/components/navbar.md +17 -1
  33. package/docs/pages/components/pagination.md +2 -2
  34. package/docs/pages/components/steps.md +19 -0
  35. package/docs/pages/components/table.md +3 -2
  36. package/docs/pages/enhancements/flyout.md +144 -24
  37. package/docs/pages/enhancements/overview.md +25 -0
  38. package/docs/pages/enhancements/status-bar.md +20 -0
  39. package/docs/pages/enhancements/tabs.md +45 -37
  40. package/docs/pages/enhancements/tooltip.md +40 -2
  41. package/docs/pages/examples/overview.md +38 -5
  42. package/docs/pages/forms/choice-cards.md +6 -2
  43. package/docs/pages/forms/combobox.md +1 -1
  44. package/docs/pages/forms/floating-field.md +4 -3
  45. package/docs/pages/forms/joined-controls.md +22 -0
  46. package/docs/pages/forms/otp.md +2 -1
  47. package/docs/pages/forms/overview.md +1 -1
  48. package/docs/pages/forms/states-and-sizes.md +16 -18
  49. package/docs/pages/forms/sticky-actions.md +2 -2
  50. package/docs/pages/forms/switch.md +1 -1
  51. package/docs/pages/foundations/philosophy.md +67 -22
  52. package/docs/pages/foundations/theming.md +10 -2
  53. package/docs/pages/foundations/tokens.md +118 -21
  54. package/docs/pages/foundations/typography.md +35 -14
  55. package/docs/pages/guides/introduction.md +11 -9
  56. package/docs/pages/guides/progressive-enhancement.md +136 -49
  57. package/docs/pages/guides/tailwind.md +3 -2
  58. package/docs/pages/layout/media.md +1 -1
  59. package/docs/pages/layout/overview.md +11 -1
  60. package/docs/pages/layout/scroll-snap.md +3 -3
  61. package/docs/pages/layout/stack.md +17 -9
  62. package/docs/pages/patterns/icon-input.md +4 -2
  63. package/docs/pages/patterns/icon-slot.md +2 -0
  64. package/docs/pages/patterns/intent-nesting.md +1 -1
  65. package/docs/pages/utilities/overview.md +23 -2
  66. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  67. package/docs/pages/utilities/spacing.md +10 -8
  68. package/llms.txt +2 -1
  69. package/package.json +19 -8
  70. package/{scripts/reserved-classes.json → reserved-classes.json} +4 -6
  71. package/src/css/components/accordion.css +42 -3
  72. package/src/css/components/alert.css +25 -13
  73. package/src/css/components/app-nav.css +18 -8
  74. package/src/css/components/avatar.css +13 -1
  75. package/src/css/components/badge.css +78 -12
  76. package/src/css/components/breadcrumb.css +2 -3
  77. package/src/css/components/button.css +9 -16
  78. package/src/css/components/card.css +9 -2
  79. package/src/css/components/chat.css +1 -0
  80. package/src/css/components/data-list.css +1 -1
  81. package/src/css/components/drawer.css +79 -25
  82. package/src/css/components/fab.css +0 -6
  83. package/src/css/components/flyout.css +46 -63
  84. package/src/css/components/index.css +0 -1
  85. package/src/css/components/indicator.css +21 -0
  86. package/src/css/components/join.css +7 -0
  87. package/src/css/components/key.css +10 -0
  88. package/src/css/components/list.css +40 -4
  89. package/src/css/components/menu.css +18 -11
  90. package/src/css/components/modal.css +48 -33
  91. package/src/css/components/navbar.css +37 -4
  92. package/src/css/components/pagination.css +1 -0
  93. package/src/css/components/spinner.css +6 -6
  94. package/src/css/components/status-bar.css +0 -5
  95. package/src/css/components/steps.css +45 -59
  96. package/src/css/components/tab.css +29 -9
  97. package/src/css/components/table.css +5 -5
  98. package/src/css/components/tooltip.css +45 -13
  99. package/src/css/core/base.css +23 -1
  100. package/src/css/core/intents.css +17 -6
  101. package/src/css/core/theme.css +27 -0
  102. package/src/css/core/tokens.css +35 -15
  103. package/src/css/core/variants.css +64 -15
  104. package/src/css/forms/choice-card.css +4 -4
  105. package/src/css/forms/choice.css +6 -3
  106. package/src/css/forms/control.css +2 -1
  107. package/src/css/forms/floating-field.css +3 -3
  108. package/src/css/forms/form.css +4 -3
  109. package/src/css/forms/input-icon.css +20 -14
  110. package/src/css/forms/native.css +6 -1
  111. package/src/css/forms/otp.css +9 -1
  112. package/src/css/forms/range.css +2 -0
  113. package/src/css/forms/select.css +29 -9
  114. package/src/css/forms/switch.css +19 -5
  115. package/src/css/layout/app-layout.css +1 -1
  116. package/src/css/layout/grid.css +50 -76
  117. package/src/css/layout/scroll-snap.css +11 -7
  118. package/src/css/layout/scroller.css +3 -4
  119. package/src/css/layout/stack.css +2 -2
  120. package/src/css/typography/fluid.css +4 -3
  121. package/src/css/typography/prose.css +6 -5
  122. package/src/css/utilities/base.css +2 -1
  123. package/src/css/utilities/extra.css +20 -17
  124. package/src/js/context-menu.js +27 -26
  125. package/src/js/dialog.js +2 -1
  126. package/src/js/dismiss.js +5 -0
  127. package/src/js/enhance.js +34 -2
  128. package/src/js/enhancement-loader.js +36 -43
  129. package/src/js/events.js +0 -3
  130. package/src/js/filter.js +0 -1
  131. package/src/js/floating-space.js +42 -0
  132. package/src/js/flyout.js +9 -44
  133. package/src/js/focus-group.js +7 -0
  134. package/src/js/focus.js +6 -0
  135. package/src/js/menu.js +10 -1
  136. package/src/js/selectors.js +5 -9
  137. package/src/js/status.js +44 -7
  138. package/src/js/surface.js +106 -132
  139. package/src/js/tab.js +6 -1
  140. package/src/js/tooltip.js +152 -53
  141. package/src/js/validation.js +1 -1
  142. package/src/css/components/surface.css +0 -49
  143. package/src/js/floating.js +0 -432
package/src/js/enhance.js CHANGED
@@ -146,8 +146,6 @@ function createRegistry(root) {
146
146
  if (hasRemoval) sweepDisconnected();
147
147
  });
148
148
 
149
- observer.observe(root, { childList: true, subtree: true });
150
-
151
149
  return {
152
150
  add(enhancers) {
153
151
  const selectors = Object.keys(enhancers).filter(isValidSelector);
@@ -164,6 +162,9 @@ function createRegistry(root) {
164
162
  selectorString: selectors.join(","),
165
163
  };
166
164
  records.add(record);
165
+ // Observe only once there is a record to act on; an enhance() call with
166
+ // no valid selector must not leave an idle observer behind.
167
+ if (records.size === 1) observer.observe(root, { childList: true, subtree: true });
167
168
  updateSelectorString();
168
169
  scanFor(record, root);
169
170
  return record;
@@ -201,6 +202,37 @@ export function hasEnhancement(el, name) {
201
202
  return el.matches(enhancementSelector(name));
202
203
  }
203
204
 
205
+ /**
206
+ * Apply a named opt-in once; presentation selectors remain application-owned.
207
+ * @param {string} name
208
+ * @param {string} selector
209
+ * @param {Document | Element | DocumentFragment} [root] Query scope, excluding itself.
210
+ * @returns {Element[]} Matched descendants.
211
+ */
212
+ export function applyEnhancement(name, selector, root) {
213
+ if (typeof document === "undefined") return [];
214
+
215
+ enhancementSelector(name);
216
+ root ??= document.documentElement;
217
+ const elements = [...root.querySelectorAll(selector)];
218
+
219
+ for (const el of elements) {
220
+ if (hasEnhancement(el, name)) continue;
221
+ const tokens = el.getAttribute("data-enhance");
222
+ el.setAttribute("data-enhance", tokens ? `${tokens} ${name}` : name);
223
+ }
224
+ // Refresh the scope once per owner; parentNode stops at shadow boundaries.
225
+ // A Document scope can own registrations, but its default root —
226
+ // documentElement — is a descendant the ancestor walk never reaches.
227
+ for (let owner = root; owner; owner = owner.parentNode) {
228
+ ownedNames.get(owner)?.get(name)?.refresh(root);
229
+ if (owner === root && owner.nodeType === DOCUMENT_NODE) {
230
+ ownedNames.get(owner.documentElement)?.get(name)?.refresh(root);
231
+ }
232
+ }
233
+ return elements;
234
+ }
235
+
204
236
  export function registerEnhancement(name, init, root) {
205
237
  if (typeof document === "undefined") return noopRuntime();
206
238
 
@@ -29,51 +29,45 @@ const styleLoads = new Map();
29
29
 
30
30
  let moduleImporter = (url) => import(url);
31
31
 
32
- export function loadScript(url) {
32
+ function loadAsset(url, loads, kind) {
33
33
  const absoluteUrl = new URL(url, document.baseURI).href;
34
34
 
35
- if (!scriptLoads.has(absoluteUrl)) {
36
- scriptLoads.set(
37
- absoluteUrl,
38
- new Promise((resolve, reject) => {
39
- const script = document.createElement("script");
40
- script.src = absoluteUrl;
41
- script.onload = resolve;
42
- script.onerror = () => {
43
- scriptLoads.delete(absoluteUrl);
44
- script.remove();
45
- reject(new Error(`Unable to load script: ${absoluteUrl}`));
46
- };
47
- document.head.append(script);
48
- }),
49
- );
35
+ if (!loads.has(absoluteUrl)) {
36
+ const pending = new Promise((resolve, reject) => {
37
+ const element = document.createElement(kind === "script" ? "script" : "link");
38
+ if (kind === "script") {
39
+ element.src = absoluteUrl;
40
+ } else {
41
+ element.rel = "stylesheet";
42
+ element.href = absoluteUrl;
43
+ }
44
+ element.onload = resolve;
45
+ element.onerror = () => {
46
+ element.remove();
47
+ reject(new Error(`Unable to load ${kind}: ${absoluteUrl}`));
48
+ };
49
+ document.head.append(element);
50
+ });
51
+
52
+ // The DOM clears the failed element; the cache evicts the rejected
53
+ // promise. The guard keeps a finished load from wiping out a newer one.
54
+ // A catch never runs synchronously, so the eviction always lands after
55
+ // the `set` above, even when the transport rejects during append.
56
+ loads.set(absoluteUrl, pending);
57
+ pending.catch(() => {
58
+ if (loads.get(absoluteUrl) === pending) loads.delete(absoluteUrl);
59
+ });
50
60
  }
51
61
 
52
- return scriptLoads.get(absoluteUrl);
62
+ return loads.get(absoluteUrl);
53
63
  }
54
64
 
55
- export function loadStyle(url) {
56
- const absoluteUrl = new URL(url, document.baseURI).href;
57
-
58
- if (!styleLoads.has(absoluteUrl)) {
59
- styleLoads.set(
60
- absoluteUrl,
61
- new Promise((resolve, reject) => {
62
- const link = document.createElement("link");
63
- link.rel = "stylesheet";
64
- link.href = absoluteUrl;
65
- link.onload = resolve;
66
- link.onerror = () => {
67
- styleLoads.delete(absoluteUrl);
68
- link.remove();
69
- reject(new Error(`Unable to load style: ${absoluteUrl}`));
70
- };
71
- document.head.append(link);
72
- }),
73
- );
74
- }
65
+ export function loadScript(url) {
66
+ return loadAsset(url, scriptLoads, "script");
67
+ }
75
68
 
76
- return styleLoads.get(absoluteUrl);
69
+ export function loadStyle(url) {
70
+ return loadAsset(url, styleLoads, "style");
77
71
  }
78
72
 
79
73
  function makeInit(name, connect) {
@@ -177,8 +171,7 @@ export function loadEnhancement(name, url) {
177
171
  return promise;
178
172
  }
179
173
 
180
- async function loadManifest(manifest) {
181
- const entries = Object.entries(manifest);
174
+ async function loadManifest(entries) {
182
175
  const results = await Promise.allSettled(
183
176
  entries.map(([name, url]) => loadEnhancement(name, url)),
184
177
  );
@@ -239,7 +232,7 @@ export async function loadEnhancements(scope = document) {
239
232
  }
240
233
  }
241
234
 
242
- const result = await loadManifest(Object.fromEntries(manifest));
235
+ const result = await loadManifest([...manifest]);
243
236
 
244
237
  if (result.failed.length === 0) {
245
238
  for (const block of blocks) block.remove();
@@ -250,7 +243,7 @@ export async function loadEnhancements(scope = document) {
250
243
 
251
244
  async function loadManifestBlock(block) {
252
245
  const entries = parseManifestEntries(block.textContent);
253
- const result = await loadManifest(Object.fromEntries(entries));
246
+ const result = await loadManifest(entries);
254
247
 
255
248
  for (const failure of result.failed) {
256
249
  console.error(`Enhancement "${failure.name}" failed to load:`, failure.error);
@@ -286,7 +279,7 @@ export async function loadResponse(response) {
286
279
  throw new TypeError("Enhance-Modules header must be a JSON object.");
287
280
  }
288
281
 
289
- return loadManifest(manifest);
282
+ return loadManifest(Object.entries(manifest));
290
283
  }
291
284
 
292
285
  export function __setModuleImporter(fn) {
package/src/js/events.js CHANGED
@@ -1,9 +1,6 @@
1
1
  export const ACTUAL_EVENT_PREFIX = "actual";
2
2
 
3
3
  export const EVENTS = {
4
- reposition: `${ACTUAL_EVENT_PREFIX}:reposition`,
5
- hide: `${ACTUAL_EVENT_PREFIX}:hide`,
6
- outOfView: `${ACTUAL_EVENT_PREFIX}:out-of-view`,
7
4
  // Cancelable: dispatched by a surface before it opens.
8
5
  // detail: { surface, options }
9
6
  surfaceOpen: `${ACTUAL_EVENT_PREFIX}:surface-open`,
package/src/js/filter.js CHANGED
@@ -62,7 +62,6 @@ function filterSlug(value, event) {
62
62
  const slug = normalizeText(value)
63
63
  .toLowerCase()
64
64
  .replace(/[^\p{L}\p{N}]+/gu, "-")
65
- .replace(/-{2,}/g, "-")
66
65
  .replace(/^-+/, "");
67
66
 
68
67
  return keepTrailingSeparator ? slug : slug.replace(/-+$/, "");
@@ -0,0 +1,42 @@
1
+ /*
2
+ * The coordinate space and CSS positioning scheme are one decision. Keeping
3
+ * them in one value prevents a caller from writing document coordinates to a
4
+ * fixed box, or viewport coordinates to an absolute one.
5
+ *
6
+ * An open top-layer element with position: absolute uses the initial
7
+ * containing block, so only the anchor determines the correct mode.
8
+ */
9
+ const VIEWPORT_ANCHORED = "dialog:modal, :popover-open";
10
+
11
+ function isViewportAnchored(anchor) {
12
+ const win = anchor?.ownerDocument?.defaultView;
13
+ if (!win) return true;
14
+
15
+ // Modal dialogs and open popovers are viewport-anchored without computing
16
+ // position: fixed, so the ancestor walk below cannot identify them.
17
+ try {
18
+ if (anchor.closest(VIEWPORT_ANCHORED)) return true;
19
+ } catch {
20
+ /* An engine without :modal / :popover-open: the walk is all there is. */
21
+ }
22
+
23
+ for (let el = anchor; el; el = el.parentElement) {
24
+ const { position } = win.getComputedStyle(el);
25
+ // Resolve sticky once even while unstuck: changing mode when it sticks
26
+ // would jump the floating element between coordinate systems mid-scroll.
27
+ if (position === "fixed" || position === "sticky") return true;
28
+ }
29
+ return false;
30
+ }
31
+
32
+ /**
33
+ * @param {Element | null | undefined} anchor
34
+ * @returns {{ space: "viewport" | "document", position: "fixed" | "absolute" }}
35
+ */
36
+ export function positionModeFor(anchor) {
37
+ const space = isViewportAnchored(anchor) ? "viewport" : "document";
38
+ return {
39
+ space,
40
+ position: space === "document" ? "absolute" : "fixed",
41
+ };
42
+ }
package/src/js/flyout.js CHANGED
@@ -3,15 +3,8 @@ import { focusFirstDescendant } from "./focus.js";
3
3
  import { connectMenu, focusFirstMenuItem, focusLastMenuItem } from "./menu.js";
4
4
  import { closeSurface, isSurfaceOpen, openSurface, retainSurface } from "./surface.js";
5
5
 
6
- const panelRefs = new WeakMap();
7
6
  const triggerMap = new WeakMap();
8
7
 
9
- function normalizeBreakpoint(raw) {
10
- const BREAKPOINTS = { sm: 640, md: 768, lg: 1024 };
11
- const value = Number.parseInt(BREAKPOINTS[raw] || raw, 10);
12
- return Number.isFinite(value) ? value : undefined;
13
- }
14
-
15
8
  function readFlyoutOptions(panel) {
16
9
  const opts = { autoClose: "true" };
17
10
  const ds = panel.dataset;
@@ -20,11 +13,6 @@ function readFlyoutOptions(panel) {
20
13
  const d = Number.parseFloat(ds.flyoutDistance);
21
14
  if (Number.isFinite(d)) opts.distance = d;
22
15
  }
23
- if (ds.flyoutMobile) opts.mobile = ds.flyoutMobile;
24
- if (ds.flyoutBreakpoint) {
25
- const bp = normalizeBreakpoint(ds.flyoutBreakpoint);
26
- if (bp != null) opts.breakpoint = bp;
27
- }
28
16
  if (ds.flyoutAutoClose) opts.autoClose = ds.flyoutAutoClose;
29
17
  return opts;
30
18
  }
@@ -93,20 +81,12 @@ function onTriggerKeydown(e) {
93
81
 
94
82
  switch (e.key) {
95
83
  case "ArrowDown":
96
- e.preventDefault();
97
- if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
98
- focusFirstMenuItem(panel);
99
- break;
100
- case "ArrowUp":
101
- e.preventDefault();
102
- if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
103
- focusLastMenuItem(panel);
104
- break;
105
84
  case "Home":
106
85
  e.preventDefault();
107
86
  if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
108
87
  focusFirstMenuItem(panel);
109
88
  break;
89
+ case "ArrowUp":
110
90
  case "End":
111
91
  e.preventDefault();
112
92
  if (!isSurfaceOpen(panel) && !openFlyout(panel, trigger)) break;
@@ -131,7 +111,7 @@ function connectTrigger(trigger) {
131
111
  if (triggerMap.has(trigger)) return;
132
112
 
133
113
  const controller = new AbortController();
134
- const state = { panel: null, controller, releaseMenu: null };
114
+ const state = { panel: null, controller, releaseMenu: null, releaseSurface: null };
135
115
  triggerMap.set(trigger, state);
136
116
 
137
117
  resolvePanel(trigger, state);
@@ -144,38 +124,23 @@ function connectTrigger(trigger) {
144
124
  trigger.addEventListener("keydown", onTriggerKeydown, { signal: controller.signal });
145
125
  }
146
126
 
147
- function releasePanelReference(trigger, state) {
127
+ function releasePanelReference(state) {
148
128
  state.releaseMenu?.();
149
129
  state.releaseMenu = null;
150
-
151
- const panel = state.panel;
130
+ state.releaseSurface?.();
131
+ state.releaseSurface = null;
152
132
  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
133
  }
164
134
 
165
135
  function resolvePanel(trigger, state) {
166
136
  const panel = panelFor(trigger);
167
137
  if (state.panel === panel && panel?.isConnected) return panel;
168
138
 
169
- releasePanelReference(trigger, state);
139
+ releasePanelReference(state);
170
140
  if (!panel) return null;
171
141
 
172
- let panelEntry = panelRefs.get(panel);
173
- if (!panelEntry) {
174
- const release = retainSurface(panel);
175
- panelEntry = { triggers: new Set(), release };
176
- panelRefs.set(panel, panelEntry);
177
- }
178
- panelEntry.triggers.add(trigger);
142
+ // Surface retention owns the shared lifetime across all triggers and consumers.
143
+ state.releaseSurface = retainSurface(panel);
179
144
  state.panel = panel;
180
145
 
181
146
  if (isMenuFlyout(panel)) {
@@ -197,7 +162,7 @@ function disconnectTrigger(trigger) {
197
162
  if (state.panel?.isConnected && isSurfaceOpen(state.panel)) {
198
163
  closeSurface(state.panel, { restoreFocus: false });
199
164
  }
200
- releasePanelReference(trigger, state);
165
+ releasePanelReference(state);
201
166
  triggerMap.delete(trigger);
202
167
  }
203
168
 
@@ -1,3 +1,10 @@
1
+ /*
2
+ * Focus group — one remembered tab stop and Arrow/Home/End navigation for a
3
+ * composite widget. The consumer supplies getItems(); the controller only
4
+ * updates tabindex and focus, never activation or ARIA. sync() re-reads the
5
+ * candidate list; the primitive does not observe the subtree.
6
+ */
7
+
1
8
  import { itemForKey } from "./keys.js";
2
9
 
3
10
  const ORIENTATIONS = new Set(["horizontal", "vertical"]);
package/src/js/focus.js CHANGED
@@ -9,6 +9,12 @@ const FOCUSABLE_SELECTOR = [
9
9
  "[tabindex]",
10
10
  ].join(",");
11
11
 
12
+ /*
13
+ * The branch below checkVisibility() is not dead code: checkVisibility() is
14
+ * Safari 17.4 and the floor is Safari 17, so it is the branch that runs at the
15
+ * bottom of the supported range — where it also decides whether a closed
16
+ * popover's contents are reachable. Measured there; do not drop it.
17
+ */
12
18
  export function isElementVisible(el) {
13
19
  if (!el || el.hidden) return false;
14
20
  if (typeof el.checkVisibility === "function") return el.checkVisibility();
package/src/js/menu.js CHANGED
@@ -1,3 +1,13 @@
1
+ /*
2
+ * Menu — the shared `.menu > li > .menu-item` vocabulary and wiring.
3
+ *
4
+ * getMenuItems() filters to usable items; onMenuKeydown() adds directional
5
+ * navigation and activation; connectMenu() owns the ref-counted listeners a
6
+ * menu shares across its triggers. Wiring is per menu, not per trigger, so a
7
+ * menu retained by several triggers (or by flyout and context-menu at once)
8
+ * tears down only when the last one releases it.
9
+ */
10
+
1
11
  import { isElementVisible } from "./focus.js";
2
12
  import { connectFocusGroup } from "./focus-group.js";
3
13
  import { firstItem, itemForKey, lastItem } from "./keys.js";
@@ -134,7 +144,6 @@ export function connectMenu(menu, { close }) {
134
144
  released = true;
135
145
  entry.count--;
136
146
  if (entry.count <= 0) {
137
- entry.focusGroup?.disconnect();
138
147
  entry.controller.abort();
139
148
  menuConnections.delete(menu);
140
149
  }
@@ -1,11 +1,11 @@
1
1
  /*
2
2
  * Selectors — the state-class vocabulary the runtime writes.
3
3
  *
4
- * This file no longer lists discovery selectors: behavior discovery moved
5
- * to data-enhance tokens in 0.2. What remains are the class names modules
6
- * write as state (is-open, was-validated, …) and validation's two read-side
7
- * presentation adapters (field, fieldError). To run this runtime against a
8
- * different CSS framework, edit this file (or alias it in a bundler).
4
+ * The file lists the class names modules write as state (is-open,
5
+ * was-validated, …) and validation's two read-side presentation adapters
6
+ * (field, fieldError). Behavior discovery is not listed here: it uses
7
+ * data-enhance tokens. To run this runtime against a different CSS
8
+ * framework, edit this file (or alias it in a bundler).
9
9
  *
10
10
  * data-* attributes and ARIA are not listed: they are each module's own
11
11
  * behavior API, not CSS vocabulary, so they stay framework-neutral by
@@ -27,10 +27,6 @@ export const CLASSES = {
27
27
  // surface.js — state class on the open flyout/menu/dialog surface.
28
28
  // status.js writes the same class on the status bar while a message shows.
29
29
  open: "is-open",
30
- // surface.js — state class while the surface renders as a mobile sheet.
31
- sheet: "is-sheet",
32
- // surface.js — class name written on the generated sheet backdrop.
33
- backdrop: "surface-backdrop",
34
30
  // dialog.js — transient class for the "can't dismiss" shake animation.
35
31
  static: "is-static",
36
32
  // dialog.js — written on <html> while any modal dialog is open.
package/src/js/status.js CHANGED
@@ -31,6 +31,12 @@
31
31
  * - Any other code, without importing this module: dispatch `actual:status`
32
32
  * directly — `detail: { message, intent, duration }`, or `{}` to clear.
33
33
  *
34
+ * A message originating inside an open dialog temporarily mounts the singleton
35
+ * bar in that dialog. Content outside a modal dialog is inert and cannot sit
36
+ * above its top layer, so this keeps both the visual feedback and live region
37
+ * in the active subtree. Programmatic callers pass the originating element as
38
+ * `options.source`; event and command entry points propagate it automatically.
39
+ *
34
40
  * The element contract is `[data-status][role="status"]`. Both are required:
35
41
  * "status" is a generic domain word, so `role="status"` keeps the
36
42
  * document-wide lookup from matching an unrelated app element that happens to
@@ -52,6 +58,7 @@ import { waitForTransitions } from "./transition.js";
52
58
  const STATUS_SELECTOR = '[data-status][role="status"]';
53
59
  const STATUS_CLASSES_ATTR = "statusClasses";
54
60
  const VIEWPORT_OFFSET_PROPERTY = "--status-viewport-offset";
61
+ const DEFAULT_DURATION_MS = 3000;
55
62
 
56
63
  let statusTimer;
57
64
  // Bumped by every show and every clear. A deferred exit cleanup only runs when
@@ -59,12 +66,39 @@ let statusTimer;
59
66
  // never emptied by the previous message's cleanup.
60
67
  let statusGeneration = 0;
61
68
  let viewportTracker = null;
69
+ const statusHomes = new WeakMap();
62
70
 
63
71
  function statusTarget() {
64
72
  if (typeof document === "undefined") return null;
65
73
  return document.querySelector(STATUS_SELECTOR);
66
74
  }
67
75
 
76
+ function restoreStatusTarget(target) {
77
+ const home = statusHomes.get(target);
78
+ if (!home) return;
79
+
80
+ const parent = home.parent?.isConnected ? home.parent : target.ownerDocument?.body;
81
+ if (parent) {
82
+ const reference = home.nextSibling?.parentNode === parent ? home.nextSibling : null;
83
+ parent.insertBefore(target, reference);
84
+ }
85
+ statusHomes.delete(target);
86
+ }
87
+
88
+ function mountStatusTarget(target, source) {
89
+ const dialog = source?.closest?.("dialog[open]");
90
+ if (!dialog || dialog.ownerDocument !== target.ownerDocument) {
91
+ restoreStatusTarget(target);
92
+ return;
93
+ }
94
+ if (dialog.contains(target)) return;
95
+
96
+ if (!statusHomes.has(target)) {
97
+ statusHomes.set(target, { parent: target.parentNode, nextSibling: target.nextSibling });
98
+ }
99
+ dialog.append(target);
100
+ }
101
+
68
102
  // The bar is fixed to the layout viewport, so the mobile software keyboard
69
103
  // covers it. visualViewport reports the occluded band; the CSS adds it to the
70
104
  // bottom inset. Tracking runs only while a message is on screen.
@@ -118,10 +152,10 @@ export function status(message, options = {}) {
118
152
  const target = statusTarget();
119
153
  if (!target || message == null) return;
120
154
 
121
- // A live region with nothing to announce is not a message. `:empty` used to
122
- // absorb this case for free; now that the open state is explicit, an empty
123
- // message has to mean "close" or it would open a blank pill. This also keeps
124
- // status(await response.text()) from leaving a stale message on screen.
155
+ // A live region with nothing to announce is not a message. The open state
156
+ // is explicit, so an empty message has to mean "close" or it would open a
157
+ // blank pill. This also keeps status(await response.text()) from leaving a
158
+ // stale message on screen.
125
159
  if (message === "") {
126
160
  status.clear();
127
161
  return;
@@ -130,6 +164,8 @@ export function status(message, options = {}) {
130
164
  clearTimeout(statusTimer);
131
165
  statusGeneration++;
132
166
 
167
+ mountStatusTarget(target, options.source);
168
+
133
169
  clearIntentClasses(target);
134
170
  const intent = intentClasses(options.intent);
135
171
  if (intent.length) {
@@ -144,7 +180,7 @@ export function status(message, options = {}) {
144
180
  trackViewport(target);
145
181
 
146
182
  if (options.duration !== false) {
147
- statusTimer = setTimeout(() => status.clear(), options.duration ?? 3000);
183
+ statusTimer = setTimeout(() => status.clear(), options.duration ?? DEFAULT_DURATION_MS);
148
184
  }
149
185
  }
150
186
 
@@ -168,6 +204,7 @@ status.clear = function clear() {
168
204
  target.textContent = "";
169
205
  clearIntentClasses(target);
170
206
  releaseViewport();
207
+ restoreStatusTarget(target);
171
208
  });
172
209
  };
173
210
 
@@ -198,7 +235,7 @@ function connectStatusTrigger(trigger, target) {
198
235
  if (typeof document !== "undefined") {
199
236
  document.addEventListener(EVENTS.invalid, (event) => {
200
237
  const message = event.detail?.message;
201
- if (message) status(message, { intent: "danger" });
238
+ if (message) status(message, { intent: "danger", source: event.target });
202
239
  });
203
240
 
204
241
  // Public contract: any code can trigger the status bar this way, without
@@ -209,7 +246,7 @@ if (typeof document !== "undefined") {
209
246
  // paths end in the same exit, so a detail that lost its message never
210
247
  // strands a stale one on screen.
211
248
  if (message != null) {
212
- status(message, { intent, duration });
249
+ status(message, { intent, duration, source: event.target });
213
250
  } else {
214
251
  status.clear();
215
252
  }