actual-css 0.2.0 → 0.3.1

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 (214) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +24 -9
  3. package/dist/actual-themes.min.css +1 -0
  4. package/dist/actual.css +1532 -1114
  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 +94 -14
  10. package/docs/design-notes/cascade-layer.md +7 -6
  11. package/docs/design-notes/enhancement-contract.md +11 -14
  12. package/docs/design-notes/forced-colors.md +105 -0
  13. package/docs/design-notes/naming.md +2 -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 +37 -1
  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 +210 -0
  25. package/docs/pages/components/card.md +195 -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 +43 -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 +99 -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 +59 -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 +28 -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 +55 -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 +32 -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 +4 -4
  103. package/package.json +21 -12
  104. package/src/css/actual.css +16 -2
  105. package/src/css/base.css +3 -5
  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 +139 -102
  109. package/src/css/components/avatar.css +16 -10
  110. package/src/css/components/badge.css +69 -42
  111. package/src/css/components/breadcrumb.css +7 -15
  112. package/src/css/components/busy.css +6 -0
  113. package/src/css/components/button.css +84 -34
  114. package/src/css/components/card.css +26 -38
  115. package/src/css/components/drawer.css +30 -13
  116. package/src/css/components/flyout.css +39 -109
  117. package/src/css/components/index.css +1 -0
  118. package/src/css/components/join.css +14 -2
  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 +30 -23
  124. package/src/css/components/overline.css +8 -9
  125. package/src/css/components/pagination.css +1 -1
  126. package/src/css/components/progress.css +8 -26
  127. package/src/css/components/skeleton.css +7 -0
  128. package/src/css/components/spinner.css +6 -7
  129. package/src/css/components/status-bar.css +15 -24
  130. package/src/css/components/surface.css +2 -8
  131. package/src/css/components/tab.css +19 -48
  132. package/src/css/components/table.css +7 -5
  133. package/src/css/components/tooltip.css +14 -7
  134. package/src/css/focus.css +14 -38
  135. package/src/css/forms/choice-card.css +13 -14
  136. package/src/css/forms/choice.css +21 -27
  137. package/src/css/forms/control.css +18 -17
  138. package/src/css/forms/custom-select.css +9 -11
  139. package/src/css/forms/form-actions.css +14 -3
  140. package/src/css/forms/form.css +7 -4
  141. package/src/css/forms/index.css +4 -4
  142. package/src/css/forms/input-icon.css +30 -12
  143. package/src/css/forms/native.css +50 -11
  144. package/src/css/forms/range.css +17 -9
  145. package/src/css/forms/select.css +8 -8
  146. package/src/css/forms/switch.css +14 -12
  147. package/src/css/forms/validation.css +32 -5
  148. package/src/css/grid.css +32 -18
  149. package/src/css/intents.css +2 -2
  150. package/src/css/layout.css +45 -2
  151. package/src/css/optional/aura.css +93 -0
  152. package/src/css/optional/chat.css +105 -0
  153. package/src/css/optional/fab.css +104 -0
  154. package/src/css/optional/floating-field.css +87 -0
  155. package/src/css/optional/index.css +5 -0
  156. package/src/css/optional/layout-extra.css +7 -4
  157. package/src/css/optional/otp.css +115 -0
  158. package/src/css/optional/scroll-snap.css +6 -0
  159. package/src/css/optional/scroller.css +13 -7
  160. package/src/css/optional/typography-fluid.css +5 -0
  161. package/src/css/optional/utilities-extra.css +17 -2
  162. package/src/css/prose.css +18 -8
  163. package/src/css/reset.css +0 -10
  164. package/src/css/theme.css +21 -5
  165. package/src/css/themes/cyberpunk.css +1 -1
  166. package/src/css/themes/dim.css +3 -3
  167. package/src/css/themes/edge.css +8 -10
  168. package/src/css/themes/index.css +8 -4
  169. package/src/css/themes/{dark.css → indigo.css} +5 -4
  170. package/src/css/themes/neon.css +5 -5
  171. package/src/css/themes/square.css +0 -3
  172. package/src/css/tokens.css +74 -20
  173. package/src/css/utilities.css +8 -10
  174. package/src/css/variants.css +51 -66
  175. package/src/js/command.js +1 -3
  176. package/src/js/context-menu.js +67 -35
  177. package/src/js/dialog.js +68 -79
  178. package/src/js/dismiss.js +15 -0
  179. package/src/js/enhance.js +54 -23
  180. package/src/js/enhancement-loader.js +301 -0
  181. package/src/js/events.js +3 -0
  182. package/src/js/filter.js +3 -3
  183. package/src/js/floating.js +101 -131
  184. package/src/js/flyout.js +104 -87
  185. package/src/js/focus-group.js +116 -0
  186. package/src/js/focus.js +6 -1
  187. package/src/js/index.js +4 -2
  188. package/src/js/input.js +1 -1
  189. package/src/js/keys.js +27 -0
  190. package/src/js/mask.js +7 -6
  191. package/src/js/menu.js +87 -34
  192. package/src/js/parse-config.js +47 -0
  193. package/src/js/password.js +9 -1
  194. package/src/js/scrollspy.js +12 -4
  195. package/src/js/selectors.js +0 -7
  196. package/src/js/status.js +3 -1
  197. package/src/js/surface.js +187 -136
  198. package/src/js/tab.js +36 -53
  199. package/src/js/tooltip.js +70 -35
  200. package/src/js/validation.js +34 -8
  201. package/dist/actual.js.map +0 -31
  202. package/docs/README.md +0 -28
  203. package/docs/components.md +0 -860
  204. package/docs/design-notes/previous-work.md +0 -5
  205. package/docs/forms.md +0 -1265
  206. package/docs/javascript.md +0 -496
  207. package/docs/layout.md +0 -480
  208. package/docs/patterns.md +0 -504
  209. package/docs/reserved.md +0 -7
  210. package/docs/tokens.md +0 -455
  211. package/docs/ui.md +0 -929
  212. package/docs/utilities.md +0 -384
  213. package/src/css/dialog-fallback.css +0 -56
  214. package/src/js/dialog-fallback.js +0 -163
