actual-css 0.1.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.
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/actual.css +4929 -0
- package/dist/actual.js +3 -0
- package/dist/actual.js.map +31 -0
- package/dist/actual.min.css +1 -0
- package/docs/README.md +26 -0
- package/docs/components.md +860 -0
- package/docs/design-notes/accessibility-decisions.md +32 -0
- package/docs/design-notes/browser-support.md +93 -0
- package/docs/design-notes/cascade-layer.md +52 -0
- package/docs/design-notes/css-anchor.md +33 -0
- package/docs/design-notes/naming.md +37 -0
- package/docs/design-notes/previous-work.md +5 -0
- package/docs/design-notes/references.md +324 -0
- package/docs/forms.md +1265 -0
- package/docs/javascript.md +426 -0
- package/docs/layout.md +480 -0
- package/docs/mental-model.md +73 -0
- package/docs/patterns.md +415 -0
- package/docs/reserved.md +7 -0
- package/docs/tokens.md +455 -0
- package/docs/typography.md +159 -0
- package/docs/ui.md +937 -0
- package/docs/utilities.md +384 -0
- package/llms.txt +17 -0
- package/package.json +101 -0
- package/src/css/actual.css +17 -0
- package/src/css/actual.full.css +2 -0
- package/src/css/actual.layer.css +3 -0
- package/src/css/base.css +70 -0
- package/src/css/components/accordion.css +97 -0
- package/src/css/components/actions.css +21 -0
- package/src/css/components/alert.css +137 -0
- package/src/css/components/avatar.css +199 -0
- package/src/css/components/badge.css +143 -0
- package/src/css/components/breadcrumb.css +56 -0
- package/src/css/components/busy.css +44 -0
- package/src/css/components/button.css +216 -0
- package/src/css/components/card.css +140 -0
- package/src/css/components/drawer.css +122 -0
- package/src/css/components/flyout.css +243 -0
- package/src/css/components/index.css +41 -0
- package/src/css/components/join.css +106 -0
- package/src/css/components/key.css +25 -0
- package/src/css/components/meter.css +81 -0
- package/src/css/components/modal.css +162 -0
- package/src/css/components/navbar.css +98 -0
- package/src/css/components/overline.css +49 -0
- package/src/css/components/pagination.css +30 -0
- package/src/css/components/progress.css +126 -0
- package/src/css/components/scrollspy.css +27 -0
- package/src/css/components/skeleton.css +67 -0
- package/src/css/components/spinner.css +60 -0
- package/src/css/components/status-bar.css +82 -0
- package/src/css/components/surface.css +48 -0
- package/src/css/components/tab.css +128 -0
- package/src/css/components/table.css +64 -0
- package/src/css/components/tooltip.css +107 -0
- package/src/css/dialog-fallback.css +56 -0
- package/src/css/focus.css +13 -0
- package/src/css/forms/choice-card.css +158 -0
- package/src/css/forms/choice.css +196 -0
- package/src/css/forms/control.css +106 -0
- package/src/css/forms/custom-select.css +93 -0
- package/src/css/forms/form-actions.css +43 -0
- package/src/css/forms/form.css +57 -0
- package/src/css/forms/index.css +15 -0
- package/src/css/forms/input-icon.css +36 -0
- package/src/css/forms/native.css +68 -0
- package/src/css/forms/select.css +36 -0
- package/src/css/forms/switch.css +127 -0
- package/src/css/forms/validation.css +63 -0
- package/src/css/grid.css +95 -0
- package/src/css/intents.css +33 -0
- package/src/css/layout.css +80 -0
- package/src/css/optional/index.css +5 -0
- package/src/css/optional/layout-extra.css +55 -0
- package/src/css/optional/scroll-snap.css +51 -0
- package/src/css/optional/scroller.css +51 -0
- package/src/css/optional/typography-fluid.css +48 -0
- package/src/css/optional/utilities-extra.css +289 -0
- package/src/css/print.css +47 -0
- package/src/css/prose.css +187 -0
- package/src/css/reset.css +56 -0
- package/src/css/theme.css +186 -0
- package/src/css/themes/corporate.css +60 -0
- package/src/css/themes/cyberpunk.css +60 -0
- package/src/css/themes/dark.css +32 -0
- package/src/css/themes/dim.css +33 -0
- package/src/css/themes/edge.css +143 -0
- package/src/css/themes/forest.css +65 -0
- package/src/css/themes/gradient.css +97 -0
- package/src/css/themes/index.css +43 -0
- package/src/css/themes/lavender.css +66 -0
- package/src/css/themes/mono.css +60 -0
- package/src/css/themes/neon.css +66 -0
- package/src/css/themes/ocean.css +60 -0
- package/src/css/themes/petrol.css +64 -0
- package/src/css/themes/spruce.css +64 -0
- package/src/css/themes/square.css +68 -0
- package/src/css/themes/sunset.css +66 -0
- package/src/css/tokens.css +122 -0
- package/src/css/utilities.css +162 -0
- package/src/css/variants.css +118 -0
- package/src/js/command.js +165 -0
- package/src/js/context-menu.js +291 -0
- package/src/js/dialog-fallback.js +163 -0
- package/src/js/dialog.js +503 -0
- package/src/js/enhance.js +208 -0
- package/src/js/events.js +22 -0
- package/src/js/filter.js +122 -0
- package/src/js/floating.js +462 -0
- package/src/js/flyout.js +187 -0
- package/src/js/focus.js +35 -0
- package/src/js/index.js +26 -0
- package/src/js/input.js +47 -0
- package/src/js/keys.js +30 -0
- package/src/js/mask.js +184 -0
- package/src/js/menu.js +88 -0
- package/src/js/password.js +98 -0
- package/src/js/scrollspy.js +121 -0
- package/src/js/selectors.js +59 -0
- package/src/js/status.js +158 -0
- package/src/js/surface.js +358 -0
- package/src/js/tab.js +173 -0
- package/src/js/tooltip.js +374 -0
- package/src/js/validation.js +419 -0
package/src/js/dialog.js
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Dialog — thin declarative layer over native <dialog>.
|
|
3
|
+
*
|
|
4
|
+
* Trigger: <button commandfor="dialog-id" command="show-modal">
|
|
5
|
+
* Close button: <button commandfor="dialog-id" command="request-close">
|
|
6
|
+
* Dialog: <dialog id="dialog-id" data-dialog-dismissible>
|
|
7
|
+
*
|
|
8
|
+
* Command semantics are explicit: show-modal calls showModal(), show calls
|
|
9
|
+
* show(). data-dialog-modal still defines the default for direct openDialog()
|
|
10
|
+
* calls when no command mode is forced.
|
|
11
|
+
* Dismissible means backdrop click/light dismiss is allowed.
|
|
12
|
+
*
|
|
13
|
+
* View transitions (opt-in via data-dialog-view-transition):
|
|
14
|
+
* When the browser supports document.startViewTransition and the user allows
|
|
15
|
+
* motion, the dialog morphs to/from its trigger using a shared
|
|
16
|
+
* view-transition-name. Otherwise native dialog behavior remains the
|
|
17
|
+
* baseline: the dialog simply opens and closes.
|
|
18
|
+
*
|
|
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.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { registerCommands, targetFor } from "./command.js";
|
|
26
|
+
import enhance from "./enhance.js";
|
|
27
|
+
import { EVENTS } from "./events.js";
|
|
28
|
+
import { CLASSES } from "./selectors.js";
|
|
29
|
+
|
|
30
|
+
const dialogMap = new WeakMap();
|
|
31
|
+
const wiredDialogs = new Set();
|
|
32
|
+
const DIALOG_COMMANDS = ["show-modal", "show", "request-close", "close"];
|
|
33
|
+
const DIALOG_SELECTOR = "dialog";
|
|
34
|
+
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
|
+
let uid = 0;
|
|
38
|
+
|
|
39
|
+
function supportsDialog() {
|
|
40
|
+
return (
|
|
41
|
+
typeof HTMLDialogElement !== "undefined" &&
|
|
42
|
+
typeof HTMLDialogElement.prototype.showModal === "function"
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
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);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function boolData(el, name) {
|
|
67
|
+
return el.hasAttribute(name) && el.getAttribute(name) !== "false";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureId(el, prefix) {
|
|
71
|
+
if (!el.id) {
|
|
72
|
+
uid++;
|
|
73
|
+
el.id = `${prefix}-${uid}`;
|
|
74
|
+
}
|
|
75
|
+
return el.id;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function syncDialogSemantics(dialog, modal = isModal(dialog)) {
|
|
79
|
+
if (modal) {
|
|
80
|
+
dialog.setAttribute("aria-modal", "true");
|
|
81
|
+
} else {
|
|
82
|
+
dialog.removeAttribute("aria-modal");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (dialog.hasAttribute("aria-label") || dialog.hasAttribute("aria-labelledby")) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const label = dialog.getAttribute("data-title")?.trim();
|
|
90
|
+
if (label) {
|
|
91
|
+
dialog.setAttribute("aria-label", label);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const title = dialog.querySelector(DIALOG_TITLE_SELECTOR);
|
|
96
|
+
if (title) {
|
|
97
|
+
dialog.setAttribute("aria-labelledby", ensureId(title, "dialog-title"));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
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
|
+
function isModal(dialog) {
|
|
109
|
+
return dialog.getAttribute("data-dialog-modal") !== "false";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveModalMode(dialog, forcedModal = null) {
|
|
113
|
+
return forcedModal == null ? isModal(dialog) : forcedModal;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isModalOpen(dialog) {
|
|
117
|
+
if (!dialog.open) return false;
|
|
118
|
+
// :modal distinguishes a top-layer showModal() dialog from one opened via
|
|
119
|
+
// show() or a bare `open` attribute in the initial HTML (never modal per
|
|
120
|
+
// spec, whatever data-dialog-modal says).
|
|
121
|
+
try {
|
|
122
|
+
return dialog.matches(":modal");
|
|
123
|
+
} catch {
|
|
124
|
+
// Selector unsupported: the only way a dialog is open before we wire it
|
|
125
|
+
// is the `open` attribute, which is non-modal.
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function syncModalOpenClass(doc = document) {
|
|
131
|
+
let hasOpenModal = false;
|
|
132
|
+
|
|
133
|
+
for (const dialog of wiredDialogs) {
|
|
134
|
+
const state = dialogMap.get(dialog);
|
|
135
|
+
if (
|
|
136
|
+
dialog.ownerDocument === doc &&
|
|
137
|
+
dialog.isConnected &&
|
|
138
|
+
dialog.open &&
|
|
139
|
+
state?.modalOpen === true
|
|
140
|
+
) {
|
|
141
|
+
hasOpenModal = true;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const root = doc.documentElement;
|
|
147
|
+
const wasOpen = root.classList.contains(CLASSES.modalOpen);
|
|
148
|
+
|
|
149
|
+
if (hasOpenModal && !wasOpen) {
|
|
150
|
+
const viewportWidth = doc.defaultView?.innerWidth;
|
|
151
|
+
root.classList.toggle(
|
|
152
|
+
CLASSES.hadScrollbar,
|
|
153
|
+
Number.isFinite(viewportWidth) && viewportWidth > root.clientWidth,
|
|
154
|
+
);
|
|
155
|
+
} else if (!hasOpenModal) {
|
|
156
|
+
root.classList.remove(CLASSES.hadScrollbar);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
root.classList.toggle(CLASSES.modalOpen, hasOpenModal);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isDismissible(dialog) {
|
|
163
|
+
return boolData(dialog, "data-dialog-dismissible");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function wantsViewTransition(dialog) {
|
|
167
|
+
return boolData(dialog, "data-dialog-view-transition");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const VT_NAME = "actual-dialog";
|
|
171
|
+
|
|
172
|
+
function supportsViewTransitions(doc = document) {
|
|
173
|
+
return typeof doc.startViewTransition === "function";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function motionAllowed(doc = document) {
|
|
177
|
+
return !doc.defaultView?.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function setVtName(target, name) {
|
|
181
|
+
if (target) target.style.viewTransitionName = name;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function clearVtName(...targets) {
|
|
185
|
+
for (const target of targets) {
|
|
186
|
+
if (target) target.style.viewTransitionName = "";
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function canViewTransition(dialog, trigger) {
|
|
191
|
+
const doc = dialog.ownerDocument;
|
|
192
|
+
return (
|
|
193
|
+
wantsViewTransition(dialog) &&
|
|
194
|
+
supportsViewTransitions(doc) &&
|
|
195
|
+
motionAllowed(doc) &&
|
|
196
|
+
trigger?.isConnected === true
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function openDialogWithViewTransition(dialog, trigger, forcedModal = null) {
|
|
201
|
+
if (!canViewTransition(dialog, trigger)) {
|
|
202
|
+
openDialog(dialog, trigger, forcedModal);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
setVtName(trigger, VT_NAME);
|
|
207
|
+
|
|
208
|
+
const transition = dialog.ownerDocument.startViewTransition(() => {
|
|
209
|
+
clearVtName(trigger);
|
|
210
|
+
setVtName(dialog, VT_NAME);
|
|
211
|
+
openDialog(dialog, trigger, forcedModal);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
transition.finished.finally(() => clearVtName(dialog, trigger)).catch(() => {});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isOutsideDialog(dialog, event) {
|
|
218
|
+
const rect = dialog.getBoundingClientRect();
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
event.clientX < rect.left ||
|
|
222
|
+
event.clientX > rect.right ||
|
|
223
|
+
event.clientY < rect.top ||
|
|
224
|
+
event.clientY > rect.bottom
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function flashStatic(dialog) {
|
|
229
|
+
const state = ensureDialogWired(dialog);
|
|
230
|
+
|
|
231
|
+
if (state.staticTimer) {
|
|
232
|
+
window.clearTimeout(state.staticTimer);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
dialog.classList.remove(CLASSES.static);
|
|
236
|
+
void dialog.offsetWidth;
|
|
237
|
+
dialog.classList.add(CLASSES.static);
|
|
238
|
+
|
|
239
|
+
state.staticTimer = window.setTimeout(() => {
|
|
240
|
+
dialog.classList.remove(CLASSES.static);
|
|
241
|
+
state.staticTimer = null;
|
|
242
|
+
}, 250);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function finishClose(dialog, returnValue = "") {
|
|
246
|
+
const state = dialogMap.get(dialog);
|
|
247
|
+
|
|
248
|
+
if (state) {
|
|
249
|
+
state.closing = false;
|
|
250
|
+
state.returnValue = "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (dialog.open) {
|
|
254
|
+
dialog.close(returnValue);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function closeDialog(dialog, returnValue = "") {
|
|
259
|
+
if (!isDialog(dialog) || !dialog.open) return;
|
|
260
|
+
|
|
261
|
+
const state = ensureDialogWired(dialog);
|
|
262
|
+
if (state.closing) return;
|
|
263
|
+
|
|
264
|
+
const trigger = state.restoreFocusTo;
|
|
265
|
+
if (canViewTransition(dialog, trigger)) {
|
|
266
|
+
state.closing = true;
|
|
267
|
+
setVtName(dialog, VT_NAME);
|
|
268
|
+
|
|
269
|
+
const transition = dialog.ownerDocument.startViewTransition(() => {
|
|
270
|
+
clearVtName(dialog);
|
|
271
|
+
setVtName(trigger, VT_NAME);
|
|
272
|
+
dialog.close(returnValue);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
transition.finished
|
|
276
|
+
.finally(() => {
|
|
277
|
+
state.closing = false;
|
|
278
|
+
clearVtName(trigger);
|
|
279
|
+
})
|
|
280
|
+
.catch(() => {});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
finishClose(dialog, returnValue);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function requestDialogClose(dialog, returnValue = "") {
|
|
288
|
+
if (!isDialog(dialog) || !dialog.open) return;
|
|
289
|
+
|
|
290
|
+
if (typeof dialog.requestClose === "function") {
|
|
291
|
+
ensureDialogWired(dialog).returnValue = returnValue;
|
|
292
|
+
dialog.requestClose(returnValue);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
closeDialog(dialog, returnValue);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function openDialog(dialog, trigger = null, forcedModal = null) {
|
|
300
|
+
if (!isDialog(dialog) || dialog.open || !dialog.isConnected) return;
|
|
301
|
+
|
|
302
|
+
const state = ensureDialogWired(dialog);
|
|
303
|
+
const modal = resolveModalMode(dialog, forcedModal);
|
|
304
|
+
syncDialogSemantics(dialog, modal);
|
|
305
|
+
|
|
306
|
+
state.closing = false;
|
|
307
|
+
state.restoreFocusTo = trigger || document.activeElement;
|
|
308
|
+
|
|
309
|
+
if (modal) {
|
|
310
|
+
dialog.showModal();
|
|
311
|
+
state.modalOpen = true;
|
|
312
|
+
} else {
|
|
313
|
+
dialog.show();
|
|
314
|
+
state.modalOpen = false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
syncModalOpenClass(dialog.ownerDocument);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function handleDialogClick(event) {
|
|
321
|
+
const dialog = event.currentTarget;
|
|
322
|
+
|
|
323
|
+
if (event.target !== dialog || !isOutsideDialog(dialog, event)) return;
|
|
324
|
+
|
|
325
|
+
event.preventDefault();
|
|
326
|
+
|
|
327
|
+
if (isDismissible(dialog)) {
|
|
328
|
+
requestDialogClose(dialog);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
flashStatic(dialog);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function handleDialogSubmit(event) {
|
|
336
|
+
const dialog = event.currentTarget;
|
|
337
|
+
const form = event.target;
|
|
338
|
+
|
|
339
|
+
if (!(form instanceof HTMLFormElement)) return;
|
|
340
|
+
if (form.method.toLowerCase() !== "dialog") return;
|
|
341
|
+
if (!canViewTransition(dialog, dialogMap.get(dialog)?.restoreFocusTo)) return;
|
|
342
|
+
|
|
343
|
+
event.preventDefault();
|
|
344
|
+
closeDialog(dialog, event.submitter?.value || "");
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function handleDialogCancel(event) {
|
|
348
|
+
const dialog = event.currentTarget;
|
|
349
|
+
|
|
350
|
+
if (!isDismissible(dialog)) {
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
flashStatic(dialog);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const request = new CustomEvent(EVENTS.dialogCancel, {
|
|
357
|
+
bubbles: true,
|
|
358
|
+
cancelable: true,
|
|
359
|
+
detail: { dialog, sourceEvent: event },
|
|
360
|
+
});
|
|
361
|
+
if (!dialog.dispatchEvent(request)) {
|
|
362
|
+
event.preventDefault();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
closeDialog(dialog, dialogMap.get(dialog)?.returnValue || "");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function handleDialogClose(event) {
|
|
371
|
+
const dialog = event.currentTarget;
|
|
372
|
+
const state = dialogMap.get(dialog);
|
|
373
|
+
|
|
374
|
+
clearVtName(dialog);
|
|
375
|
+
|
|
376
|
+
if (!state) return;
|
|
377
|
+
|
|
378
|
+
if (state.staticTimer) {
|
|
379
|
+
window.clearTimeout(state.staticTimer);
|
|
380
|
+
state.staticTimer = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
dialog.classList.remove(CLASSES.static);
|
|
384
|
+
state.closing = false;
|
|
385
|
+
state.returnValue = "";
|
|
386
|
+
state.modalOpen = false;
|
|
387
|
+
syncModalOpenClass(dialog.ownerDocument);
|
|
388
|
+
|
|
389
|
+
const restoreFocusTo = state.restoreFocusTo;
|
|
390
|
+
state.restoreFocusTo = null;
|
|
391
|
+
|
|
392
|
+
if (restoreFocusTo?.isConnected) {
|
|
393
|
+
restoreFocusTo.focus();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function ensureDialogWired(dialog) {
|
|
398
|
+
if (dialogMap.has(dialog)) return dialogMap.get(dialog);
|
|
399
|
+
|
|
400
|
+
const controller = new AbortController();
|
|
401
|
+
const state = {
|
|
402
|
+
controller,
|
|
403
|
+
closing: false,
|
|
404
|
+
modalOpen: isModalOpen(dialog),
|
|
405
|
+
restoreFocusTo: null,
|
|
406
|
+
returnValue: "",
|
|
407
|
+
staticTimer: null,
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
syncDialogSemantics(dialog);
|
|
411
|
+
|
|
412
|
+
dialog.addEventListener("click", handleDialogClick, { signal: controller.signal });
|
|
413
|
+
dialog.addEventListener("submit", handleDialogSubmit, { signal: controller.signal });
|
|
414
|
+
dialog.addEventListener("cancel", handleDialogCancel, { signal: controller.signal });
|
|
415
|
+
dialog.addEventListener("close", handleDialogClose, { signal: controller.signal });
|
|
416
|
+
|
|
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.
|
|
422
|
+
dialog.closedBy = "closerequest";
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
dialogMap.set(dialog, state);
|
|
426
|
+
wiredDialogs.add(dialog);
|
|
427
|
+
syncModalOpenClass(dialog.ownerDocument);
|
|
428
|
+
return state;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function connectDialog(dialog) {
|
|
432
|
+
ensureDialogWired(dialog);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function disconnectDialog(dialog) {
|
|
436
|
+
const state = dialogMap.get(dialog);
|
|
437
|
+
if (!state) return;
|
|
438
|
+
|
|
439
|
+
if (state.staticTimer) {
|
|
440
|
+
window.clearTimeout(state.staticTimer);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
state.controller.abort();
|
|
444
|
+
dialog.classList.remove(CLASSES.static);
|
|
445
|
+
state.modalOpen = false;
|
|
446
|
+
dialogMap.delete(dialog);
|
|
447
|
+
wiredDialogs.delete(dialog);
|
|
448
|
+
syncModalOpenClass(dialog.ownerDocument);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
registerCommands(DIALOG_COMMANDS, {
|
|
452
|
+
resolve: (trigger) => {
|
|
453
|
+
const dialog = targetFor(trigger);
|
|
454
|
+
return isDialogElement(dialog) ? dialog : null;
|
|
455
|
+
},
|
|
456
|
+
prepare: (trigger, dialog, command) => {
|
|
457
|
+
if (isDialog(dialog)) {
|
|
458
|
+
ensureDialogWired(dialog);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
trigger.setAttribute("aria-controls", dialog.id);
|
|
462
|
+
|
|
463
|
+
if (
|
|
464
|
+
!trigger.hasAttribute("aria-haspopup") &&
|
|
465
|
+
(command === "show-modal" || command === "show")
|
|
466
|
+
) {
|
|
467
|
+
trigger.setAttribute("aria-haspopup", "dialog");
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
handle: (event, trigger, dialog, command) => {
|
|
471
|
+
event.preventDefault();
|
|
472
|
+
|
|
473
|
+
if (!isDialog(dialog)) {
|
|
474
|
+
if (command === "show-modal" || command === "show") {
|
|
475
|
+
notifyUnsupportedDialog(trigger);
|
|
476
|
+
}
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (command === "show-modal" || command === "show") {
|
|
481
|
+
openDialogWithViewTransition(dialog, trigger, command === "show-modal");
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (command === "request-close") {
|
|
486
|
+
requestDialogClose(dialog, trigger.value || "");
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (command === "close") {
|
|
491
|
+
closeDialog(dialog, trigger.value || "");
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
if (supportsDialog()) {
|
|
497
|
+
enhance({
|
|
498
|
+
[DIALOG_SELECTOR]: (dialog) => {
|
|
499
|
+
connectDialog(dialog);
|
|
500
|
+
return () => disconnectDialog(dialog);
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Enhance — DOM lifecycle engine for behavioral enhancers.
|
|
3
|
+
*
|
|
4
|
+
* One MutationObserver per root watches DOM insertions/removals. Multiple calls
|
|
5
|
+
* to enhance() share that observer while keeping separate teardown handles.
|
|
6
|
+
*
|
|
7
|
+
* Sweep (not removedNodes scanning) is the key design choice: it reasons
|
|
8
|
+
* about final state after a batch, so a moved element (removed then
|
|
9
|
+
* reinserted in the same batch) survives without a spurious disconnect.
|
|
10
|
+
*
|
|
11
|
+
* Attribute changes are intentionally not observed. Call refresh(node) after
|
|
12
|
+
* adding a behavior attribute to an already-connected element. Behavior
|
|
13
|
+
* attributes are setup-time contracts, not live enable/disable switches.
|
|
14
|
+
*
|
|
15
|
+
* enhancers: { [selector]: (el) => cleanup | void }
|
|
16
|
+
* Returns: { refresh, forget, disconnect }
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const registries = new WeakMap();
|
|
20
|
+
|
|
21
|
+
function noopRuntime() {
|
|
22
|
+
return {
|
|
23
|
+
refresh() {},
|
|
24
|
+
forget() {},
|
|
25
|
+
disconnect() {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function canScan(node) {
|
|
30
|
+
return (
|
|
31
|
+
node?.nodeType === Node.ELEMENT_NODE ||
|
|
32
|
+
node?.nodeType === Node.DOCUMENT_NODE ||
|
|
33
|
+
node?.nodeType === Node.DOCUMENT_FRAGMENT_NODE
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createRegistry(root) {
|
|
38
|
+
const records = new Set();
|
|
39
|
+
let selectorString = "";
|
|
40
|
+
|
|
41
|
+
function isValidSelector(selector) {
|
|
42
|
+
try {
|
|
43
|
+
root.querySelector?.(selector);
|
|
44
|
+
if (root instanceof Element) root.matches(selector);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`Invalid enhancer selector "${selector}"`, error);
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function updateSelectorString() {
|
|
53
|
+
selectorString = [...records].flatMap((record) => record.selectors).join(",");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function start(record, el) {
|
|
57
|
+
if (!(el instanceof Element)) return;
|
|
58
|
+
let active = record.instances.get(el);
|
|
59
|
+
|
|
60
|
+
for (const selector of record.selectors) {
|
|
61
|
+
if (!el.matches(selector)) continue;
|
|
62
|
+
if (active?.has(selector)) continue;
|
|
63
|
+
|
|
64
|
+
let cleanup;
|
|
65
|
+
try {
|
|
66
|
+
cleanup = record.enhancers[selector](el);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`Enhancer failed for selector "${selector}"`, error);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (!active) {
|
|
72
|
+
active = new Map();
|
|
73
|
+
record.instances.set(el, active);
|
|
74
|
+
}
|
|
75
|
+
active.set(selector, typeof cleanup === "function" ? cleanup : null);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stop(record, el) {
|
|
80
|
+
const active = record.instances.get(el);
|
|
81
|
+
if (!active) return;
|
|
82
|
+
for (const cleanup of active.values()) {
|
|
83
|
+
cleanup?.();
|
|
84
|
+
}
|
|
85
|
+
record.instances.delete(el);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function scanFor(record, node) {
|
|
89
|
+
if (!canScan(node)) return;
|
|
90
|
+
if (node instanceof Element) start(record, node);
|
|
91
|
+
if (record.selectorString) {
|
|
92
|
+
node.querySelectorAll?.(record.selectorString).forEach((el) => {
|
|
93
|
+
start(record, el);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function scan(node) {
|
|
99
|
+
if (!canScan(node) || !selectorString) return;
|
|
100
|
+
if (node instanceof Element) {
|
|
101
|
+
for (const record of records) start(record, node);
|
|
102
|
+
}
|
|
103
|
+
node.querySelectorAll?.(selectorString).forEach((el) => {
|
|
104
|
+
for (const record of records) start(record, el);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sweepDisconnected() {
|
|
109
|
+
for (const record of records) {
|
|
110
|
+
for (const el of Array.from(record.instances.keys())) {
|
|
111
|
+
if (!el.isConnected) stop(record, el);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const observer = new MutationObserver((mutationRecords) => {
|
|
117
|
+
let hasRemoval = false;
|
|
118
|
+
for (const mutationRecord of mutationRecords) {
|
|
119
|
+
for (const node of mutationRecord.addedNodes) {
|
|
120
|
+
scan(node);
|
|
121
|
+
}
|
|
122
|
+
if (mutationRecord.removedNodes.length > 0) {
|
|
123
|
+
hasRemoval = true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (hasRemoval) sweepDisconnected();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
observer.observe(root, { childList: true, subtree: true });
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
add(enhancers) {
|
|
133
|
+
const selectors = Object.keys(enhancers).filter(isValidSelector);
|
|
134
|
+
if (!selectors.length) return null;
|
|
135
|
+
const validEnhancers = Object.fromEntries(
|
|
136
|
+
selectors.map((selector) => [selector, enhancers[selector]]),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const record = {
|
|
140
|
+
disconnected: false,
|
|
141
|
+
enhancers: validEnhancers,
|
|
142
|
+
instances: new Map(),
|
|
143
|
+
selectors,
|
|
144
|
+
selectorString: selectors.join(","),
|
|
145
|
+
};
|
|
146
|
+
records.add(record);
|
|
147
|
+
updateSelectorString();
|
|
148
|
+
scanFor(record, root);
|
|
149
|
+
return record;
|
|
150
|
+
},
|
|
151
|
+
refresh(record, node) {
|
|
152
|
+
if (record.disconnected) return;
|
|
153
|
+
scanFor(record, node);
|
|
154
|
+
},
|
|
155
|
+
forget(record, el) {
|
|
156
|
+
if (record.disconnected) return;
|
|
157
|
+
record.instances.delete(el);
|
|
158
|
+
},
|
|
159
|
+
remove(record) {
|
|
160
|
+
if (record.disconnected) return;
|
|
161
|
+
record.disconnected = true;
|
|
162
|
+
for (const el of Array.from(record.instances.keys())) {
|
|
163
|
+
stop(record, el);
|
|
164
|
+
}
|
|
165
|
+
records.delete(record);
|
|
166
|
+
updateSelectorString();
|
|
167
|
+
if (records.size === 0) {
|
|
168
|
+
observer.disconnect();
|
|
169
|
+
registries.delete(root);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function registryFor(root) {
|
|
176
|
+
let registry = registries.get(root);
|
|
177
|
+
if (!registry) {
|
|
178
|
+
registry = createRegistry(root);
|
|
179
|
+
registries.set(root, registry);
|
|
180
|
+
}
|
|
181
|
+
return registry;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {Record<string, (el: Element) => (() => void) | void>} enhancers
|
|
186
|
+
* @param {Document | Element | DocumentFragment} [root]
|
|
187
|
+
* @returns {{ refresh: (node: Node) => void, forget: (el: Element) => void, disconnect: () => void }}
|
|
188
|
+
*/
|
|
189
|
+
export default function enhance(enhancers, root) {
|
|
190
|
+
if (typeof document === "undefined") {
|
|
191
|
+
return noopRuntime();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
root ??= document.documentElement;
|
|
195
|
+
const registry = registryFor(root);
|
|
196
|
+
const record = registry.add(enhancers);
|
|
197
|
+
if (!record) return noopRuntime();
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
refresh: (node) => registry.refresh(record, node),
|
|
201
|
+
forget: (el) => {
|
|
202
|
+
registry.forget(record, el);
|
|
203
|
+
},
|
|
204
|
+
disconnect: () => {
|
|
205
|
+
registry.remove(record);
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|