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
@@ -1,24 +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 { CLASSES } from "./selectors.js";
5
- import {
6
- closeSurface,
7
- disconnectSurface,
8
- isSurfaceOpen,
9
- openSurface,
10
- prepareSurface,
11
- } from "./surface.js";
3
+ import { connectMenu, focusFirstMenuItem, hasMenuItems } from "./menu.js";
4
+ import { closeSurface, isSurfaceOpen, openSurface, retainSurface } from "./surface.js";
12
5
 
13
6
  const LONG_PRESS_MS = 450;
7
+ const LONG_PRESS_CLICK_SUPPRESSION_MS = 750;
14
8
  const MOVE_TOLERANCE = 10;
15
- const CONTEXT_MENU_SELECTOR = `menu.${CLASSES.flyout}`;
9
+ const CONTEXT_MENU_SELECTOR = 'menu, [role="menu"]';
16
10
  const CONTEXT_TARGET_SELECTOR = "[data-context-menu]";
17
11
  const contextMap = new WeakMap();
18
12
  const contextByMenu = new WeakMap();
19
13
 
20
- // Returns the detail from the last accepted `actual:context-menu` event for a
21
- // menu. This is intentionally optional: static menus need no application JS.
22
14
  export function contextFor(menu) {
23
15
  return contextByMenu.get(menu) || null;
24
16
  }
@@ -55,27 +47,29 @@ function getLongPressDelay(target, menu) {
55
47
  if (value === "") return LONG_PRESS_MS;
56
48
 
57
49
  const delay = Number.parseInt(value, 10);
58
- 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;
59
59
  }
60
60
 
61
61
  function focusMenuContainer(menu) {
62
62
  if (!menu.hasAttribute("tabindex")) {
63
63
  menu.tabIndex = -1;
64
64
  }
65
- 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 });
66
68
  }
67
69
 
68
- // Pointer-triggered opens (right-click, long-press) focus the menu container,
69
- // not the first item: a pointer-set focus ring is invisible (:focus-visible
70
- // doesn't match), so focusing "Open" makes the first ArrowDown look like it
71
- // skips to the second item. Focusing the container keeps the ring hidden and
72
- // lets keys.js's out-of-list fallback (indexOf === -1) land ArrowDown/ArrowUp
73
- // on the first/last item, matching native OS context menus. Keyboard-triggered
74
- // opens (openFromKeyboard) still focus the first item directly.
75
70
  function focusMenu(menu, mode) {
76
- if (!hasMenuItems(menu)) return;
77
71
  if (mode === "first-item") {
78
- focusFirstMenuItem(menu);
72
+ if (hasMenuItems(menu)) focusFirstMenuItem(menu);
79
73
  } else {
80
74
  focusMenuContainer(menu);
81
75
  }
@@ -108,9 +102,11 @@ function openContextMenu(context, menu, opts = {}) {
108
102
  y: opts.y,
109
103
  placement: opts.placement || "bottom-start",
110
104
  distance: opts.distance ?? 2,
111
- mobile: opts.mobile || menu.dataset.flyoutMobile || "auto",
105
+ dismissOnScroll: true,
106
+ mobile: opts.mobile || "auto",
112
107
  scope: opts.scope || getContextScope(context),
113
108
  restoreFocusTo: opts.restoreFocusTo,
109
+ ...readPanelOptions(menu),
114
110
  })
115
111
  ) {
116
112
  focusMenu(menu, opts.focus);
@@ -144,11 +140,16 @@ function connectContextTarget(target) {
144
140
  const menu = menuFor(target);
145
141
  if (!menu) return;
146
142
 
147
- prepareSurface(menu);
143
+ const release = retainSurface(menu);
148
144
  const controller = new AbortController();
145
+ const releaseMenu = connectMenu(menu, {
146
+ close: (menu) => closeSurface(menu),
147
+ });
149
148
  const state = {
150
149
  controller,
150
+ releaseMenu,
151
151
  menu,
152
+ release,
152
153
  timer: null,
153
154
  pointerId: null,
154
155
  startX: 0,
@@ -197,7 +198,7 @@ function connectContextTarget(target) {
197
198
  state.startY = e.clientY;
198
199
  state.timer = setTimeout(() => {
199
200
  state.timer = null;
200
- state.suppressClickUntil = Date.now() + 750;
201
+ state.suppressClickUntil = Date.now() + LONG_PRESS_CLICK_SUPPRESSION_MS;
201
202
  openContextMenu(target, menu, {
202
203
  x: state.startX,
203
204
  y: state.startY,
@@ -245,6 +246,45 @@ function connectContextTarget(target) {
245
246
  { signal: controller.signal, capture: true },
246
247
  );
247
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
+
248
288
  contextMap.set(target, state);
249
289
  }
250
290
 
@@ -253,39 +293,18 @@ function disconnectContextTarget(target) {
253
293
  if (!state) return;
254
294
  clearLongPress(state);
255
295
  state.controller.abort();
256
- if (contextFor(state.menu)?.context === target) contextByMenu.delete(state.menu);
296
+ state.releaseMenu?.();
297
+ if (contextFor(state.menu)?.context === target) {
298
+ contextByMenu.delete(state.menu);
299
+ closeSurface(state.menu, { restoreFocus: false });
300
+ }
301
+ state.release();
257
302
  contextMap.delete(target);
258
303
  }
259
304
 
260
- function connectContextMenu(menu) {
261
- const controller = new AbortController();
262
- menu.addEventListener(
263
- EVENTS.surfaceOpen,
264
- (event) => {
265
- const options = event.detail?.options;
266
- const trigger = options?.trigger;
267
- const context = trigger?.closest?.(CONTEXT_TARGET_SELECTOR);
268
- if (!context || menuFor(context) !== menu) return;
269
- if (!requestContextMenu(context, menu, { origin: trigger, trigger: "button" })) {
270
- event.preventDefault();
271
- return;
272
- }
273
- options.source = context;
274
- options.restoreFocusTo = trigger;
275
- },
276
- { signal: controller.signal },
277
- );
278
- return () => {
279
- controller.abort();
280
- contextByMenu.delete(menu);
281
- disconnectSurface(menu);
282
- };
283
- }
284
-
285
305
  enhance({
286
306
  [CONTEXT_TARGET_SELECTOR]: (target) => {
287
307
  connectContextTarget(target);
288
308
  return () => disconnectContextTarget(target);
289
309
  },
290
- [CONTEXT_MENU_SELECTOR]: (menu) => connectContextMenu(menu),
291
310
  });
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
+ });