actual-css 0.7.0 → 0.9.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 (144) hide show
  1. package/CHANGELOG.md +217 -0
  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 +1087 -643
  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 +62 -30
  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 +53 -7
  18. package/docs/design-notes/popover-manual-poc.md +105 -25
  19. package/docs/design-notes/surface-reparenting.md +56 -47
  20. package/docs/design-notes/tooltip-coordinate-space.md +132 -0
  21. package/docs/design-notes/widget-primitives.md +7 -4
  22. package/docs/pages/components/accordion.md +41 -2
  23. package/docs/pages/components/alert.md +85 -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 +21 -6
  30. package/docs/pages/components/drawer.md +13 -0
  31. package/docs/pages/components/fab.md +4 -3
  32. package/docs/pages/components/list.md +42 -4
  33. package/docs/pages/components/navbar.md +17 -1
  34. package/docs/pages/components/pagination.md +2 -2
  35. package/docs/pages/components/skeleton.md +3 -1
  36. package/docs/pages/components/steps.md +19 -0
  37. package/docs/pages/components/table.md +3 -2
  38. package/docs/pages/enhancements/flyout.md +82 -38
  39. package/docs/pages/enhancements/overview.md +25 -0
  40. package/docs/pages/enhancements/status-bar.md +1 -1
  41. package/docs/pages/enhancements/tabs.md +45 -37
  42. package/docs/pages/enhancements/tooltip.md +36 -10
  43. package/docs/pages/examples/overview.md +38 -5
  44. package/docs/pages/forms/choice-cards.md +11 -6
  45. package/docs/pages/forms/combobox.md +1 -1
  46. package/docs/pages/forms/floating-field.md +4 -3
  47. package/docs/pages/forms/joined-controls.md +22 -0
  48. package/docs/pages/forms/otp.md +2 -1
  49. package/docs/pages/forms/overview.md +1 -1
  50. package/docs/pages/forms/states-and-sizes.md +16 -18
  51. package/docs/pages/forms/sticky-actions.md +2 -2
  52. package/docs/pages/forms/switch.md +1 -1
  53. package/docs/pages/foundations/philosophy.md +57 -26
  54. package/docs/pages/foundations/theming.md +2 -2
  55. package/docs/pages/foundations/tokens.md +118 -21
  56. package/docs/pages/foundations/typography.md +35 -14
  57. package/docs/pages/guides/introduction.md +11 -9
  58. package/docs/pages/guides/progressive-enhancement.md +115 -30
  59. package/docs/pages/guides/tailwind.md +3 -2
  60. package/docs/pages/layout/media.md +1 -1
  61. package/docs/pages/layout/overview.md +11 -1
  62. package/docs/pages/layout/scroll-snap.md +3 -3
  63. package/docs/pages/layout/stack.md +17 -9
  64. package/docs/pages/patterns/icon-input.md +4 -2
  65. package/docs/pages/patterns/icon-slot.md +2 -0
  66. package/docs/pages/patterns/intent-nesting.md +1 -1
  67. package/docs/pages/utilities/overview.md +23 -2
  68. package/docs/pages/utilities/sizing-wrapping.md +1 -1
  69. package/docs/pages/utilities/spacing.md +10 -8
  70. package/llms.txt +2 -1
  71. package/package.json +159 -149
  72. package/{scripts/reserved-classes.json → reserved-classes.json} +4 -6
  73. package/src/css/components/accordion.css +42 -3
  74. package/src/css/components/alert.css +25 -13
  75. package/src/css/components/app-nav.css +18 -8
  76. package/src/css/components/avatar.css +13 -1
  77. package/src/css/components/badge.css +78 -12
  78. package/src/css/components/breadcrumb.css +2 -3
  79. package/src/css/components/button.css +9 -16
  80. package/src/css/components/card.css +9 -2
  81. package/src/css/components/chat.css +1 -0
  82. package/src/css/components/data-list.css +1 -1
  83. package/src/css/components/drawer.css +101 -32
  84. package/src/css/components/fab.css +0 -6
  85. package/src/css/components/flyout.css +35 -98
  86. package/src/css/components/index.css +0 -1
  87. package/src/css/components/indicator.css +21 -0
  88. package/src/css/components/key.css +10 -0
  89. package/src/css/components/list.css +40 -4
  90. package/src/css/components/menu.css +2 -1
  91. package/src/css/components/modal.css +48 -33
  92. package/src/css/components/navbar.css +37 -4
  93. package/src/css/components/pagination.css +1 -0
  94. package/src/css/components/skeleton.css +15 -4
  95. package/src/css/components/spinner.css +6 -6
  96. package/src/css/components/status-bar.css +0 -5
  97. package/src/css/components/steps.css +45 -59
  98. package/src/css/components/tab.css +29 -9
  99. package/src/css/components/table.css +13 -5
  100. package/src/css/components/tooltip.css +36 -40
  101. package/src/css/core/base.css +23 -1
  102. package/src/css/core/intents.css +17 -6
  103. package/src/css/core/theme.css +27 -0
  104. package/src/css/core/tokens.css +35 -15
  105. package/src/css/core/variants.css +64 -15
  106. package/src/css/forms/choice-card.css +4 -4
  107. package/src/css/forms/choice.css +76 -29
  108. package/src/css/forms/control.css +2 -1
  109. package/src/css/forms/floating-field.css +3 -3
  110. package/src/css/forms/form.css +4 -3
  111. package/src/css/forms/input-icon.css +20 -14
  112. package/src/css/forms/native.css +6 -1
  113. package/src/css/forms/otp.css +9 -1
  114. package/src/css/forms/range.css +2 -0
  115. package/src/css/forms/select.css +29 -9
  116. package/src/css/forms/switch.css +28 -8
  117. package/src/css/layout/app-layout.css +1 -1
  118. package/src/css/layout/grid.css +50 -76
  119. package/src/css/layout/scroll-snap.css +11 -7
  120. package/src/css/layout/scroller.css +3 -4
  121. package/src/css/layout/stack.css +2 -2
  122. package/src/css/typography/fluid.css +4 -3
  123. package/src/css/typography/prose.css +6 -5
  124. package/src/css/utilities/base.css +2 -1
  125. package/src/css/utilities/extra.css +20 -17
  126. package/src/js/context-menu.js +27 -26
  127. package/src/js/dialog.js +2 -1
  128. package/src/js/dismiss.js +5 -0
  129. package/src/js/enhance.js +34 -2
  130. package/src/js/enhancement-loader.js +36 -43
  131. package/src/js/events.js +0 -3
  132. package/src/js/filter.js +0 -1
  133. package/src/js/floating-space.js +42 -0
  134. package/src/js/flyout.js +9 -44
  135. package/src/js/focus-group.js +7 -0
  136. package/src/js/focus.js +6 -0
  137. package/src/js/menu.js +10 -1
  138. package/src/js/selectors.js +5 -9
  139. package/src/js/status.js +6 -5
  140. package/src/js/surface.js +97 -139
  141. package/src/js/tab.js +6 -1
  142. package/src/js/tooltip.js +150 -51
  143. package/src/js/validation.js +1 -1
  144. package/src/css/components/surface.css +0 -49
