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
@@ -1,17 +1,16 @@
1
1
  import enhance from "./enhance.js";
2
2
  import { EVENTS } from "./events.js";
3
- import { focusFirstMenuItem, hasMenuItems } from "./menu.js";
4
- import { closeSurface, isSurfaceOpen, openSurface, prepareSurface } from "./surface.js";
3
+ import { connectMenu, focusFirstMenuItem, hasMenuItems } from "./menu.js";
4
+ import { closeSurface, isSurfaceOpen, openSurface, retainSurface } from "./surface.js";
5
5
 
6
6
  const LONG_PRESS_MS = 450;
7
+ const LONG_PRESS_CLICK_SUPPRESSION_MS = 750;
7
8
  const MOVE_TOLERANCE = 10;
8
9
  const CONTEXT_MENU_SELECTOR = 'menu, [role="menu"]';
9
10
  const CONTEXT_TARGET_SELECTOR = "[data-context-menu]";
10
11
  const contextMap = new WeakMap();
11
12
  const contextByMenu = new WeakMap();
12
13
 
13
- // Returns the detail from the last accepted `actual:context-menu` event for a
14
- // menu. This is intentionally optional: static menus need no application JS.
15
14
  export function contextFor(menu) {
16
15
  return contextByMenu.get(menu) || null;
17
16
  }
@@ -48,27 +47,29 @@ function getLongPressDelay(target, menu) {
48
47
  if (value === "") return LONG_PRESS_MS;
49
48
 
50
49
  const delay = Number.parseInt(value, 10);
51
- return Number.isFinite(delay) ? delay : LONG_PRESS_MS;
50
+ return Number.isFinite(delay) ? Math.max(0, delay) : LONG_PRESS_MS;
51
+ }
52
+
53
+ function readPanelOptions(menu) {
54
+ const opts = { autoClose: "true" };
55
+ const ds = menu.dataset;
56
+ if (ds.flyoutMobile) opts.mobile = ds.flyoutMobile;
57
+ if (ds.flyoutAutoClose) opts.autoClose = ds.flyoutAutoClose;
58
+ return opts;
52
59
  }
53
60
 
54
61
  function focusMenuContainer(menu) {
55
62
  if (!menu.hasAttribute("tabindex")) {
56
63
  menu.tabIndex = -1;
57
64
  }
58
- menu.focus();
65
+ // Moving the mounted menu into the surface root must not scroll the page.
66
+ // Genuine user scrolling is handled separately by the surface policy.
67
+ menu.focus({ preventScroll: true });
59
68
  }
60
69
 
61
- // Pointer-triggered opens (right-click, long-press) focus the menu container,
62
- // not the first item: a pointer-set focus ring is invisible (:focus-visible
63
- // doesn't match), so focusing "Open" makes the first ArrowDown look like it
64
- // skips to the second item. Focusing the container keeps the ring hidden and
65
- // lets keys.js's out-of-list fallback (indexOf === -1) land ArrowDown/ArrowUp
66
- // on the first/last item, matching native OS context menus. Keyboard-triggered
67
- // opens (openFromKeyboard) still focus the first item directly.
68
70
  function focusMenu(menu, mode) {
69
- if (!hasMenuItems(menu)) return;
70
71
  if (mode === "first-item") {
71
- focusFirstMenuItem(menu);
72
+ if (hasMenuItems(menu)) focusFirstMenuItem(menu);
72
73
  } else {
73
74
  focusMenuContainer(menu);
74
75
  }
@@ -101,9 +102,11 @@ function openContextMenu(context, menu, opts = {}) {
101
102
  y: opts.y,
102
103
  placement: opts.placement || "bottom-start",
103
104
  distance: opts.distance ?? 2,
104
- mobile: opts.mobile || menu.dataset.flyoutMobile || "auto",
105
+ dismissOnScroll: true,
106
+ mobile: opts.mobile || "auto",
105
107
  scope: opts.scope || getContextScope(context),
106
108
  restoreFocusTo: opts.restoreFocusTo,
109
+ ...readPanelOptions(menu),
107
110
  })
