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
package/src/js/surface.js CHANGED
@@ -1,17 +1,49 @@
1
+ import enhance from "./enhance.js";
1
2
  import { EVENTS } from "./events.js";
2
- import { reposition, repositionAt, track } from "./floating.js";
3
- import { hasMenuItem, onMenuKeydown } from "./menu.js";
3
+ import { autoUpdate, reposition, repositionAt } from "./floating.js";
4
+
4
5
  import { CLASSES } from "./selectors.js";
5
6
 
6
- const DEFAULT_BREAKPOINT = 768;
7
- const BREAKPOINTS = {
8
- sm: 640,
9
- md: 768,
10
- lg: 1024,
11
- };
12
7
  const openSurfaces = new Set();
13
8
  const surfaceMap = new WeakMap();
9
+ const surfaceRetainers = new WeakMap();
14
10
  const mountedSurfaces = new WeakMap();
11
+ const clickBoundDocuments = new WeakSet();
12
+
13
+ const SURFACE_MARKER = "data-actual-surface";
14
+ const AUTO_CLOSE_VALUES = new Set(["true", "inside", "outside", "false"]);
15
+ const SCROLL_KEYS = new Set([
16
+ "ArrowDown",
17
+ "ArrowLeft",
18
+ "ArrowRight",
19
+ "ArrowUp",
20
+ "End",
21
+ "Home",
22
+ "PageDown",
23
+ "PageUp",
24
+ " ",
25
+ ]);
26
+ const reapers = new WeakMap();
27
+
28
+ function normalizeAutoClose(value) {
29
+ const normalized = String(value ?? "outside");
30
+ return AUTO_CLOSE_VALUES.has(normalized) ? normalized : "true";
31
+ }
32
+
33
+ function reaperFor(menu) {
34
+ const root = menu.ownerDocument?.documentElement;
35
+ if (!root) return null;
36
+
37
+ let reaper = reapers.get(root);
38
+ if (!reaper) {
39
+ reaper = enhance(
40
+ { [`[${SURFACE_MARKER}]`]: (el) => () => disconnectSurface(el, { restore: false }) },
41
+ root,
42
+ );
43
+ reapers.set(root, reaper);
44
+ }
45
+ return reaper;
46
+ }
15
47
 