package/src/js/surface.js CHANGED
@@ -2,14 +2,13 @@ import { autoUpdate, reposition, repositionAt } from "@lekoala/floating";
2
2
  import enhance from "./enhance.js";
3
3
  import { registerEscapeDismissal } from "./escape.js";
4
4
  import { EVENTS } from "./events.js";
5
+ import { positionModeFor } from "./floating-space.js";
5
6
 
6
7
  import { CLASSES } from "./selectors.js";
7
- import { waitForTransitions } from "./transition.js";
8
8
 
9
9
  const openSurfaces = new Set();
10
10
  const surfaceMap = new WeakMap();
11
11
  const surfaceRetainers = new WeakMap();
12
- const mountedSurfaces = new WeakMap();
13
12
  const clickBoundDocuments = new WeakSet();
14
13
 
15
14
  const SURFACE_MARKER = "data-actual-surface";
@@ -38,61 +37,57 @@ function reaperFor(menu) {
38
37
 
39
38
  let reaper = reapers.get(root);
40
39
  if (!reaper) {
41
- reaper = enhance(
42
- { [`[${SURFACE_MARKER}]`]: (el) => () => disconnectSurface(el, { restore: false }) },
43
- root,
44
- );
40
+ reaper = enhance({ [`[${SURFACE_MARKER}]`]: (el) => () => disconnectSurface(el) }, root);
45
41
  reapers.set(root, reaper);
46
42
  }
47
43
  return reaper;
48
44
  }
49
45
 
