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.
- package/CHANGELOG.md +49 -1
- package/README.md +24 -9
- package/dist/actual-themes.min.css +1 -0
- package/dist/actual.css +1532 -1114
- package/dist/actual.js +1 -3
- package/dist/actual.min.css +1 -1
- package/dist/optional.css +1048 -0
- package/dist/optional.min.css +1 -0
- package/docs/design-notes/browser-support.md +94 -14
- package/docs/design-notes/cascade-layer.md +7 -6
- package/docs/design-notes/enhancement-contract.md +11 -14
- package/docs/design-notes/forced-colors.md +105 -0
- package/docs/design-notes/naming.md +2 -1
- package/docs/design-notes/platform-alignment.md +492 -0
- package/docs/design-notes/references.md +8 -0
- package/docs/design-notes/widget-primitives.md +37 -1
- package/docs/navigation.json +135 -0
- package/docs/pages/components/accordion.md +80 -0
- package/docs/pages/components/alert.md +203 -0
- package/docs/pages/components/aura.md +35 -0
- package/docs/pages/components/avatar.md +95 -0
- package/docs/pages/components/badge.md +102 -0
- package/docs/pages/components/breadcrumb.md +19 -0
- package/docs/pages/components/button.md +210 -0
- package/docs/pages/components/card.md +195 -0
- package/docs/pages/components/chat.md +50 -0
- package/docs/pages/components/dialog.md +367 -0
- package/docs/pages/components/drawer.md +113 -0
- package/docs/pages/components/fab.md +118 -0
- package/docs/pages/components/key.md +33 -0
- package/docs/pages/components/meter.md +23 -0
- package/docs/pages/components/navbar.md +92 -0
- package/docs/pages/components/pagination.md +55 -0
- package/docs/pages/components/progress.md +29 -0
- package/docs/pages/components/skeleton.md +38 -0
- package/docs/pages/components/spinner.md +93 -0
- package/docs/pages/components/table.md +102 -0
- package/docs/pages/enhancements/flyout.md +391 -0
- package/docs/pages/enhancements/overview.md +36 -0
- package/docs/pages/enhancements/scrollspy.md +71 -0
- package/docs/pages/enhancements/status-bar.md +72 -0
- package/docs/pages/enhancements/tabs.md +80 -0
- package/docs/pages/enhancements/tooltip.md +130 -0
- package/docs/pages/examples/overview.md +43 -0
- package/docs/pages/forms/choice-cards.md +102 -0
- package/docs/pages/forms/controls.md +32 -0
- package/docs/pages/forms/detached-actions.md +59 -0
- package/docs/pages/forms/floating-field.md +88 -0
- package/docs/pages/forms/form-actions.md +56 -0
- package/docs/pages/forms/input-policy.md +91 -0
- package/docs/pages/forms/joined-controls.md +37 -0
- package/docs/pages/forms/labels.md +42 -0
- package/docs/pages/forms/otp.md +50 -0
- package/docs/pages/forms/overview.md +188 -0
- package/docs/pages/forms/password.md +40 -0
- package/docs/pages/forms/range.md +17 -0
- package/docs/pages/forms/states-and-sizes.md +321 -0
- package/docs/pages/forms/sticky-actions.md +187 -0
- package/docs/pages/forms/switch.md +31 -0
- package/docs/pages/forms/validation.md +241 -0
- package/docs/{mental-model.md → pages/foundations/philosophy.md} +33 -18
- package/docs/pages/foundations/theming.md +99 -0
- package/docs/pages/foundations/tokens.md +421 -0
- package/docs/{typography.md → pages/foundations/typography.md} +27 -9
- package/docs/pages/guides/bootstrap.md +438 -0
- package/docs/pages/guides/introduction.md +157 -0
- package/docs/pages/guides/optional-components.md +33 -0
- package/docs/pages/guides/progressive-enhancement.md +686 -0
- package/docs/pages/guides/tailwind.md +455 -0
- package/docs/pages/layout/app-shell.md +25 -0
- package/docs/pages/layout/center.md +29 -0
- package/docs/pages/layout/cluster.md +59 -0
- package/docs/pages/layout/frame.md +30 -0
- package/docs/pages/layout/grid.md +142 -0
- package/docs/pages/layout/list.md +41 -0
- package/docs/pages/layout/media.md +28 -0
- package/docs/pages/layout/overview.md +37 -0
- package/docs/pages/layout/scroll-snap.md +54 -0
- package/docs/pages/layout/sidebar-layout.md +27 -0
- package/docs/pages/layout/stack.md +55 -0
- package/docs/pages/layout/switcher.md +36 -0
- package/docs/pages/layout/topbar.md +23 -0
- package/docs/pages/patterns/actions.md +52 -0
- package/docs/pages/patterns/code-block.md +36 -0
- package/docs/pages/patterns/exclusive-accordion.md +16 -0
- package/docs/pages/patterns/hierarchical-nav.md +26 -0
- package/docs/pages/patterns/icon-input.md +25 -0
- package/docs/pages/patterns/intent-nesting.md +3 -0
- package/docs/pages/patterns/navigation-list.md +235 -0
- package/docs/pages/patterns/overview.md +58 -0
- package/docs/pages/patterns/structured-lists.md +47 -0
- package/docs/pages/utilities/accessibility.md +34 -0
- package/docs/pages/utilities/flex.md +105 -0
- package/docs/pages/utilities/links.md +18 -0
- package/docs/pages/utilities/list.md +32 -0
- package/docs/pages/utilities/overflow.md +43 -0
- package/docs/pages/utilities/overview.md +18 -0
- package/docs/pages/utilities/shape.md +25 -0
- package/docs/pages/utilities/sizing-wrapping.md +115 -0
- package/docs/pages/utilities/spacing.md +79 -0
- package/docs/pages/utilities/text.md +43 -0
- package/llms.txt +4 -4
- package/package.json +21 -12
- package/src/css/actual.css +16 -2
- package/src/css/base.css +3 -5
- package/src/css/components/accordion.css +18 -30
- package/src/css/components/actions.css +4 -1
- package/src/css/components/alert.css +139 -102
- package/src/css/components/avatar.css +16 -10
- package/src/css/components/badge.css +69 -42
- package/src/css/components/breadcrumb.css +7 -15
- package/src/css/components/busy.css +6 -0
- package/src/css/components/button.css +84 -34
- package/src/css/components/card.css +26 -38
- package/src/css/components/drawer.css +30 -13
- package/src/css/components/flyout.css +39 -109
- package/src/css/components/index.css +1 -0
- package/src/css/components/join.css +14 -2
- package/src/css/components/key.css +2 -0
- package/src/css/components/menu.css +125 -0
- package/src/css/components/meter.css +0 -23
- package/src/css/components/modal.css +162 -7
- package/src/css/components/navbar.css +30 -23
- package/src/css/components/overline.css +8 -9
- package/src/css/components/pagination.css +1 -1
- package/src/css/components/progress.css +8 -26
- package/src/css/components/skeleton.css +7 -0
- package/src/css/components/spinner.css +6 -7
- package/src/css/components/status-bar.css +15 -24
- package/src/css/components/surface.css +2 -8
- package/src/css/components/tab.css +19 -48
- package/src/css/components/table.css +7 -5
- package/src/css/components/tooltip.css +14 -7
- package/src/css/focus.css +14 -38
- package/src/css/forms/choice-card.css +13 -14
- package/src/css/forms/choice.css +21 -27
- package/src/css/forms/control.css +18 -17
- package/src/css/forms/custom-select.css +9 -11
- package/src/css/forms/form-actions.css +14 -3
- package/src/css/forms/form.css +7 -4
- package/src/css/forms/index.css +4 -4
- package/src/css/forms/input-icon.css +30 -12
- package/src/css/forms/native.css +50 -11
- package/src/css/forms/range.css +17 -9
- package/src/css/forms/select.css +8 -8
- package/src/css/forms/switch.css +14 -12
- package/src/css/forms/validation.css +32 -5
- package/src/css/grid.css +32 -18
- package/src/css/intents.css +2 -2
- package/src/css/layout.css +45 -2
- package/src/css/optional/aura.css +93 -0
- package/src/css/optional/chat.css +105 -0
- package/src/css/optional/fab.css +104 -0
- package/src/css/optional/floating-field.css +87 -0
- package/src/css/optional/index.css +5 -0
- package/src/css/optional/layout-extra.css +7 -4
- package/src/css/optional/otp.css +115 -0
- package/src/css/optional/scroll-snap.css +6 -0
- package/src/css/optional/scroller.css +13 -7
- package/src/css/optional/typography-fluid.css +5 -0
- package/src/css/optional/utilities-extra.css +17 -2
- package/src/css/prose.css +18 -8
- package/src/css/reset.css +0 -10
- package/src/css/theme.css +21 -5
- package/src/css/themes/cyberpunk.css +1 -1
- package/src/css/themes/dim.css +3 -3
- package/src/css/themes/edge.css +8 -10
- package/src/css/themes/index.css +8 -4
- package/src/css/themes/{dark.css → indigo.css} +5 -4
- package/src/css/themes/neon.css +5 -5
- package/src/css/themes/square.css +0 -3
- package/src/css/tokens.css +74 -20
- package/src/css/utilities.css +8 -10
- package/src/css/variants.css +51 -66
- package/src/js/command.js +1 -3
- package/src/js/context-menu.js +67 -35
- package/src/js/dialog.js +68 -79
- package/src/js/dismiss.js +15 -0
- package/src/js/enhance.js +54 -23
- package/src/js/enhancement-loader.js +301 -0
- package/src/js/events.js +3 -0
- package/src/js/filter.js +3 -3
- package/src/js/floating.js +101 -131
- package/src/js/flyout.js +104 -87
- package/src/js/focus-group.js +116 -0
- package/src/js/focus.js +6 -1
- package/src/js/index.js +4 -2
- package/src/js/input.js +1 -1
- package/src/js/keys.js +27 -0
- package/src/js/mask.js +7 -6
- package/src/js/menu.js +87 -34
- package/src/js/parse-config.js +47 -0
- package/src/js/password.js +9 -1
- package/src/js/scrollspy.js +12 -4
- package/src/js/selectors.js +0 -7
- package/src/js/status.js +3 -1
- package/src/js/surface.js +187 -136
- package/src/js/tab.js +36 -53
- package/src/js/tooltip.js +70 -35
- package/src/js/validation.js +34 -8
- package/dist/actual.js.map +0 -31
- package/docs/README.md +0 -28
- package/docs/components.md +0 -860
- package/docs/design-notes/previous-work.md +0 -5
- package/docs/forms.md +0 -1265
- package/docs/javascript.md +0 -496
- package/docs/layout.md +0 -480
- package/docs/patterns.md +0 -504
- package/docs/reserved.md +0 -7
- package/docs/tokens.md +0 -455
- package/docs/ui.md +0 -929
- package/docs/utilities.md +0 -384
- package/src/css/dialog-fallback.css +0 -56
- package/src/js/dialog-fallback.js +0 -163
package/src/js/context-menu.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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() +
|
|
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
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 (!
|
|
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 (!
|
|
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 (!
|
|
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 ||
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
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
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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,
|
|
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()
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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 ===
|
|
41
|
-
node?.nodeType ===
|
|
42
|
-
node?.nodeType ===
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
},
|