@@ -0,0 +1,47 @@
1
+ /*
2
+ * parse-config.js — Configuration parsing for data-enhance-config.
3
+ *
4
+ * Strict JSON is parsed as-is, never rewritten. When strict JSON fails, a
5
+ * small, deliberate relax step accepts the ergonomic subset that is common in
6
+ * HTML attributes: unquoted keys and single-quoted strings. The relaxed
7
+ * subset is intentionally tiny and is NOT a second grammar — anything it
8
+ * cannot transform cleanly is rejected by the final JSON.parse. The root
9
+ * value must be an object; bare string values (other than true/false/null)
10
+ * stay invalid, and trailing commas are rejected.
11
+ */
12
+
13
+ function relax(source) {
14
+ return source.replace(
15
+ /([a-zA-Z_$][\w$-]*)\s*:|'((?:\\'|[^'])*)'/g,
16
+ (_match, key, stringContent) => {
17
+ if (key) return `"${key}":`;
18
+ const unescaped = stringContent.replace(/\\'/g, "'");
19
+ const escaped = unescaped.replace(/"/g, '\\"');
20
+ return `"${escaped}"`;
21
+ },
22
+ );
23
+ }
24
+
25
+ export function parseConfig(source) {
26
+ if (source == null || typeof source !== "string") return {};
27
+ const str = source.trim();
28
+ if (str === "") return {};
29
+
30
+ let text = str;
31
+ let value;
32
+ try {
33
+ value = JSON.parse(text);
34
+ } catch {
35
+ const relaxed = relax(text);
36
+ text = relaxed.startsWith("{") ? relaxed : `{${relaxed}}`;
37
+ value = JSON.parse(text);
38
+ }
39
+
40
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
41
+ throw new TypeError("Enhancement config must be an object");
42
+ }
43
+
44
+ return value;
45
+ }
46
+
47
+ export default parseConfig;
@@ -93,6 +93,14 @@ if (typeof document !== "undefined") {
93
93
  document.addEventListener("submit", handleSubmit);
94
94
 
95
95
  window.addEventListener("pagehide", () => {
96
- for (const input of revealed) setRevealed(input, false);
96
+ for (const input of revealed) {
97
+ // Opportunistic housekeeping: an input removed from the DOM before
98
+ // navigation no longer needs reverting and can leave the set.
99
+ if (!input.isConnected) {
100
+ revealed.delete(input);
101
+ continue;
102
+ }
103
+ setRevealed(input, false);
104
+ }
97
105
  });
98
106
  }
@@ -75,12 +75,15 @@ function measureRoot(nav, root) {
75
75
  };
76
76
  }
77
77
 
78
- const html = nav.ownerDocument.documentElement;
78
+ // scrollingElement is the real scroll container in standards mode; fall
79
+ // back to documentElement (quirks mode, or older engines without it).
80
+ const doc = nav.ownerDocument;
81
+ const el = doc.scrollingElement || doc.documentElement;
79
82
  return {
80
83
  top: 0,
81
- height: html.clientHeight,
82
- scrollTop: html.scrollTop,
83
- scrollHeight: html.scrollHeight,
84
+ height: el.clientHeight,
85
+ scrollTop: el.scrollTop,
86
+ scrollHeight: el.scrollHeight,
84
87
  };
85
88
  }