108
111
  ) {
109
112
  focusMenu(menu, opts.focus);
@@ -137,11 +140,16 @@ function connectContextTarget(target) {
137
140
  const menu = menuFor(target);
138
141
  if (!menu) return;
139
142
 
140
- prepareSurface(menu);
143
+ const release = retainSurface(menu);
141
144
  const controller = new AbortController();
145
+ const releaseMenu = connectMenu(menu, {
146
+ close: (menu) => closeSurface(menu),
147
+ });
142
148
  const state = {
143
149
  controller,
150
+ releaseMenu,
144
151
  menu,
152
+ release,
145
153
  timer: null,
146
154
  pointerId: null,
147
155
  startX: 0,
@@ -190,7 +198,7 @@ function connectContextTarget(target) {
190
198
  state.startY = e.clientY;
191
199
  state.timer = setTimeout(() => {
192
200
  state.timer = null;
193
- state.suppressClickUntil = Date.now() + 750;
201
+ state.suppressClickUntil = Date.now() + LONG_PRESS_CLICK_SUPPRESSION_MS;
194
202
  openContextMenu(target, menu, {
195
203
  x: state.startX,
196
204
  y: state.startY,
@@ -238,6 +246,45 @@ function connectContextTarget(target) {
238
246
  { signal: controller.signal, capture: true },
239
247
  );
240
248
 
249
+ target.addEventListener(
250
+ "click",
251
+ (e) => {
252
+ const trigger = e.target.closest("[data-context-menu-trigger]");
253
+ if (
254
+ !trigger ||
255
+ trigger.getAttribute("aria-controls") !== target.getAttribute("data-context-menu")
256
+ )
257
+ return;
258
+
259
+ e.preventDefault();
260
+ e.stopPropagation();
261
+
262
+ if (isSurfaceOpen(menu)) {
263
+ closeSurface(menu);
264
+ return;
265
+ }
266
+
267
+ if (!requestContextMenu(target, menu, { origin: trigger, trigger: "button" })) return;
268
+
269
+ const rect = trigger.getBoundingClientRect();
270
+ if (
271
+ openSurface(menu, {
272
+ source: target,
273
+ x: rect.left,
274
+ y: rect.bottom,
275
+ placement: "bottom-start",
276
+ distance: 4,
277
+ dismissOnScroll: true,
278
+ restoreFocusTo: trigger,
279
+ ...readPanelOptions(menu),
280
+ })
281
+ ) {
282
+ focusMenuContainer(menu);
283
+ }
284
+ },
285
+ { signal: controller.signal },
286
+ );
287
+
241
288
  contextMap.set(target, state);
242
289
  }
243
290
 
@@ -246,33 +293,18 @@ function disconnectContextTarget(target) {
246
293
  if (!state) return;
247
294
  clearLongPress(state);
248
295
  state.controller.abort();
296
+ state.releaseMenu?.();
249
297
  if (contextFor(state.menu)?.context === target) {
250
298
  contextByMenu.delete(state.menu);
251
299
  closeSurface(state.menu, { restoreFocus: false });
252
300
  }
301
+ state.release();
253
302
  contextMap.delete(target);
254
303
  }
255
304
 
256
- function onSurfaceOpen(event) {
257
- const options = event.detail?.options;
258
- const trigger = options?.trigger;
259
- const context = trigger?.closest?.(CONTEXT_TARGET_SELECTOR);
260
- if (!context || menuFor(context) !== event.detail?.surface) return;
261
- if (!requestContextMenu(context, event.detail.surface, { origin: trigger, trigger: "button" })) {
262
- event.preventDefault();
263
- return;
264
- }
265
- options.source = context;
266
- options.restoreFocusTo = trigger;
267
- }
268
-
269
305
  enhance({
270
306
  [CONTEXT_TARGET_SELECTOR]: (target) => {
271
307
  connectContextTarget(target);
272
308
  return () => disconnectContextTarget(target);
273
309
  },
274
310
  });
275
-
276
- if (typeof document !== "undefined") {
277
- document.addEventListener(EVENTS.surfaceOpen, onSurfaceOpen);
278
- }
package/src/js/dialog.js CHANGED
@@ -8,7 +8,13 @@
8
8
  * Command semantics are explicit: show-modal calls showModal(), show calls
9
9
  * show(). data-dialog-modal still defines the default for direct openDialog()
10
10
  * calls when no command mode is forced.
11
- * Dismissible means backdrop click/light dismiss is allowed.
11
+ * data-dialog-dismissible gates backdrop click only: with it, the runtime
12
+ * handles light dismiss (rewriting closedby="any" to "closerequest" so the
13
+ * native dialog does not double-handle). Without it, a native closedby="any"
14
+ * dialog keeps its own light dismiss and other dialogs flash a static
15
+ * indicator on backdrop click. Escape and explicit close requests always
16
+ * close, unless an application cancels the actual:dialog-cancel event, and
17
+ * closedby="none" keeps its native meaning (no Escape, no backdrop close).
12
18
  *
13
19
  * View transitions (opt-in via data-dialog-view-transition):
14
20
  * When the browser supports document.startViewTransition and the user allows
@@ -16,10 +22,9 @@
16
22
  * view-transition-name. Otherwise native dialog behavior remains the
17
23
  * baseline: the dialog simply opens and closes.
18
24
  *
19
- * Browsers without native <dialog> can get a rudimentary per-element shim
20
- * from the optional, standalone dialog-fallback.js module; this module has
21
- * no dependency on it and alerts when no shim or polyfill made the target
22
- * controllable.
25
+ * The runtime assumes native <dialog> with show()/showModal()/close() — the
26
+ * Minimal browser tier. requestClose() is newer than the floor and is used
27
+ * when present, falling back to close() otherwise.
23
28
  */
24
29
 
25
30
  import { registerCommands, targetFor } from "./command.js";
@@ -32,35 +37,10 @@ const wiredDialogs = new Set();
32
37
  const DIALOG_COMMANDS = ["show-modal", "show", "request-close", "close"];
33
38
  const DIALOG_SELECTOR = "dialog";
34
39
  const DIALOG_TITLE_SELECTOR = "[data-title], h1, h2, h3, h4, h5, h6";
35
- const DEFAULT_UNSUPPORTED_MESSAGE =
36
- "This dialog cannot open because this browser does not support this feature.";
37
40
  let uid = 0;
38
41
 
39
- function supportsDialog() {
40
- return (
41
- typeof HTMLDialogElement !== "undefined" &&
42
- typeof HTMLDialogElement.prototype.showModal === "function"
43
- );
44
- }
45
42
  function isDialogElement(el) {
46
- return (
47
- typeof Node !== "undefined" && el?.nodeType === Node.ELEMENT_NODE && el.localName === "dialog"
48
- );
49
- }
50
-
51
- function canControlDialog(el) {
52
- // Optional polyfills may patch registered <dialog> elements without making
53
- // supportsDialog() true globally, so check the actual element before opening.
54
- return (
55
- isDialogElement(el) &&
56
- typeof el.show === "function" &&
57
- typeof el.showModal === "function" &&
58
- typeof el.close === "function"
59
- );
60
- }
61
-
62
- function isDialog(el) {
63
- return canControlDialog(el);
43
+ return el?.nodeType === 1 && el.localName === "dialog";
64
44
  }
65
45
 
66
46
  function boolData(el, name) {
@@ -98,13 +78,6 @@ function syncDialogSemantics(dialog, modal = isModal(dialog)) {
98
78
  }
99
79
  }
100
80
 
101
- // Reached only when neither the optional dialog-fallback module nor an
102
- // adopter polyfill made the target controllable on a legacy browser.
103
- function notifyUnsupportedDialog(trigger) {
104
- const win = trigger.ownerDocument.defaultView;
105
- win?.alert?.(DEFAULT_UNSUPPORTED_MESSAGE);
106
- }
107
-
108
81
  function isModal(dialog) {
109
82
  return dialog.getAttribute("data-dialog-modal") !== "false";
110
83
  }
@@ -227,16 +200,17 @@ function isOutsideDialog(dialog, event) {
227
200
 
228
201
  function flashStatic(dialog) {
229
202
  const state = ensureDialogWired(dialog);
203
+ const win = dialog.ownerDocument.defaultView;
230
204
 
231
205
  if (state.staticTimer) {
232
- window.clearTimeout(state.staticTimer);
206
+ win?.clearTimeout(state.staticTimer);
233
207
  }
234
208
 
235
209
  dialog.classList.remove(CLASSES.static);
236
210
  void dialog.offsetWidth;
237
211
  dialog.classList.add(CLASSES.static);
238
212
 
239
- state.staticTimer = window.setTimeout(() => {
213
+ state.staticTimer = win?.setTimeout(() => {
240
214
  dialog.classList.remove(CLASSES.static);
241
215
  state.staticTimer = null;
242
216
  }, 250);
@@ -256,7 +230,7 @@ function finishClose(dialog, returnValue = "") {
256
230
  }
257
231
 
258
232
  export function closeDialog(dialog, returnValue = "") {
259
- if (!isDialog(dialog) || !dialog.open) return;
233
+ if (!isDialogElement(dialog) || !dialog.open) return;
260
234
 
261
235
  const state = ensureDialogWired(dialog);
262
236
  if (state.closing) return;
@@ -285,7 +259,7 @@ export function closeDialog(dialog, returnValue = "") {
285
259
  }
286
260
 
287
261
  export function requestDialogClose(dialog, returnValue = "") {
288
- if (!isDialog(dialog) || !dialog.open) return;
262
+ if (!isDialogElement(dialog) || !dialog.open) return;
289
263
 
290
264
  if (typeof dialog.requestClose === "function") {
291
265
  ensureDialogWired(dialog).returnValue = returnValue;
@@ -297,14 +271,20 @@ export function requestDialogClose(dialog, returnValue = "") {
297
271
  }
298
272
 
299
273
  export function openDialog(dialog, trigger = null, forcedModal = null) {
300
- if (!isDialog(dialog) || dialog.open || !dialog.isConnected) return;
274
+ if (!isDialogElement(dialog) || dialog.open || !dialog.isConnected) return;
301
275
 
302
276
  const state = ensureDialogWired(dialog);
303
277
  const modal = resolveModalMode(dialog, forcedModal);
304
278
  syncDialogSemantics(dialog, modal);
305
279
 
306
280
  state.closing = false;
307
- state.restoreFocusTo = trigger || document.activeElement;
281
+ state.restoreFocusTo = trigger || dialog.ownerDocument.activeElement;
282
+
283
+ // showModal()/show() focus the dialog, and Chrome resets the viewport scroll
284
+ // while doing so; capture the position first and restore it so the page does
285
+ // not jump to the top behind the modal.
286
+ const viewport = dialog.ownerDocument.defaultView;
287
+ const scrollY = viewport?.scrollY ?? 0;
308
288
 
309
289
  if (modal) {
310
290
  dialog.showModal();
@@ -314,6 +294,10 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
314
294
  state.modalOpen = false;
315
295
  }
316
296
 
297
+ if (scrollY > 0) {
298
+ viewport?.scrollTo(0, scrollY);
299
+ }
300
+
317
301
  syncModalOpenClass(dialog.ownerDocument);
318
302
  }
319
303
 
@@ -322,14 +306,19 @@ function handleDialogClick(event) {
322
306
 
323
307
  if (event.target !== dialog || !isOutsideDialog(dialog, event)) return;
324
308
 
325
- event.preventDefault();
326
-
327
- if (isDismissible(dialog)) {
328
- requestDialogClose(dialog);
309
+ // A native closedby="any" dialog owns its light dismiss; the runtime only
310
+ // takes over when the author opted in via data-dialog-dismissible (which
311
+ // rewrites "any" to "closerequest"). Leave the native close alone.
312
+ if (!isDismissible(dialog)) {
313
+ const closedBy = "closedBy" in dialog ? dialog.closedBy : "";
314
+ if (closedBy === "any") return;
315
+ event.preventDefault();
316
+ flashStatic(dialog);
329
317
  return;
330
318
  }
331
319
 
332
- flashStatic(dialog);
320
+ event.preventDefault();
321
+ requestDialogClose(dialog);
333
322
  }
334
323
 
335
324
  function handleDialogSubmit(event) {
@@ -346,25 +335,25 @@ function handleDialogSubmit(event) {
346
335
 
347
336
  function handleDialogCancel(event) {
348
337
  const dialog = event.currentTarget;
338
+ const state = dialogMap.get(dialog);
349
339
 
350
- if (!isDismissible(dialog)) {
351
- event.preventDefault();
352
- flashStatic(dialog);
353
- return;
354
- }
355
-
340
+ // Escape and close requests always close, whatever the dismissible flag:
341
+ // data-dialog-dismissible only gates light dismiss (backdrop click, handled
342
+ // in handleDialogClick). Applications intercept via actual:dialog-cancel.
356
343
  const request = new CustomEvent(EVENTS.dialogCancel, {
357
344
  bubbles: true,
358
345
  cancelable: true,
359
346
  detail: { dialog, sourceEvent: event },
360
347
  });
361
348
  if (!dialog.dispatchEvent(request)) {
349
+ // Close was prevented; drop any returnValue staged for it.
362
350
  event.preventDefault();
351
+ if (state) state.returnValue = "";
363
352
  return;
364
353
  }
365
354
 
366
355
  event.preventDefault();
367
- closeDialog(dialog, dialogMap.get(dialog)?.returnValue || "");
356
+ closeDialog(dialog, state?.returnValue || "");
368
357
  }
369
358
 
370
359
  function handleDialogClose(event) {
@@ -376,7 +365,7 @@ function handleDialogClose(event) {
376
365
  if (!state) return;
377
366
 
378
367
  if (state.staticTimer) {
379
- window.clearTimeout(state.staticTimer);
368
+ dialog.ownerDocument.defaultView?.clearTimeout(state.staticTimer);
380
369
  state.staticTimer = null;
381
370
  }
382
371
 
@@ -389,8 +378,11 @@ function handleDialogClose(event) {
389
378
  const restoreFocusTo = state.restoreFocusTo;
390
379
  state.restoreFocusTo = null;
391
380
 
392
- if (restoreFocusTo?.isConnected) {
393
- restoreFocusTo.focus();
381
+ // The native dialog restores focus to the previously focused element on
382
+ // close; only step in when it has not (e.g. an explicit trigger we tracked)
383
+ // and avoid scrolling the viewport while doing so.
384
+ if (restoreFocusTo?.isConnected && dialog.ownerDocument.activeElement !== restoreFocusTo) {
385
+ restoreFocusTo.focus({ preventScroll: true });
394
386
  }
395
387
  }
396
388
 
@@ -407,18 +399,20 @@ function ensureDialogWired(dialog) {
407
399
  staticTimer: null,
408
400
  };
409
401
 
410
- syncDialogSemantics(dialog);
402
+ // A dialog that is already `open` in the markup is non-modal per spec,
403
+ // whatever data-dialog-modal says; infer semantics from its actual state.
404
+ syncDialogSemantics(dialog, dialog.open ? isModalOpen(dialog) : isModal(dialog));
411
405
 
412
406
  dialog.addEventListener("click", handleDialogClick, { signal: controller.signal });
413
407
  dialog.addEventListener("submit", handleDialogSubmit, { signal: controller.signal });
414
408
  dialog.addEventListener("cancel", handleDialogCancel, { signal: controller.signal });
415
409
  dialog.addEventListener("close", handleDialogClose, { signal: controller.signal });
416
410
 
417
- // Let modern browsers know the intended native close policy. Backdrop click
418
- // is still handled above so animation remains consistent across browsers.
419
- if ("closedBy" in dialog) {
420
- // Keep native Esc/back close requests, but handle backdrop clicks ourselves
421
- // so animated light dismiss behaves consistently across browsers.
411
+ // Only rewrite "any" when the runtime owns light dismiss (dismissible
412
+ // backdrop): the author opted into Actual's controlled behavior. Otherwise
413
+ // closedby keeps its native meaning — "any" closes on backdrop click,
414
+ // "closerequest" on Escape, "none" disables both. Never override "none".
415
+ if (isDismissible(dialog) && "closedBy" in dialog && dialog.closedBy === "any") {
422
416
  dialog.closedBy = "closerequest";
423
417
  }
424
418
 
@@ -437,7 +431,7 @@ function disconnectDialog(dialog) {
437
431
  if (!state) return;
438
432
 
439
433
  if (state.staticTimer) {
440
- window.clearTimeout(state.staticTimer);
434
+ dialog.ownerDocument.defaultView?.clearTimeout(state.staticTimer);
441
435
  }
442
436
 
443
437
  state.controller.abort();
@@ -454,7 +448,7 @@ registerCommands(DIALOG_COMMANDS, {
454
448
  return isDialogElement(dialog) ? dialog : null;
455
449
  },
456
450
  prepare: (trigger, dialog, command) => {
457
- if (isDialog(dialog)) {
451
+ if (isDialogElement(dialog)) {
458
452
  ensureDialogWired(dialog);
459
453
  }
460
454
 
@@ -470,10 +464,7 @@ registerCommands(DIALOG_COMMANDS, {
470
464
  handle: (event, trigger, dialog, command) => {
471
465
  event.preventDefault();
472
466
 
473
- if (!isDialog(dialog)) {
474
- if (command === "show-modal" || command === "show") {
475
- notifyUnsupportedDialog(trigger);
476
- }
467
+ if (!isDialogElement(dialog)) {
477
468
  return;
478
469
  }
479
470
 
@@ -493,11 +484,9 @@ registerCommands(DIALOG_COMMANDS, {
493
484
  },
494
485
  });
495
486
 
496
- if (supportsDialog()) {
497
- enhance({
498
- [DIALOG_SELECTOR]: (dialog) => {
499
- connectDialog(dialog);
500
- return () => disconnectDialog(dialog);
501
- },
502
- });
503
- }
487
+ enhance({
488
+ [DIALOG_SELECTOR]: (dialog) => {
489
+ connectDialog(dialog);
490
+ return () => disconnectDialog(dialog);
491
+ },
492
+ });
@@ -0,0 +1,15 @@
1
+ import { registerCommands } from "./command.js";
2
+ import { EVENTS } from "./events.js";
3
+
4
+ registerCommands("--dismiss", {
5
+ handle(event, trigger, target) {
6
+ event.preventDefault();
7
+ target.hidden = true;
8
+ target.dispatchEvent(
9
+ new CustomEvent(EVENTS.dismiss, {
10
+ bubbles: true,
11
+ detail: { trigger },
12
+ }),
13
+ );
14
+ },
15
+ });
package/src/js/enhance.js CHANGED
@@ -13,33 +13,40 @@
13
13
  * attributes are setup-time contracts, not live enable/disable switches.
14
14
  *
15
15
  * enhancers: { [selector]: (el) => cleanup | void }
16
- * Returns: { refresh, forget, disconnect }
16
+ * Returns: { refresh, disconnect }
17
17
  *
18
18
  * Enhancement tokens — the data-enhance layer on top of enhance().
19
19
  *
20
20
  * enhancementSelector("tabs") returns [data-enhance~="tabs"], the generic
21
- * opt-in for root-controller behaviors. registerEnhancement() is a stateless
22
- * wrapper around enhance() — no central registry, no double-registration
23
- * warning. Third-party behaviors register exactly like built-in ones.
21
+ * opt-in for root-controller behaviors. registerEnhancement() owns a name per
22
+ * root — a second registration for the same name on the same root throws, and
23
+ * disconnect() releases ownership. Third-party behaviors register exactly
24
+ * like built-in ones.
24
25
  *
25
26
  * See docs/design-notes/enhancement-contract.md.
26
27
  */
27
28
 
28
29
  const registries = new WeakMap();
30
+ const ownedNames = new WeakMap();
31
+
32
+ // Node type constants as literals, not the realm's Node global, so nodes from
33
+ // another document/window compare correctly.
34
+ const ELEMENT_NODE = 1;
35
+ const DOCUMENT_NODE = 9;
36
+ const DOCUMENT_FRAGMENT_NODE = 11;
29
37
 
30
38
  function noopRuntime() {
31
39
  return {
32
40
  refresh() {},
33
- forget() {},
34
41
  disconnect() {},
35
42
  };
36
43
  }
37
44
 
38
45
  function canScan(node) {
39
46
  return (
40
- node?.nodeType === Node.ELEMENT_NODE ||
41
- node?.nodeType === Node.DOCUMENT_NODE ||
42
- node?.nodeType === Node.DOCUMENT_FRAGMENT_NODE
47
+ node?.nodeType === ELEMENT_NODE ||
48
+ node?.nodeType === DOCUMENT_NODE ||
49
+ node?.nodeType === DOCUMENT_FRAGMENT_NODE
43
50
  );
44
51
  }
45
52
 
@@ -50,7 +57,7 @@ function createRegistry(root) {
50
57
  function isValidSelector(selector) {
51
58
  try {
52
59
  root.querySelector?.(selector);
53
- if (root instanceof Element) root.matches(selector);
60
+ if (root.nodeType === ELEMENT_NODE) root.matches(selector);
54
61
  return true;
55
62
  } catch (error) {
56
63
  console.error(`Invalid enhancer selector "${selector}"`, error);
@@ -63,7 +70,7 @@ function createRegistry(root) {
63
70
  }
64
71
 
65
72
  function start(record, el) {
66
- if (!(el instanceof Element)) return;
73
+ if (el?.nodeType !== ELEMENT_NODE) return;
67
74
  let active = record.instances.get(el);
68
75
 
69
76
  for (const selector of record.selectors) {
@@ -89,14 +96,18 @@ function createRegistry(root) {
89
96
  const active = record.instances.get(el);
90
97
  if (!active) return;
91
98
  for (const cleanup of active.values()) {
92
- cleanup?.();
99
+ try {
100
+ cleanup?.();
101
+ } catch (error) {
102
+ console.error("Enhancer cleanup failed", error);
103
+ }
93
104
  }
94
105
  record.instances.delete(el);
95
106
  }
96
107
 
97
108
  function scanFor(record, node) {
98
109
  if (!canScan(node)) return;
99
- if (node instanceof Element) start(record, node);
110
+ if (node.nodeType === ELEMENT_NODE) start(record, node);
100
111
  if (record.selectorString) {
101
112
  node.querySelectorAll?.(record.selectorString).forEach((el) => {
102
113
  start(record, el);
@@ -106,7 +117,7 @@ function createRegistry(root) {
106
117
 
107
118
  function scan(node) {
108
119
  if (!canScan(node) || !selectorString) return;
109
- if (node instanceof Element) {
120
+ if (node.nodeType === ELEMENT_NODE) {
110
121
  for (const record of records) start(record, node);
111
122
  }
112
123
  node.querySelectorAll?.(selectorString).forEach((el) => {
@@ -117,7 +128,7 @@ function createRegistry(root) {
117
128
  function sweepDisconnected() {
118
129
  for (const record of records) {
119
130
  for (const el of Array.from(record.instances.keys())) {
120
- if (!el.isConnected) stop(record, el);
131
+ if (!el.isConnected || !root.contains(el)) stop(record, el);
121
132
  }
122
133
  }
123
134
  }
@@ -161,10 +172,6 @@ function createRegistry(root) {
161
172
  if (record.disconnected) return;
162
173
  scanFor(record, node);
163
174
  },
164
- forget(record, el) {
165
- if (record.disconnected) return;
166
- record.instances.delete(el);
167
- },
168
175
  remove(record) {
169
176
  if (record.disconnected) return;
170
177
  record.disconnected = true;
@@ -195,7 +202,34 @@ export function hasEnhancement(el, name) {
195
202
  }
196
203
 
197
204
  export function registerEnhancement(name, init, root) {
198
- return enhance({ [enhancementSelector(name)]: init }, root);
205
+ if (typeof document === "undefined") return noopRuntime();
206
+
207
+ enhancementSelector(name);
208
+ if (typeof init !== "function") {
209
+ throw new TypeError("registerEnhancement() requires a function init.");
210
+ }
211
+
212
+ root ??= document.documentElement;
213
+ let names = ownedNames.get(root);
214
+ if (!names) {
215
+ names = new Map();
216
+ ownedNames.set(root, names);
217
+ }
218
+ if (names.has(name)) {
219
+ throw new Error(`Enhancement "${name}" is already registered on this root.`);
220
+ }
221
+
222
+ const runtime = enhance({ [enhancementSelector(name)]: init }, root);
223
+ names.set(name, runtime);
224
+
225
+ return {
226
+ refresh: (node) => runtime.refresh(node),
227
+ disconnect: () => {
228
+ runtime.disconnect();
229
+ if (names.get(name) === runtime) names.delete(name);
230
+ if (names.size === 0) ownedNames.delete(root);
231
+ },
232
+ };
199
233
  }
200
234
 
201
235
  function registryFor(root) {
@@ -210,7 +244,7 @@ function registryFor(root) {
210
244
  /**
211
245
  * @param {Record<string, (el: Element) => (() => void) | void>} enhancers
212
246
  * @param {Document | Element | DocumentFragment} [root]
213
- * @returns {{ refresh: (node: Node) => void, forget: (el: Element) => void, disconnect: () => void }}
247
+ * @returns {{ refresh: (node: Node) => void, disconnect: () => void }}
214
248
  */
215
249
  export default function enhance(enhancers, root) {
216
250
  if (typeof document === "undefined") {
@@ -224,9 +258,6 @@ export default function enhance(enhancers, root) {
224
258
 
225
259
  return {
226
260
  refresh: (node) => registry.refresh(record, node),
227
- forget: (el) => {
228
- registry.forget(record, el);
229
- },
230
261
  disconnect: () => {
231
262
  registry.remove(record);
232
263
  },