50
- function getSurfaceRoot(menu, anchor) {
51
- return anchor?.closest("dialog") || menu.closest("dialog") || menu.ownerDocument.body;
52
- }
53
-
54
- function mountSurface(menu, anchor) {
55
- if (mountedSurfaces.has(menu)) return;
56
-
57
- const root = getSurfaceRoot(menu, anchor);
58
- const parent = menu.parentNode;
59
- if (!root || !parent || parent === root) return;
60
-
61
- mountedSurfaces.set(menu, { parent, next: menu.nextSibling });
62
- root.append(menu);
63
- }
64
-
65
- function restoreSurface(menu) {
66
- const mount = mountedSurfaces.get(menu);
67
- if (!mount) return;
68
-
69
- if (mount.parent.isConnected) {
70
- const next = mount.next?.parentNode === mount.parent ? mount.next : null;
71
- mount.parent.insertBefore(menu, next);
72
- } else {
73
- menu.remove();
46
+ /*
47
+ * Transport: the top layer, via popover="manual".
48
+ *
49
+ * The runtime owns the whole lifecycle, so "manual" is the only usable mode —
50
+ * it supplies promotion to the top layer and nothing else, leaving the
51
+ * dismissal policy, Escape ordering and focus restoration below untouched.
52
+ *
53
+ * The attribute is set by the runtime, not asked of the author: a surface is
54
+ * still marked up exactly as before. showPopover/hidePopover throw on an
55
+ * out-of-order call (already open, not connected), which is a state Actual's
56
+ * own guards should already have excluded — the try/catch is there so a
57
+ * surprising DOM does not take the lifecycle down with it.
58
+ */
59
+ function showTransport(menu) {
60
+ try {
61
+ menu.showPopover();
62
+ return true;
63
+ } catch {
64
+ return false;
74
65
  }
75
-
76
- mountedSurfaces.delete(menu);
77
66
  }
78
67
 
79
- function shouldUseSheet(menu, state) {
80
- const mobile = state.mobile || "auto";
81
- if (mobile === "none" || mobile === "anchored") return false;
82
- if (mobile === "sheet") return true;
83
-
84
- const view = menu.ownerDocument?.defaultView;
85
- if (!view || typeof view.matchMedia !== "function") {
86
- return false;
68
+ function hideTransport(menu) {
69
+ try {
70
+ menu.hidePopover();
71
+ } catch {
72
+ /* Already closed or detached; nothing left to hide. */
87
73
  }
88
-
89
- const breakpoint = state.breakpoint ?? 768;
90
- return (
91
- view.matchMedia("(pointer: coarse)").matches &&
92
- view.matchMedia(`(max-width: ${breakpoint}px)`).matches
93
- );
94
74
  }
95
75
 
76
+ /*
77
+ * Trigger state sync stays Actual's job, deliberately.
78
+ *
79
+ * A button carrying popovertarget gets an implicit invoker relation and the
80
+ * platform computes aria-expanded on it by itself — measured on the AX tree:
81
+ * expanded false when closed, true when open. Tempting, and out of scope: the
82
+ * public contract here is aria-controls, and popovertarget would replace it.
83
+ * That is a markup change for adopters, not an internal transport detail, so it
84
+ * cannot ride along with a transport swap that changes no HTML.
85
+ *
86
+ * It is also not obviously a win. linkedTriggers() is small, explicit, and
87
+ * already supports several triggers pointing at one panel; popovertarget covers
88
+ * the single-invoker case. Any future move needs to weigh what is lost, not
89
+ * just count the lines saved.
90
+ */
96
91
  function linkedTriggers(menu) {
97
92
  if (!menu.id) return [];
98
93
  return [...menu.ownerDocument.querySelectorAll(`[aria-controls="${CSS.escape(menu.id)}"]`)];
@@ -104,39 +99,9 @@ function syncExpanded(menu, expanded) {
104
99
  }
105
100
  }
106
101
 
107
- function ensureBackdrop(menu, state) {
108
- if (!state.isSheet) {
109
- state.backdrop?.remove();
110
- state.backdrop = null;
111
- return;
112
- }
113
-
114
- if (state.backdrop?.isConnected) {
115
- state.backdrop.hidden = false;
116
- return;
117
- }
118
-
119
- const backdrop = menu.ownerDocument.createElement("div");
120
- backdrop.className = CLASSES.backdrop;
121
- backdrop.hidden = false;
122
-
123
- menu.before(backdrop);
124
- state.backdrop = backdrop;
125
- }
126
-
127
- function applyPresentation(menu, state) {
128
- state.isSheet = shouldUseSheet(menu, state);
129
- if (state.isSheet) {
130
- menu.style.removeProperty("left");
131
- menu.style.removeProperty("top");
132
- }
133
- menu.classList.toggle(CLASSES.sheet, state.isSheet);
134
- ensureBackdrop(menu, state);
135
- }
136
-
137
102
  function positionSurface(menu) {
138
103
  const state = surfaceMap.get(menu);
139
- if (!state || state.isSheet) return true;
104
+ if (!state) return true;
140
105
 
141
106
  if (state.trigger) {
142
107
  const triggerWidth = state.trigger.getBoundingClientRect().width;
@@ -152,6 +117,7 @@ function positionSurface(menu) {
152
117
  shift: state.shift !== false,
153
118
  shiftPadding: state.shiftPadding ?? 4,
154
119
  scope: state.scope,
120
+ coordinateSpace: state.coordinateSpace,
155
121
  };
156
122
 
157
123
  if (state.point) {
@@ -171,12 +137,9 @@ function ensureSurfaceWired(menu) {
171
137
  stopTracking: null,
172
138
  stopScrollIntent: null,
173
139
  unregisterEscape: null,
174
- backdrop: null,
175
140
  trigger: null,
176
141
  source: null,
177
142
  point: null,
178
- mobile: "auto",
179
- breakpoint: 768,
180
143
  placement: "bottom-start",
181
144
  distance: 4,
182
145
  flip: true,
@@ -185,8 +148,7 @@ function ensureSurfaceWired(menu) {
185
148
  autoClose: "outside",
186
149
  dismissOnScroll: false,
187
150
  scrollIntentAt: null,
188
- isSheet: false,
189
- closeId: 0,
151
+ coordinateSpace: "viewport",
190
152
  };
191
153
 
192
154
  surfaceMap.set(menu, state);
@@ -226,50 +188,37 @@ function startSurfaceResources(menu, state) {
226
188
  }
227
189
  const anchor = state.point ? null : state.trigger || state.source;
228
190
  state.stopTracking = autoUpdate(anchor, menu, ({ type }) => {
229
- if (menu.hidden) return;
191
+ if (!isSurfaceOpen(menu)) return;
230
192
  if (type === "scroll" && state.dismissOnScroll) return;
231
- applyPresentation(menu, state);
232
193
  if (!positionSurface(menu)) closeSurface(menu);
233
194
  });
234
195
  }
235
196
 
236
197
  export function isSurfaceOpen(menu) {
237
- return menu.classList.contains(CLASSES.open) && !menu.hidden;
198
+ return menu.classList.contains(CLASSES.open);
238
199
  }
239
200
 
240
201
  export function prepareSurface(menu) {
241
202
  if (!menu) return;
242
203
  menu.style.position = "fixed";
243
- menu.hidden = true;
204
+ // A closed popover is hidden by the platform, so [hidden] is no longer the
205
+ // closed state. Any left over from author markup would outrank it.
206
+ menu.removeAttribute("hidden");
207
+ // "manual" is written even over an author's own value, because it is the
208
+ // whole of what the transport asks for. Any other mode hands the UA a
209
+ // dismissal policy of its own: light dismiss closes on outside clicks only,
210
+ // which silently defeats data-flyout-auto-close.
211
+ menu.setAttribute("popover", "manual");
244
212
  syncExpanded(menu, false);
245
213
  menu.setAttribute(SURFACE_MARKER, "");
246
214
  reaperFor(menu)?.refresh(menu);
247
215
  }
248
216
 
249
- /*
250
- * One lifecycle owner per surface. surface.js opens by clearing [hidden] and
251
- * adding .is-open; it never calls showPopover(). So a [popover] panel handed to
252
- * the runtime has two owners and stays shut: the platform keeps a popover it
253
- * was never asked to show closed, whatever the runtime writes.
254
- *
255
- * This covers every popover value. "manual" is a candidate future transport for
256
- * this module, which does not make it a legitimate mix today. Delete this guard
257
- * in the commit that makes surface.js call showPopover().
258
- */
259
- function warnPopoverConflict(panel) {
260
- if (!panel.hasAttribute("popover")) return;
261
- console.warn(
262
- "Actual: the surface lifecycle cannot own a [popover] element — choose one lifecycle owner (remove the popover attribute, or drop the Actual enhancement).",
263
- panel,
264
- );
265
- }
266
-
267
217
  export function retainSurface(panel) {
268
218
  if (!panel) return () => {};
269
219
 
270
220
  let entry = surfaceRetainers.get(panel);
271
221
  if (!entry) {
272
- warnPopoverConflict(panel);
273
222
  prepareSurface(panel);
274
223
  entry = { count: 0 };
275
224
  surfaceRetainers.set(panel, entry);
@@ -282,8 +231,10 @@ export function retainSurface(panel) {
282
231
  released = true;
283
232
  entry.count--;
284
233
  if (entry.count <= 0) {
285
- // A released retainer may leave a mounted panel whose original parent
286
- // either still needs it back or has disappeared along with the trigger.
234
+ // Nothing was moved, so nothing has to be put back. What the last
235
+ // release still owes is the teardown: the panel may be open, and an
236
+ // open surface holds an Escape entry and a position tracker, neither of
237
+ // which has an owner once the last retainer is gone.
287
238
  disconnectSurface(panel);
288
239
  surfaceRetainers.delete(panel);
289
240
  }
@@ -301,20 +252,12 @@ export function openSurface(menu, opts = {}) {
301
252
  });
302
253
  if (!menu.dispatchEvent(beforeOpen)) return false;
303
254
 
304
- for (const other of openSurfaces) {
305
- if (other !== menu && other.ownerDocument === menu.ownerDocument) closeSurface(other);
306
- }
307
-
308
- const anchor = opts.trigger || opts.source || null;
309
- mountSurface(menu, anchor);
310
255
  prepareSurface(menu);
311
256
  const state = ensureSurfaceWired(menu);
312
257
  state.trigger = opts.trigger || null;
313
258
  state.source = opts.source || null;
314
259
  state.point =
315
260
  Number.isFinite(opts.x) && Number.isFinite(opts.y) ? { x: opts.x, y: opts.y } : null;
316
- state.mobile = opts.mobile || "auto";
317
- state.breakpoint = opts.breakpoint ?? 768;
318
261
  state.autoClose = normalizeAutoClose(opts.autoClose);
319
262
  state.dismissOnScroll = opts.dismissOnScroll === true;
320
263
  state.scrollIntentAt = null;
@@ -325,11 +268,18 @@ export function openSurface(menu, opts = {}) {
325
268
  state.shiftPadding = opts.shiftPadding ?? 4;
326
269
  state.scope = opts.scope;
327
270
  state.restoreFocusTo = opts.restoreFocusTo || opts.trigger || opts.source || null;
328
- state.closeId++;
329
271
 
272
+ // Points are client coordinates and therefore always viewport-relative.
273
+ // Element anchors choose their coupled coordinate/CSS mode once per open;
274
+ // in particular, a sticky anchor must not switch modes while scrolling.
275
+ const positionAnchor = state.point ? null : state.trigger || state.source;
276
+ const mode = positionModeFor(positionAnchor);
277
+ state.coordinateSpace = mode.space;
278
+ menu.style.position = mode.position;
279
+
280
+ // Promote before measuring: a closed popover has no box to position.
281
+ if (!showTransport(menu)) return false;
330
282
  menu.classList.add(CLASSES.open);
331
- menu.hidden = false;
332
- applyPresentation(menu, state);
333
283
  syncExpanded(menu, true);
334
284
 
335
285
  if (!positionSurface(menu)) {
@@ -337,6 +287,33 @@ export function openSurface(menu, opts = {}) {
337
287
  return false;
338
288
  }
339
289
 
290
+ /*
291
+ * Surfaces are mutually exclusive per document, including when one contains
292
+ * the other's trigger. That is a deliberate contract, not a missing feature:
293
+ * making this loop ancestor-aware is most of a nesting implementation but
294
+ * not all of it. The platform closes a popover with display: none, and what
295
+ * that does to a descendant surface already in the top layer is unmeasured —
296
+ * nothing here cascades a close, so a live Escape entry and position tracker
297
+ * outliving their panel is the expected failure. Measure before relaxing it.
298
+ *
299
+ * The others come down only once this one is up and placed. With
300
+ * popover="manual" nothing requires the old surface to leave the top layer
301
+ * before the new one enters it, so there is no reason to spend it before
302
+ * knowing the open will succeed — a detached anchor or a positioner failure
303
+ * used to dismiss the current menu and then open nothing.
304
+ *
305
+ * restoreFocus: false because these are superseded, not dismissed. Restoring
306
+ * would bounce focus through the outgoing trigger on the way to the new
307
+ * surface, firing focusin on a control the user has just navigated away
308
+ * from; placing focus in the new surface is the caller's job, and both
309
+ * callers in tree do it as soon as this returns.
310
+ */
311
+ for (const other of openSurfaces) {
312
+ if (other !== menu && other.ownerDocument === menu.ownerDocument) {
313
+ closeSurface(other, { restoreFocus: false });
314
+ }
315
+ }
316
+
340
317
  openSurfaces.add(menu);
341
318
  startSurfaceResources(menu, state);
342
319
  return true;
@@ -346,17 +323,10 @@ export function closeSurface(menu, opts = {}) {
346
323
  if (!menu || !isSurfaceOpen(menu)) return;
347
324
 
348
325
  const state = surfaceMap.get(menu);
349
- const closeId = state ? ++state.closeId : 0;
350
- const wasSheet = state?.isSheet === true;
351
326
  const activeElement = menu.ownerDocument.activeElement;
352
327
  const shouldRestoreFocus = opts.restoreFocus ?? menu.contains(activeElement);
353
328
  menu.classList.remove(CLASSES.open);
354
- if (!wasSheet) {
355
- menu.classList.remove(CLASSES.sheet);
356
- }
357
- menu.hidden = true;
358
- const backdrop = state?.backdrop || null;
359
- if (backdrop) backdrop.hidden = true;
329
+ hideTransport(menu);
360
330
  openSurfaces.delete(menu);
361
331
  syncExpanded(menu, false);
362
332
  state?.unregisterEscape?.();
@@ -366,30 +336,18 @@ export function closeSurface(menu, opts = {}) {
366
336
  if (shouldRestoreFocus && state?.restoreFocusTo?.isConnected) {
367
337
  state.restoreFocusTo.focus({ preventScroll: true });
368
338
  }
369
-
370
- waitForTransitions(menu, backdrop).then(() => {
371
- if (!state || state.closeId !== closeId) return;
372
- state.isSheet = false;
373
- menu.classList.remove(CLASSES.sheet);
374
- backdrop?.remove();
375
- if (state.backdrop === backdrop) state.backdrop = null;
376
- restoreSurface(menu);
377
- });
378
339
  }
379
340
 
380
- export function disconnectSurface(menu, { restore = true } = {}) {
341
+ export function disconnectSurface(menu) {
381
342
  if (!menu) return;
382
- if (!restore) mountedSurfaces.delete(menu);
383
343
  closeSurface(menu);
384
344
  const state = surfaceMap.get(menu);
385
345
  if (state) {
386
- state.backdrop?.remove();
387
346
  state.unregisterEscape?.();
388
347
  state.stopScrollIntent?.();
389
348
  state.stopTracking?.();
390
349
  surfaceMap.delete(menu);
391
350
  }
392
- restoreSurface(menu);
393
351
  }
394
352
 
395
353
  function onDocumentClick(e) {
package/src/js/tab.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * Keyboard: ArrowLeft/Right, Home/End (select tab, wrapping)
9
9
  * ArrowUp/Down for aria-orientation="vertical"
10
10
  * ArrowDown (focus selected panel)
11
+ * [hidden], disabled and panel-less tabs are skipped
11
12
  *
12
13
  * Self-registers via registerEnhancement: injected tablists wire automatically.
13
14
  * The tab→panel map is rebuilt from the live tablist on each interaction,
@@ -47,8 +48,12 @@ function isTabDisabled(tab) {
47
48
  return tab.disabled || tab.getAttribute("aria-disabled") === "true";
48
49
  }
49
50
 
51
+ /* A [hidden] tab is not on screen, so it must not be a keyboard stop either:
52
+ an application that filters a tab strip would otherwise leave the hidden
53
+ tabs in the arrow-key sequence, selecting a panel whose tab is invisible.
54
+ Rebuilding from the live tablist is not enough — live is not visible. */
50
55
  function activatableTabs(list) {
51
- return tabsOf(list).filter((tab) => !isTabDisabled(tab) && panelFor(tab));
56
+ return tabsOf(list).filter((tab) => !tab.hidden && !isTabDisabled(tab) && panelFor(tab));
52
57
  }
53
58
 
54
59
  function makePanelFocusable(panel) {