16
48
  function waitForAnimations(...elements) {
17
49
  const animations = elements
@@ -52,45 +84,20 @@ function restoreSurface(menu) {
52
84
  mountedSurfaces.delete(menu);
53
85
  }
54
86
 
55
- function getBreakpoint(menu) {
56
- const value = menu.dataset.flyoutBreakpoint || "";
57
- const raw = Number.parseInt(BREAKPOINTS[value] || value, 10);
58
- return Number.isFinite(raw) ? raw : DEFAULT_BREAKPOINT;
59
- }
60
-
61
- function getMobileMode(menu, mode) {
62
- return mode || menu.dataset.flyoutMobile || "auto";
63
- }
64
-
65
- function getPlacement(menu, placement) {
66
- return placement || menu.dataset.flyoutPlacement || "bottom-start";
67
- }
68
-
69
- function getDistance(menu, distance) {
70
- if (distance != null) return distance;
71
- const value = Number.parseFloat(menu.dataset.flyoutDistance);
72
- return Number.isFinite(value) ? value : 4;
73
- }
74
-
75
- function getAutoCloseMode(menu, value) {
76
- const mode = String(value ?? menu.dataset.flyoutAutoClose ?? "true").toLowerCase();
77
- if (mode === "inside" || mode === "outside" || mode === "false") return mode;
78
- return "true";
79
- }
80
-
81
- function shouldUseSheet(menu, mode) {
82
- const mobile = getMobileMode(menu, mode);
87
+ function shouldUseSheet(menu, state) {
88
+ const mobile = state.mobile || "auto";
83
89
  if (mobile === "none" || mobile === "anchored") return false;
84
90
  if (mobile === "sheet") return true;
85
91
 
86
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
92
+ const view = menu.ownerDocument?.defaultView;
93
+ if (!view || typeof view.matchMedia !== "function") {
87
94
  return false;
88
95
  }
89
96
 
90
- const breakpoint = getBreakpoint(menu);
97
+ const breakpoint = state.breakpoint ?? 768;
91
98
  return (
92
- window.matchMedia("(pointer: coarse)").matches &&
93
- window.matchMedia(`(max-width: ${breakpoint}px)`).matches
99
+ view.matchMedia("(pointer: coarse)").matches &&
100
+ view.matchMedia(`(max-width: ${breakpoint}px)`).matches
94
101
  );
95
102
  }
96
103
 
@@ -120,16 +127,13 @@ function ensureBackdrop(menu, state) {
120
127
  const backdrop = menu.ownerDocument.createElement("div");
121
128
  backdrop.className = CLASSES.backdrop;
122
129
  backdrop.hidden = false;
123
- backdrop.addEventListener("click", () => {
124
- closeSurface(menu);
125
- });
126
130
 
127
131
  menu.before(backdrop);
128
132
  state.backdrop = backdrop;
129
133
  }
130
134
 
131
135
  function applyPresentation(menu, state) {
132
- state.isSheet = shouldUseSheet(menu, state.mobile);
136
+ state.isSheet = shouldUseSheet(menu, state);
133
137
  if (state.isSheet) {
134
138
  menu.style.removeProperty("left");
135
139
  menu.style.removeProperty("top");
@@ -140,13 +144,13 @@ function applyPresentation(menu, state) {
140
144
 
141
145
  function positionSurface(menu) {
142
146
  const state = surfaceMap.get(menu);
143
- if (!state || state.isSheet) return;
147
+ if (!state || state.isSheet) return true;
144
148
 
145
149
  if (state.trigger) {
146
150
  const triggerWidth = state.trigger.getBoundingClientRect().width;
147
- menu.style.setProperty("--flyout-trigger-width", `${triggerWidth}px`);
151
+ menu.style.setProperty("--surface-anchor-width", `${triggerWidth}px`);
148
152
  } else {
149
- menu.style.removeProperty("--flyout-trigger-width");
153
+ menu.style.removeProperty("--surface-anchor-width");
150
154
  }
151
155
 
152
156
  const opts = {
@@ -159,93 +163,87 @@ function positionSurface(menu) {
159
163
  };
160
164
 
161
165
  if (state.point) {
162
- repositionAt(state.point.x, state.point.y, menu, opts);
163
- return;
166
+ return repositionAt(state.point.x, state.point.y, menu, opts);
164
167
  }
165
168
 
166
169
  const anchor = state.trigger || state.source;
167
- if (anchor) reposition(anchor, menu, opts);
170
+ if (anchor) return reposition(anchor, menu, opts);
171
+ return true;
168
172
  }
169
173
 
170
174
  function ensureSurfaceWired(menu) {
171
175
  if (surfaceMap.has(menu)) return surfaceMap.get(menu);
172
176
 
173
- const controller = new AbortController();
177
+ ensureDocumentClick(menu);
174
178
  const state = {
175
- controller,
176
- untrack: track(menu),
179
+ stopTracking: null,
180
+ stopScrollIntent: null,
181
+ unregisterEscape: null,
177
182
  backdrop: null,
178
183
  trigger: null,
179
184
  source: null,
180
185
  point: null,
181
186
  mobile: "auto",
187
+ breakpoint: 768,
182
188
  placement: "bottom-start",
183
189
  distance: 4,
184
190
  flip: true,
185
191
  shift: true,
186
192
  shiftPadding: 4,
187
- autoClose: "true",
193
+ autoClose: "outside",
194
+ dismissOnScroll: false,
195
+ scrollIntentAt: null,
188
196
  isSheet: false,
189
197
  closeId: 0,
190
198
  };
191
199
 
192
- menu.addEventListener(
193
- EVENTS.reposition,
194
- (e) => {
195
- if (menu.hidden) return;
196
- if (e.detail?.type === "scroll" && surfaceMap.get(menu)?.point) {
197
- closeSurface(menu);
198
- return;
199
- }
200
- const state = surfaceMap.get(menu);
201
- if (!state) return;
202
- applyPresentation(menu, state);
203
- positionSurface(menu);
204
- },
205
- { signal: controller.signal },
206
- );
207
-
208
- menu.addEventListener(
209
- EVENTS.hide,
210
- (e) => {
211
- if (!menu.hidden) closeSurface(menu, { restoreFocus: e.detail?.type === "escape" });
212
- },
213
- { signal: controller.signal },
214
- );
215
-
216
- menu.addEventListener(
217
- EVENTS.outOfView,
218
- () => {
219
- if (!menu.hidden) closeSurface(menu);
220
- },
221
- { signal: controller.signal },
222
- );
223
-
224
- menu.addEventListener(
225
- "keydown",
226
- (e) => onMenuKeydown(e, { close: (target) => closeSurface(target) }),
227
- { signal: controller.signal },
228
- );
229
-
230
- menu.addEventListener(
231
- "click",
232
- (e) => {
233
- const state = surfaceMap.get(menu);
234
- const disabledItem = e.target.closest?.(":disabled, [aria-disabled='true']");
235
- if (disabledItem && menu.contains(disabledItem)) {
236
- e.preventDefault();
237
- return;
238
- }
239
- if (!state || state.autoClose === "outside" || state.autoClose === "false") return;
240
- if (hasMenuItem(e.target)) closeSurface(menu);
241
- },
242
- { signal: controller.signal },
243
- );
244
-
245
200
  surfaceMap.set(menu, state);
246
201
  return state;
247
202
  }
248
203
 
204
+ // Escape and positional tracking belong to the *open* surface, not to the
205
+ // wiring. They start when the surface opens and stop when it closes, so a
206
+ // closed surface never participates in the dismissable stack or in
207
+ // scroll/resize work. Reopening re-registers in the correct order.
208
+ function startSurfaceResources(menu, state) {
209
+ state.unregisterEscape = registerEscapeDismissal(menu, (opts) => closeSurface(menu, opts));
210
+ if (state.dismissOnScroll) {
211
+ const controller = new AbortController();
212
+ const arm = (event) => {
213
+ state.scrollIntentAt = event.timeStamp;
214
+ };
215
+ const armExternalPointer = (event) => {
216
+ if (!menu.contains(event.target)) arm(event);
217
+ };
218
+ const armKey = (event) => {
219
+ if (!event.defaultPrevented && SCROLL_KEYS.has(event.key)) arm(event);
220
+ };
221
+ const captureOptions = { signal: controller.signal, capture: true };
222
+ const passiveCaptureOptions = { ...captureOptions, passive: true };
223
+ const doc = menu.ownerDocument;
224
+ doc.addEventListener("pointerdown", armExternalPointer, passiveCaptureOptions);
225
+ doc.addEventListener("touchmove", arm, passiveCaptureOptions);
226
+ doc.addEventListener("wheel", arm, passiveCaptureOptions);
227
+ doc.addEventListener("keydown", armKey, { signal: controller.signal });
228
+ state.stopScrollIntent = () => controller.abort();
229
+ }
230
+ state.stopTracking = autoUpdate(menu, ({ type, targets, timeStamp }) => {
231
+ if (menu.hidden) return;
232
+ if (
233
+ type === "scroll" &&
234
+ state.dismissOnScroll &&
235
+ state.scrollIntentAt != null &&
236
+ timeStamp >= state.scrollIntentAt &&
237
+ [...targets].some((target) => target !== menu && !menu.contains(target))
238
+ ) {
239
+ closeSurface(menu);
240
+ return;
241
+ }
242
+ applyPresentation(menu, state);
243
+ if (!positionSurface(menu)) closeSurface(menu);
244
+ });
245
+ }
246
+
249
247
  export function isSurfaceOpen(menu) {
250
248
  return menu.classList.contains(CLASSES.open) && !menu.hidden;
251
249
  }
@@ -255,6 +253,31 @@ export function prepareSurface(menu) {
255
253
  menu.style.position = "fixed";
256
254
  menu.hidden = true;
257
255
  syncExpanded(menu, false);
256
+ menu.setAttribute(SURFACE_MARKER, "");
257
+ reaperFor(menu)?.refresh(menu);
258
+ }
259
+
260
+ export function retainSurface(panel) {
261
+ if (!panel) return () => {};
262
+
263
+ let entry = surfaceRetainers.get(panel);
264
+ if (!entry) {
265
+ prepareSurface(panel);
266
+ entry = { count: 0 };
267
+ surfaceRetainers.set(panel, entry);
268
+ }
269
+ entry.count++;
270
+
271
+ let released = false;
272
+ return () => {
273
+ if (released) return;
274
+ released = true;
275
+ entry.count--;
276
+ if (entry.count <= 0) {
277
+ disconnectSurface(panel, { restore: false });
278
+ surfaceRetainers.delete(panel);
279
+ }
280
+ };
258
281
  }
259
282
 
260
283
  export function openSurface(menu, opts = {}) {
@@ -269,7 +292,7 @@ export function openSurface(menu, opts = {}) {
269
292
  if (!menu.dispatchEvent(beforeOpen)) return false;
270
293
 
271
294
  for (const other of openSurfaces) {
272
- if (other !== menu) closeSurface(other);
295
+ if (other !== menu && other.ownerDocument === menu.ownerDocument) closeSurface(other);
273
296
  }
274
297
 
275
298
  const anchor = opts.trigger || opts.source || null;
@@ -280,10 +303,13 @@ export function openSurface(menu, opts = {}) {
280
303
  state.source = opts.source || null;
281
304
  state.point =
282
305
  Number.isFinite(opts.x) && Number.isFinite(opts.y) ? { x: opts.x, y: opts.y } : null;
283
- state.mobile = getMobileMode(menu, opts.mobile);
284
- state.autoClose = getAutoCloseMode(menu, opts.autoClose);
285
- state.placement = getPlacement(menu, opts.placement);
286
- state.distance = getDistance(menu, opts.distance);
306
+ state.mobile = opts.mobile || "auto";
307
+ state.breakpoint = opts.breakpoint ?? 768;
308
+ state.autoClose = normalizeAutoClose(opts.autoClose);
309
+ state.dismissOnScroll = opts.dismissOnScroll === true;
310
+ state.scrollIntentAt = null;
311
+ state.placement = opts.placement || "bottom-start";
312
+ state.distance = opts.distance ?? 4;
287
313
  state.flip = opts.flip !== false;
288
314
  state.shift = opts.shift !== false;
289
315
  state.shiftPadding = opts.shiftPadding ?? 4;
@@ -293,10 +319,16 @@ export function openSurface(menu, opts = {}) {
293
319
 
294
320
  menu.classList.add(CLASSES.open);
295
321
  menu.hidden = false;
296
- openSurfaces.add(menu);
297
322
  applyPresentation(menu, state);
298
323
  syncExpanded(menu, true);
299
- positionSurface(menu);
324
+
325
+ if (!positionSurface(menu)) {
326
+ closeSurface(menu, { restoreFocus: false });
327
+ return false;
328
+ }
329
+
330
+ openSurfaces.add(menu);
331
+ startSurfaceResources(menu, state);
300
332
  return true;
301
333
  }
302
334
 
@@ -317,6 +349,9 @@ export function closeSurface(menu, opts = {}) {
317
349
  if (backdrop) backdrop.hidden = true;
318
350
  openSurfaces.delete(menu);
319
351
  syncExpanded(menu, false);
352
+ state?.unregisterEscape?.();
353
+ state?.stopScrollIntent?.();
354
+ state?.stopTracking?.();
320
355
 
321
356
  if (shouldRestoreFocus && state?.restoreFocusTo?.isConnected) {
322
357
  state.restoreFocusTo.focus({ preventScroll: true });
@@ -332,27 +367,86 @@ export function closeSurface(menu, opts = {}) {
332
367
  });
333
368
  }
334
369
 
335
- export function disconnectSurface(menu) {
370
+ export function disconnectSurface(menu, { restore = true } = {}) {
336
371
  if (!menu) return;
372
+ if (!restore) mountedSurfaces.delete(menu);
337
373
  closeSurface(menu);
338
374
  const state = surfaceMap.get(menu);
339
375
  if (state) {
340
376
  state.backdrop?.remove();
341
- state.untrack();
342
- state.controller.abort();
377
+ state.unregisterEscape?.();
378
+ state.stopScrollIntent?.();
379
+ state.stopTracking?.();
343
380
  surfaceMap.delete(menu);
344
381
  }
345
382
  restoreSurface(menu);
346
383
  }
347
384
 
348
- // Global outside-click listener runs at import time; keep SSR imports inert.
349
- if (typeof document !== "undefined") {
350
- document.addEventListener("click", (e) => {
351
- for (const menu of openSurfaces) {
352
- const state = surfaceMap.get(menu);
353
- if (!state || state.autoClose === "inside" || state.autoClose === "false") continue;
354
- if (menu.contains(e.target) || state?.trigger?.contains(e.target)) continue;
385
+ function onDocumentClick(e) {
386
+ const path = e.composedPath();
387
+
388
+ for (const menu of openSurfaces) {
389
+ if (menu.ownerDocument !== e.currentTarget) continue;
390
+ const state = surfaceMap.get(menu);
391
+ if (!state) continue;
392
+
393
+ const menuIndex = path.indexOf(menu);
394
+ const isInside = menuIndex >= 0;
395
+ const hasCloseTrigger =
396
+ isInside &&
397
+ path
398
+ .slice(0, menuIndex)
399
+ .some((node) => node instanceof Element && node.hasAttribute("data-flyout-close"));
400
+
401
+ if (hasCloseTrigger) {
355
402
  closeSurface(menu);
403
+ continue;
356
404
  }
357
- });
405
+
406
+ const closesInside = state.autoClose === "true" || state.autoClose === "inside";
407
+ const closesOutside = state.autoClose === "true" || state.autoClose === "outside";
408
+ if ((isInside && closesInside) || (!isInside && closesOutside)) closeSurface(menu);
409
+ }
410
+ }
411
+
412
+ function ensureDocumentClick(menu) {
413
+ const doc = menu.ownerDocument;
414
+ if (!doc || clickBoundDocuments.has(doc)) return;
415
+ clickBoundDocuments.add(doc);
416
+ doc.addEventListener("click", onDocumentClick);
417
+ }
418
+
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
+ export function getSurfaceAutoClose(menu) {
451
+ return surfaceMap.get(menu)?.autoClose ?? "outside";
358
452
  }
package/src/js/tab.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Tabs — in-place panel switcher using real tab semantics.
3
3
  *
4
- * Tab list: .tabs[role="tablist"]
4
+ * Tab list: [data-enhance="tabs"] with role="tablist"
5
5
  * Tab: [role="tab"] with aria-selected, aria-controls, id, tabindex
6
6
  * Panel: [role="tabpanel"] with id matching aria-controls
7
7
  *
@@ -9,20 +9,24 @@
9
9
  * ArrowUp/Down for aria-orientation="vertical"
10
10
  * ArrowDown (focus selected panel)
11
11
  *
12
- * Self-registers via enhance: injected tablists wire automatically.
12
+ * Self-registers via registerEnhancement: injected tablists wire automatically.
13
13
  * The tab→panel map is rebuilt from the live tablist on each interaction,
14
14
  * so tabs added after connect are picked up with no extra work.
15
15
  * Cleanup is the AbortController.abort() returned to enhance.
16
16
  */
17
17
 
18
- import enhance from "./enhance.js";
19
- import { firstItem, lastItem, nextItem } from "./keys.js";
20
- import { CLASSES } from "./selectors.js";
18
+ import { registerEnhancement } from "./enhance.js";
19
+ import { itemForKey } from "./keys.js";
21
20
 
22
- const TABLIST_SELECTOR = `.${CLASSES.tabs}[role="tablist"]`;
21
+ const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
23
22
 
23
+ // A tablist owns only the tabs directly beneath it, not those of a nested
24
+ // tablist. Filtering by closest owner keeps the outer list from operating on
25
+ // (and re-reacting to events from) tabs that belong to an inner list.
24
26
  function tabsOf(list) {
25
- return [...list.querySelectorAll('[role="tab"]')];
27
+ return [...list.querySelectorAll('[role="tab"]')].filter(
28
+ (tab) => tab.closest(TABLIST_SELECTOR) === list,
29
+ );
26
30
  }
27
31
 
28
32
  function panelsOf(tabs) {
@@ -36,7 +40,7 @@ function panelsOf(tabs) {
36
40
 
37
41
  function panelFor(tab) {
38
42
  const panelId = tab.getAttribute("aria-controls");
39
- return panelId ? document.getElementById(panelId) : null;
43
+ return panelId ? tab.ownerDocument.getElementById(panelId) : null;
40
44
  }
41
45
 
42
46
  function isTabDisabled(tab) {
@@ -90,68 +94,46 @@ function initialize(list) {
90
94
  }
91
95
 
92
96
  function onKeydown(e) {
97
+ const list = e.currentTarget;
93
98
  const tab = e.target.closest('[role="tab"]');
94
- if (!tab) return;
95
- const list = tab.closest(TABLIST_SELECTOR);
96
- if (!list) return;
99
+ if (!tab || tab.closest(TABLIST_SELECTOR) !== list) return;
97
100
 
98
101
  const tabs = activatableTabs(list);
99
102
  if (!tabs.length || !tabs.includes(tab)) return;
100
103
  const isVertical = list.getAttribute("aria-orientation") === "vertical";
101
- let next;
102
104
 
103
- switch (e.key) {
104
- case "ArrowRight":
105
- if (isVertical) break;
106
- e.preventDefault();
107
- next = nextItem(tabs, tab, 1, { wrap: true });
108
- if (next) activateAndFocus(next);
109
- break;
110
- case "ArrowLeft":
111
- if (isVertical) break;
112
- e.preventDefault();
113
- next = nextItem(tabs, tab, -1, { wrap: true });
114
- if (next) activateAndFocus(next);
115
- break;
116
- case "ArrowDown":
117
- if (!isVertical) {
118
- e.preventDefault();
119
- const panel = panelFor(tab);
120
- if (panel) {
121
- makePanelFocusable(panel);
122
- panel.focus();
123
- }
124
- break;
125
- }
126
- e.preventDefault();
127
- next = nextItem(tabs, tab, 1, { wrap: true });
128
- if (next) activateAndFocus(next);
129
- break;
130
- case "ArrowUp":
131
- if (!isVertical) break;
132
- e.preventDefault();
133
- next = nextItem(tabs, tab, -1, { wrap: true });
134
- if (next) activateAndFocus(next);
135
- break;
136
- case "Home":
137
- e.preventDefault();
138
- activateAndFocus(firstItem(tabs));
139
- break;
140
- case "End":
141
- e.preventDefault();
142
- activateAndFocus(lastItem(tabs));
143
- break;
144
- case "Enter":
145
- case " ":
105
+ if (e.key === "ArrowDown" && !isVertical) {
106
+ const panel = panelFor(tab);
107
+ if (panel) {
146
108
  e.preventDefault();
147
- activate(tab);
148
- break;
109
+ makePanelFocusable(panel);
110
+ panel.focus();
111
+ }
112
+ return;
113
+ }
114
+
115
+ if (e.key === "Enter" || e.key === " ") {
116
+ e.preventDefault();
117
+ activate(tab);
118
+ return;
149
119
  }
120
+
121
+ const direction = list.ownerDocument.defaultView?.getComputedStyle(list).direction || "ltr";
122
+ const target = itemForKey(tabs, tab, e.key, {
123
+ orientation: isVertical ? "vertical" : "horizontal",
124
+ wrap: true,
125
+ direction,
126
+ });
127
+ if (!target) return;
128
+
129
+ e.preventDefault();
130
+ activateAndFocus(target);
150
131
  }
151
132
 
152
133
  function onClick(e) {
134
+ const list = e.currentTarget;
153
135
  const tab = e.target.closest('[role="tab"]');
154
- if (!tab) return;
136
+ if (!tab || tab.closest(TABLIST_SELECTOR) !== list) return;
155
137
  if (isTabDisabled(tab)) {
156
138
  e.preventDefault();
157
139
  return;
@@ -162,12 +144,10 @@ function onClick(e) {
162
144
  tab.focus();
163
145
  }
164
146
 
165
- enhance({
166
- [TABLIST_SELECTOR]: (list) => {
167
- const controller = new AbortController();
168
- initialize(list);
169
- list.addEventListener("click", onClick, { signal: controller.signal });
170
- list.addEventListener("keydown", onKeydown, { signal: controller.signal });
171
- return () => controller.abort();
172
- },
147
+ registerEnhancement("tabs", (list) => {
148
+ const controller = new AbortController();
149
+ initialize(list);
150
+ list.addEventListener("click", onClick, { signal: controller.signal });
151
+ list.addEventListener("keydown", onKeydown, { signal: controller.signal });
152
+ return () => controller.abort();
173
153
  });