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.
Files changed (129) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/dist/actual.css +4929 -0
  5. package/dist/actual.js +3 -0
  6. package/dist/actual.js.map +31 -0
  7. package/dist/actual.min.css +1 -0
  8. package/docs/README.md +26 -0
  9. package/docs/components.md +860 -0
  10. package/docs/design-notes/accessibility-decisions.md +32 -0
  11. package/docs/design-notes/browser-support.md +93 -0
  12. package/docs/design-notes/cascade-layer.md +52 -0
  13. package/docs/design-notes/css-anchor.md +33 -0
  14. package/docs/design-notes/naming.md +37 -0
  15. package/docs/design-notes/previous-work.md +5 -0
  16. package/docs/design-notes/references.md +324 -0
  17. package/docs/forms.md +1265 -0
  18. package/docs/javascript.md +426 -0
  19. package/docs/layout.md +480 -0
  20. package/docs/mental-model.md +73 -0
  21. package/docs/patterns.md +415 -0
  22. package/docs/reserved.md +7 -0
  23. package/docs/tokens.md +455 -0
  24. package/docs/typography.md +159 -0
  25. package/docs/ui.md +937 -0
  26. package/docs/utilities.md +384 -0
  27. package/llms.txt +17 -0
  28. package/package.json +101 -0
  29. package/src/css/actual.css +17 -0
  30. package/src/css/actual.full.css +2 -0
  31. package/src/css/actual.layer.css +3 -0
  32. package/src/css/base.css +70 -0
  33. package/src/css/components/accordion.css +97 -0
  34. package/src/css/components/actions.css +21 -0
  35. package/src/css/components/alert.css +137 -0
  36. package/src/css/components/avatar.css +199 -0
  37. package/src/css/components/badge.css +143 -0
  38. package/src/css/components/breadcrumb.css +56 -0
  39. package/src/css/components/busy.css +44 -0
  40. package/src/css/components/button.css +216 -0
  41. package/src/css/components/card.css +140 -0
  42. package/src/css/components/drawer.css +122 -0
  43. package/src/css/components/flyout.css +243 -0
  44. package/src/css/components/index.css +41 -0
  45. package/src/css/components/join.css +106 -0
  46. package/src/css/components/key.css +25 -0
  47. package/src/css/components/meter.css +81 -0
  48. package/src/css/components/modal.css +162 -0
  49. package/src/css/components/navbar.css +98 -0
  50. package/src/css/components/overline.css +49 -0
  51. package/src/css/components/pagination.css +30 -0
  52. package/src/css/components/progress.css +126 -0
  53. package/src/css/components/scrollspy.css +27 -0
  54. package/src/css/components/skeleton.css +67 -0
  55. package/src/css/components/spinner.css +60 -0
  56. package/src/css/components/status-bar.css +82 -0
  57. package/src/css/components/surface.css +48 -0
  58. package/src/css/components/tab.css +128 -0
  59. package/src/css/components/table.css +64 -0
  60. package/src/css/components/tooltip.css +107 -0
  61. package/src/css/dialog-fallback.css +56 -0
  62. package/src/css/focus.css +13 -0
  63. package/src/css/forms/choice-card.css +158 -0
  64. package/src/css/forms/choice.css +196 -0
  65. package/src/css/forms/control.css +106 -0
  66. package/src/css/forms/custom-select.css +93 -0
  67. package/src/css/forms/form-actions.css +43 -0
  68. package/src/css/forms/form.css +57 -0
  69. package/src/css/forms/index.css +15 -0
  70. package/src/css/forms/input-icon.css +36 -0
  71. package/src/css/forms/native.css +68 -0
  72. package/src/css/forms/select.css +36 -0
  73. package/src/css/forms/switch.css +127 -0
  74. package/src/css/forms/validation.css +63 -0
  75. package/src/css/grid.css +95 -0
  76. package/src/css/intents.css +33 -0
  77. package/src/css/layout.css +80 -0
  78. package/src/css/optional/index.css +5 -0
  79. package/src/css/optional/layout-extra.css +55 -0
  80. package/src/css/optional/scroll-snap.css +51 -0
  81. package/src/css/optional/scroller.css +51 -0
  82. package/src/css/optional/typography-fluid.css +48 -0
  83. package/src/css/optional/utilities-extra.css +289 -0
  84. package/src/css/print.css +47 -0
  85. package/src/css/prose.css +187 -0
  86. package/src/css/reset.css +56 -0
  87. package/src/css/theme.css +186 -0
  88. package/src/css/themes/corporate.css +60 -0
  89. package/src/css/themes/cyberpunk.css +60 -0
  90. package/src/css/themes/dark.css +32 -0
  91. package/src/css/themes/dim.css +33 -0
  92. package/src/css/themes/edge.css +143 -0
  93. package/src/css/themes/forest.css +65 -0
  94. package/src/css/themes/gradient.css +97 -0
  95. package/src/css/themes/index.css +43 -0
  96. package/src/css/themes/lavender.css +66 -0
  97. package/src/css/themes/mono.css +60 -0
  98. package/src/css/themes/neon.css +66 -0
  99. package/src/css/themes/ocean.css +60 -0
  100. package/src/css/themes/petrol.css +64 -0
  101. package/src/css/themes/spruce.css +64 -0
  102. package/src/css/themes/square.css +68 -0
  103. package/src/css/themes/sunset.css +66 -0
  104. package/src/css/tokens.css +122 -0
  105. package/src/css/utilities.css +162 -0
  106. package/src/css/variants.css +118 -0
  107. package/src/js/command.js +165 -0
  108. package/src/js/context-menu.js +291 -0
  109. package/src/js/dialog-fallback.js +163 -0
  110. package/src/js/dialog.js +503 -0
  111. package/src/js/enhance.js +208 -0
  112. package/src/js/events.js +22 -0
  113. package/src/js/filter.js +122 -0
  114. package/src/js/floating.js +462 -0
  115. package/src/js/flyout.js +187 -0
  116. package/src/js/focus.js +35 -0
  117. package/src/js/index.js +26 -0
  118. package/src/js/input.js +47 -0
  119. package/src/js/keys.js +30 -0
  120. package/src/js/mask.js +184 -0
  121. package/src/js/menu.js +88 -0
  122. package/src/js/password.js +98 -0
  123. package/src/js/scrollspy.js +121 -0
  124. package/src/js/selectors.js +59 -0
  125. package/src/js/status.js +158 -0
  126. package/src/js/surface.js +358 -0
  127. package/src/js/tab.js +173 -0
  128. package/src/js/tooltip.js +374 -0
  129. package/src/js/validation.js +419 -0
@@ -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
+ }