86
89
 
@@ -151,6 +154,11 @@ function setupNav(nav) {
151
154
  measure();
152
155
  }
153
156
 
157
+ // The scroll root (custom container or the document viewport) is resolved
158
+ // once at connect and is stable for the lifetime of the connection; measure
159
+ // still re-reads the current root element so a replaced *element* under a
160
+ // stable container is handled, but swapping the container itself is out of
161
+ // contract (re-inject the nav or call refreshScrollspy after such a move).
154
162
  const scrollTarget = rootFor(nav) ?? nav.ownerDocument.defaultView;
155
163
  scrollTarget?.addEventListener("scroll", () => schedule(), {
156
164
  passive: true,
@@ -32,13 +32,6 @@ export const CLASSES = {
32
32
  backdrop: "surface-backdrop",
33
33
  // dialog.js — transient class for the "can't dismiss" shake animation.
34
34
  static: "is-static",
35
- // dialog-fallback.js — marker written on a dialog controlled by the legacy
36
- // shim (browsers without native <dialog>); dialog-fallback.css keys its
37
- // emulated dialog presentation on it.
38
- dialogFallback: "dialog-fallback",
39
- // dialog-fallback.js — state class on a shimmed dialog while it is open as
40
- // a modal; carries the fixed positioning and simulated backdrop.
41
- fallbackModal: "is-fallback-modal",
42
35
  // dialog.js — written on <html> while any modal dialog is open.
43
36
  modalOpen: "has-modal-open",
44
37
  // dialog.js — written on <html> when the viewport had a classic scrollbar
package/src/js/status.js CHANGED
@@ -125,7 +125,9 @@ if (typeof document !== "undefined") {
125
125
  // importing this module. A message shows it; omitting one clears it.
126
126
  document.addEventListener(EVENTS.status, (event) => {
127
127
  const { message, intent, duration } = event.detail ?? {};
128
- if (message) {
128
+ // `message != null` keeps "" (an intentional empty message) on the show
129
+ // path, consistent with status() itself; an absent message clears.
130
+ if (message != null) {
129
131
  status(message, { intent, duration });
130
132
  } else {
131
133
  status.clear();
package/src/js/surface.js CHANGED
@@ -1,33 +1,35 @@
1
1
  import enhance from "./enhance.js";
2
2
  import { EVENTS } from "./events.js";
3
- import { reposition, repositionAt, track } from "./floating.js";
4
- import { hasMenuItem, onMenuKeydown } from "./menu.js";
3
+ import { autoUpdate, reposition, repositionAt } from "./floating.js";
4
+
5
5
  import { CLASSES } from "./selectors.js";
6
6
 
7
- const DEFAULT_BREAKPOINT = 768;
8
- const BREAKPOINTS = {
9
- sm: 640,
10
- md: 768,
11
- lg: 1024,
12
- };
13
7
  const openSurfaces = new Set();
14
8
  const surfaceMap = new WeakMap();
9
+ const surfaceRetainers = new WeakMap();
15
10
  const mountedSurfaces = new WeakMap();
16
11
  const clickBoundDocuments = new WeakSet();
17
12
 
18
- // data-actual-surface is written by the runtime, never by an author, and never
19
- // selected on by CSS. It is namespaced because "data-surface" is a name an
20
- // application may already own. surface.js reaps its own surfaces so consumers
21
- // do not have to maintain panel-side lifecycle hooks.
22
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
+ ]);
23
26
  const reapers = new WeakMap();
24
27
 
25
- // Registered lazily, per owning document, and never at import time: enhance()
26
- // binds its observer to the root it is given, so a module-scope
27
- // enhance() would watch whichever document existed when this module was first
28
- // imported. Surfaces in any other document (an iframe, or a fresh test
29
- // document) would then never be swept. Keyed by Document, so nothing is
30
- // retained once a document is gone.
28
+ function normalizeAutoClose(value) {
29
+ const normalized = String(value ?? "outside");
30
+ return AUTO_CLOSE_VALUES.has(normalized) ? normalized : "true";
31
+ }
32
+
31
33
  function reaperFor(menu) {
32
34
  const root = menu.ownerDocument?.documentElement;
33
35
  if (!root) return null;
@@ -82,45 +84,20 @@ function restoreSurface(menu) {
82
84
  mountedSurfaces.delete(menu);
83
85
  }
84
86
 
85
- function getBreakpoint(menu) {
86
- const value = menu.dataset.flyoutBreakpoint || "";
87
- const raw = Number.parseInt(BREAKPOINTS[value] || value, 10);
88
- return Number.isFinite(raw) ? raw : DEFAULT_BREAKPOINT;
89
- }
90
-
91
- function getMobileMode(menu, mode) {
92
- return mode || menu.dataset.flyoutMobile || "auto";
93
- }
94
-
95
- function getPlacement(menu, placement) {
96
- return placement || menu.dataset.flyoutPlacement || "bottom-start";
97
- }
98
-
99
- function getDistance(menu, distance) {
100
- if (distance != null) return distance;
101
- const value = Number.parseFloat(menu.dataset.flyoutDistance);
102
- return Number.isFinite(value) ? value : 4;
103
- }
104
-
105
- function getAutoCloseMode(menu, value) {
106
- const mode = String(value ?? menu.dataset.flyoutAutoClose ?? "true").toLowerCase();
107
- if (mode === "inside" || mode === "outside" || mode === "false") return mode;
108
- return "true";
109
- }
110
-
111
- function shouldUseSheet(menu, mode) {
112
- const mobile = getMobileMode(menu, mode);
87
+ function shouldUseSheet(menu, state) {
88
+ const mobile = state.mobile || "auto";
113
89
  if (mobile === "none" || mobile === "anchored") return false;
114
90
  if (mobile === "sheet") return true;
115
91
 
116
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
92
+ const view = menu.ownerDocument?.defaultView;
93
+ if (!view || typeof view.matchMedia !== "function") {
117
94
  return false;
118
95
  }
119
96
 
120
- const breakpoint = getBreakpoint(menu);
97
+ const breakpoint = state.breakpoint ?? 768;
121
98
  return (
122
- window.matchMedia("(pointer: coarse)").matches &&
123
- window.matchMedia(`(max-width: ${breakpoint}px)`).matches
99
+ view.matchMedia("(pointer: coarse)").matches &&
100
+ view.matchMedia(`(max-width: ${breakpoint}px)`).matches
124
101
  );
125
102
  }
126
103
 
@@ -150,16 +127,13 @@ function ensureBackdrop(menu, state) {
150
127
  const backdrop = menu.ownerDocument.createElement("div");
151
128
  backdrop.className = CLASSES.backdrop;
152
129
  backdrop.hidden = false;
153
- backdrop.addEventListener("click", () => {
154
- closeSurface(menu);
155
- });
156
130
 
157
131
  menu.before(backdrop);
158
132
  state.backdrop = backdrop;
159
133
  }
160
134
 
161
135
  function applyPresentation(menu, state) {
162
- state.isSheet = shouldUseSheet(menu, state.mobile);
136
+ state.isSheet = shouldUseSheet(menu, state);
163
137
  if (state.isSheet) {
164
138
  menu.style.removeProperty("left");
165
139
  menu.style.removeProperty("top");
@@ -170,13 +144,13 @@ function applyPresentation(menu, state) {
170
144
 
171
145
  function positionSurface(menu) {
172
146
  const state = surfaceMap.get(menu);
173
- if (!state || state.isSheet) return;
147
+ if (!state || state.isSheet) return true;
174
148
 
175
149
  if (state.trigger) {
176
150
  const triggerWidth = state.trigger.getBoundingClientRect().width;
177
- menu.style.setProperty("--flyout-trigger-width", `${triggerWidth}px`);
151
+ menu.style.setProperty("--surface-anchor-width", `${triggerWidth}px`);
178
152
  } else {
179
- menu.style.removeProperty("--flyout-trigger-width");
153
+ menu.style.removeProperty("--surface-anchor-width");
180
154
  }
181
155
 
182
156
  const opts = {
@@ -189,94 +163,87 @@ function positionSurface(menu) {
189
163
  };
190
164
 
191
165
  if (state.point) {
192
- repositionAt(state.point.x, state.point.y, menu, opts);
193
- return;
166
+ return repositionAt(state.point.x, state.point.y, menu, opts);
194
167
  }
195
168
 
196
169
  const anchor = state.trigger || state.source;
197
- if (anchor) reposition(anchor, menu, opts);
170
+ if (anchor) return reposition(anchor, menu, opts);
171
+ return true;
198
172
  }
199
173
 
200
174
  function ensureSurfaceWired(menu) {
201
175
  if (surfaceMap.has(menu)) return surfaceMap.get(menu);
202
176
 
203
177
  ensureDocumentClick(menu);
204
- const controller = new AbortController();
205
178
  const state = {
206
- controller,
207
- untrack: track(menu),
179
+ stopTracking: null,
180
+ stopScrollIntent: null,
181
+ unregisterEscape: null,
208
182
  backdrop: null,
209
183
  trigger: null,
210
184
  source: null,
211
185
  point: null,
212
186
  mobile: "auto",
187
+ breakpoint: 768,
213
188
  placement: "bottom-start",
214
189
  distance: 4,
215
190
  flip: true,
216
191
  shift: true,
217
192
  shiftPadding: 4,
218
- autoClose: "true",
193
+ autoClose: "outside",
194
+ dismissOnScroll: false,
195
+ scrollIntentAt: null,
219
196
  isSheet: false,
220
197
  closeId: 0,
221
198
  };
222
199
 
223
- menu.addEventListener(
224
- EVENTS.reposition,
225
- (e) => {
226
- if (menu.hidden) return;
227
- if (e.detail?.type === "scroll" && surfaceMap.get(menu)?.point) {
228
- closeSurface(menu);
229
- return;
230
- }
231
- const state = surfaceMap.get(menu);
232
- if (!state) return;
233
- applyPresentation(menu, state);
234
- positionSurface(menu);
235
- },
236
- { signal: controller.signal },
237
- );
238
-
239
- menu.addEventListener(
240
- EVENTS.hide,
241
- (e) => {
242
- if (!menu.hidden) closeSurface(menu, { restoreFocus: e.detail?.type === "escape" });
243
- },
244
- { signal: controller.signal },
245
- );
246
-
247
- menu.addEventListener(
248
- EVENTS.outOfView,
249
- () => {
250
- if (!menu.hidden) closeSurface(menu);
251
- },
252
- { signal: controller.signal },
253
- );
254
-
255
- menu.addEventListener(
256
- "keydown",
257
- (e) => onMenuKeydown(e, { close: (target) => closeSurface(target) }),
258
- { signal: controller.signal },
259
- );
260
-
261
- menu.addEventListener(
262
- "click",
263
- (e) => {
264
- const state = surfaceMap.get(menu);
265
- const disabledItem = e.target.closest?.(":disabled, [aria-disabled='true']");
266
- if (disabledItem && menu.contains(disabledItem)) {
267
- e.preventDefault();
268
- return;
269
- }
270
- if (!state || state.autoClose === "outside" || state.autoClose === "false") return;
271
- if (hasMenuItem(menu, e.target)) closeSurface(menu);
272
- },
273
- { signal: controller.signal },
274
- );
275
-
276
200
  surfaceMap.set(menu, state);
277
201
  return state;
278
202
  }
279
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
+
280
247
  export function isSurfaceOpen(menu) {
281
248
  return menu.classList.contains(CLASSES.open) && !menu.hidden;
282
249
  }
@@ -286,12 +253,35 @@ export function prepareSurface(menu) {
286
253
  menu.style.position = "fixed";
287
254
  menu.hidden = true;
288
255
  syncExpanded(menu, false);
289
- // Both steps are idempotent, so they run unguarded: that also keeps the
290
- // reaper correct if a surface is ever adopted into another document.
291
256
  menu.setAttribute(SURFACE_MARKER, "");
292
257
  reaperFor(menu)?.refresh(menu);
293
258
  }
294
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
+ // A released retainer may leave a mounted panel whose original parent
278
+ // either still needs it back or has disappeared along with the trigger.
279
+ disconnectSurface(panel);
280
+ surfaceRetainers.delete(panel);
281
+ }
282
+ };
283
+ }
284
+
295
285
  export function openSurface(menu, opts = {}) {
296
286
  if (!menu?.isConnected) return false;
297
287
  if (isSurfaceOpen(menu)) return false;
@@ -304,7 +294,7 @@ export function openSurface(menu, opts = {}) {
304
294
  if (!menu.dispatchEvent(beforeOpen)) return false;
305
295
 
306
296
  for (const other of openSurfaces) {
307
- if (other !== menu) closeSurface(other);
297
+ if (other !== menu && other.ownerDocument === menu.ownerDocument) closeSurface(other);
308
298
  }
309
299
 
310
300
  const anchor = opts.trigger || opts.source || null;
@@ -315,10 +305,13 @@ export function openSurface(menu, opts = {}) {
315
305
  state.source = opts.source || null;
316
306
  state.point =
317
307
  Number.isFinite(opts.x) && Number.isFinite(opts.y) ? { x: opts.x, y: opts.y } : null;
318
- state.mobile = getMobileMode(menu, opts.mobile);
319
- state.autoClose = getAutoCloseMode(menu, opts.autoClose);
320
- state.placement = getPlacement(menu, opts.placement);
321
- state.distance = getDistance(menu, opts.distance);
308
+ state.mobile = opts.mobile || "auto";
309
+ state.breakpoint = opts.breakpoint ?? 768;
310
+ state.autoClose = normalizeAutoClose(opts.autoClose);
311
+ state.dismissOnScroll = opts.dismissOnScroll === true;
312
+ state.scrollIntentAt = null;
313
+ state.placement = opts.placement || "bottom-start";
314
+ state.distance = opts.distance ?? 4;
322
315
  state.flip = opts.flip !== false;
323
316
  state.shift = opts.shift !== false;
324
317
  state.shiftPadding = opts.shiftPadding ?? 4;
@@ -328,10 +321,16 @@ export function openSurface(menu, opts = {}) {
328
321
 
329
322
  menu.classList.add(CLASSES.open);
330
323
  menu.hidden = false;
331
- openSurfaces.add(menu);
332
324
  applyPresentation(menu, state);
333
325
  syncExpanded(menu, true);
334
- positionSurface(menu);
326
+
327
+ if (!positionSurface(menu)) {
328
+ closeSurface(menu, { restoreFocus: false });
329
+ return false;
330
+ }
331
+
332
+ openSurfaces.add(menu);
333
+ startSurfaceResources(menu, state);
335
334
  return true;
336
335
  }
337
336
 
@@ -352,6 +351,9 @@ export function closeSurface(menu, opts = {}) {
352
351
  if (backdrop) backdrop.hidden = true;
353
352
  openSurfaces.delete(menu);
354
353
  syncExpanded(menu, false);
354
+ state?.unregisterEscape?.();
355
+ state?.stopScrollIntent?.();
356
+ state?.stopTracking?.();
355
357
 
356
358
  if (shouldRestoreFocus && state?.restoreFocusTo?.isConnected) {
357
359
  state.restoreFocusTo.focus({ preventScroll: true });
@@ -374,30 +376,79 @@ export function disconnectSurface(menu, { restore = true } = {}) {
374
376
  const state = surfaceMap.get(menu);
375
377
  if (state) {
376
378
  state.backdrop?.remove();
377
- state.untrack();
378
- state.controller.abort();
379
+ state.unregisterEscape?.();
380
+ state.stopScrollIntent?.();
381
+ state.stopTracking?.();
379
382
  surfaceMap.delete(menu);
380
383
  }
381
384
  restoreSurface(menu);
382
385
  }
383
386
 
384
387
  function onDocumentClick(e) {
388
+ const path = e.composedPath();
389
+
385
390
  for (const menu of openSurfaces) {
391
+ if (menu.ownerDocument !== e.currentTarget) continue;
386
392
  const state = surfaceMap.get(menu);
387
- if (!state || state.autoClose === "inside" || state.autoClose === "false") continue;
388
- if (menu.contains(e.target) || state?.trigger?.contains(e.target)) continue;
389
- closeSurface(menu);
393
+ if (!state) continue;
394
+
395
+ const menuIndex = path.indexOf(menu);
396
+ const isInside = menuIndex >= 0;
397
+ const hasCloseTrigger =
398
+ isInside &&
399
+ path
400
+ .slice(0, menuIndex)
401
+ .some((node) => node instanceof Element && node.hasAttribute("data-flyout-close"));
402
+
403
+ if (hasCloseTrigger) {
404
+ closeSurface(menu);
405
+ continue;
406
+ }
407
+
408
+ const closesInside = state.autoClose === "true" || state.autoClose === "inside";
409
+ const closesOutside = state.autoClose === "true" || state.autoClose === "outside";
410
+ if ((isInside && closesInside) || (!isInside && closesOutside)) closeSurface(menu);
390
411
  }
391
412
  }
392
413
 
393
- // Bound per owning document on first use, not at import time — same reason as
394
- // reaperFor(): an import-time listener attaches to whichever document existed
395
- // then, and stays attached to it. Nothing can need this listener before a
396
- // surface has been wired, so binding here costs nothing and keeps SSR imports
397
- // inert without a typeof guard.
398
414
  function ensureDocumentClick(menu) {
399
415
  const doc = menu.ownerDocument;
400
416
  if (!doc || clickBoundDocuments.has(doc)) return;
401
417
  clickBoundDocuments.add(doc);
402
418
  doc.addEventListener("click", onDocumentClick);
403
419
  }
420
+
421
+ const dismissableStacks = new WeakMap();
422
+
423
+ export function registerEscapeDismissal(element, dismiss) {
424
+ const doc = element.ownerDocument;
425
+ let stack = dismissableStacks.get(doc);
426
+ if (!stack) {
427
+ stack = [];
428
+ dismissableStacks.set(doc, stack);
429
+ doc.addEventListener("keydown", onDocumentEscape);
430
+ }
431
+
432
+ const entry = { element, dismiss };
433
+ stack.push(entry);
434
+
435
+ return () => {
436
+ const index = stack.indexOf(entry);
437
+ if (index >= 0) stack.splice(index, 1);
438
+ };
439
+ }
440
+
441
+ function onDocumentEscape(event) {
442
+ if (event.key !== "Escape" || event.ctrlKey || event.altKey || event.shiftKey) return;
443
+
444
+ const stack = dismissableStacks.get(event.currentTarget);
445
+ const entry = stack.at(-1);
446
+ if (!entry) return;
447
+
448
+ event.preventDefault();
449
+ entry.dismiss({ restoreFocus: true });
450
+ }
451
+
452
+ export function getSurfaceAutoClose(menu) {
453
+ return surfaceMap.get(menu)?.autoClose ?? "outside";
454
+ }