actual-css 0.9.1 → 0.10.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 (107) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/README.md +67 -45
  3. package/components.json +33 -13
  4. package/dist/actual.full.css +607 -502
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/app-ui.md +1 -1
  8. package/docs/design-notes/column-layout.md +103 -520
  9. package/docs/design-notes/forced-colors.md +12 -1
  10. package/docs/design-notes/naming.md +1 -1
  11. package/docs/design-notes/surface-reparenting.md +8 -9
  12. package/docs/navigation.json +2 -0
  13. package/docs/pages/components/accordion.md +55 -1
  14. package/docs/pages/components/alert.md +9 -15
  15. package/docs/pages/components/badge.md +6 -7
  16. package/docs/pages/components/bleed.md +42 -0
  17. package/docs/pages/components/button.md +30 -4
  18. package/docs/pages/components/card.md +21 -86
  19. package/docs/pages/components/close.md +48 -0
  20. package/docs/pages/components/data-list.md +26 -1
  21. package/docs/pages/components/dialog.md +16 -16
  22. package/docs/pages/components/drawer.md +9 -9
  23. package/docs/pages/components/navbar.md +5 -5
  24. package/docs/pages/components/spinner.md +3 -3
  25. package/docs/pages/components/table.md +3 -2
  26. package/docs/pages/enhancements/overview.md +5 -0
  27. package/docs/pages/enhancements/tooltip.md +3 -1
  28. package/docs/pages/examples/overview.md +3 -1
  29. package/docs/pages/forms/choice-cards.md +39 -0
  30. package/docs/pages/forms/combobox.md +23 -11
  31. package/docs/pages/forms/input-policy.md +59 -0
  32. package/docs/pages/forms/joined-controls.md +2 -5
  33. package/docs/pages/forms/overview.md +6 -0
  34. package/docs/pages/forms/validation.md +37 -1
  35. package/docs/pages/foundations/theming.md +64 -18
  36. package/docs/pages/foundations/tokens.md +50 -13
  37. package/docs/pages/foundations/typography.md +10 -0
  38. package/docs/pages/guides/introduction.md +17 -24
  39. package/docs/pages/guides/modular-import.md +53 -6
  40. package/docs/pages/guides/progressive-enhancement.md +9 -5
  41. package/docs/pages/guides/tailwind.md +17 -5
  42. package/docs/pages/layout/column-layout.md +92 -54
  43. package/docs/pages/layout/media.md +1 -1
  44. package/docs/pages/layout/overview.md +1 -1
  45. package/docs/pages/utilities/overview.md +1 -1
  46. package/docs/pages/utilities/sizing-wrapping.md +5 -3
  47. package/llms.txt +24 -4
  48. package/package.json +7 -6
  49. package/reserved-classes.json +5 -30
  50. package/skills/actual-css/PROJECT.md.example +65 -0
  51. package/skills/actual-css/README.md +64 -0
  52. package/skills/actual-css/SKILL.md +234 -0
  53. package/skills/actual-css/references/accessibility.md +44 -0
  54. package/skills/actual-css/references/api-and-vocabulary.md +94 -0
  55. package/skills/actual-css/references/enhancements.md +59 -0
  56. package/skills/actual-css/references/forms.md +49 -0
  57. package/skills/actual-css/references/layout-and-theming.md +82 -0
  58. package/skills/actual-css/references/review-checklist.md +51 -0
  59. package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
  60. package/src/css/components/accordion.css +44 -7
  61. package/src/css/components/alert.css +23 -43
  62. package/src/css/components/app-nav.css +9 -0
  63. package/src/css/components/badge.css +11 -39
  64. package/src/css/components/bleed.css +79 -0
  65. package/src/css/components/button.css +6 -17
  66. package/src/css/components/card.css +7 -40
  67. package/src/css/components/close.css +91 -0
  68. package/src/css/components/data-list.css +18 -0
  69. package/src/css/components/drawer.css +15 -56
  70. package/src/css/components/fab.css +10 -0
  71. package/src/css/components/flyout.css +1 -0
  72. package/src/css/components/index.css +2 -0
  73. package/src/css/components/join.css +36 -20
  74. package/src/css/components/menu.css +12 -0
  75. package/src/css/components/modal.css +10 -57
  76. package/src/css/components/navbar.css +27 -0
  77. package/src/css/core/base.css +2 -0
  78. package/src/css/core/focus.css +9 -3
  79. package/src/css/core/theme.css +25 -43
  80. package/src/css/core/tokens.css +3 -1
  81. package/src/css/core/variants.css +15 -36
  82. package/src/css/forms/choice-card.css +74 -4
  83. package/src/css/forms/choice.css +2 -3
  84. package/src/css/forms/control.css +15 -8
  85. package/src/css/forms/custom-select.css +4 -4
  86. package/src/css/forms/form.css +6 -0
  87. package/src/css/forms/native.css +4 -4
  88. package/src/css/forms/range.css +23 -8
  89. package/src/css/forms/switch.css +2 -3
  90. package/src/css/layout/app-layout.css +1 -1
  91. package/src/css/layout/column-layout.css +26 -111
  92. package/src/css/utilities/extra.css +7 -6
  93. package/src/js/dialog.js +95 -24
  94. package/src/js/enhance.js +7 -3
  95. package/src/js/escape.js +1 -0
  96. package/src/js/flyout.js +10 -1
  97. package/src/js/focus-group.js +2 -1
  98. package/src/js/keys.js +14 -0
  99. package/src/js/menu.js +2 -1
  100. package/src/js/status.js +19 -0
  101. package/src/js/surface.js +12 -8
  102. package/src/js/tab.js +2 -1
  103. package/src/js/tooltip.js +18 -1
  104. package/src/js/validation.js +36 -6
  105. package/dist/actual.css +0 -863
  106. package/dist/actual.js +0 -1
  107. package/dist/actual.min.css +0 -1
@@ -4,12 +4,12 @@
4
4
  * Docs: docs/pages/layout/column-layout.md
5
5
  *
6
6
  * Neither space-driven like .grid nor count-driven like .grid-N: this is a
7
- * coordinate system. The author places regions with .column-span-N /
8
- * .column-start-N and owns narrow-container recomposition. There is
9
- * deliberately no automatic collapse — no threshold, no hidden
10
- * .container-query behavior, no breakpoint variants. A single framework
11
- * threshold cannot be right for arbitrary per-region compositions, and a
12
- * hidden default would have to be undone rather than extended.
7
+ * coordinate system. Each direct child places itself with two numbers,
8
+ * --column-start and --column-span, and the author owns narrow-container
9
+ * recomposition. There is deliberately no automatic collapse — no threshold,
10
+ * no hidden .container-query behavior, no breakpoint variants. A single
11
+ * framework threshold cannot be right for arbitrary per-region compositions,
12
+ * and a hidden default would have to be undone rather than extended.
13
13
  *
14
14
  * See docs/design-notes/column-layout.md.
15
15
  *
@@ -21,11 +21,12 @@
21
21
  * media + flexible content -> .media
22
22
  * exact custom track templates -> --grid-columns on .grid
23
23
  *
24
- * Child contract: each direct child is placed on the canvas via its own
25
- * .column-span-N / .column-start-N.
26
- *
27
24
  * Unlike .grid-N, the track template does not read --gap, so the gap
28
25
  * utilities are exact here at every width.
26
+ *
27
+ * Public hooks:
28
+ * --column-start (the start line, 1-12; unset = auto-placed)
29
+ * --column-span (the width in canvas units, 1-12; unset = to the canvas end)
29
30
  */
30
31
  .column-layout {
31
32
  display: grid;
@@ -33,114 +34,28 @@
33
34
  grid-template-columns: repeat(12, minmax(0, 1fr));
34
35
  }
35
36
 
36
- /* Three zero-specificity tiers, applied in this order and only ever separated
37
- * by source order. ORDER IS LOAD-BEARING:
37
+ /* One rule covers the four placements:
38
+ * neither auto-placed, spanning the whole canvas
39
+ * span only auto-placed over N units, so 8 + 4 flows onto one row
40
+ * start only from line S to the canvas end (span 13 - S)
41
+ * start and span explicit; keep S + N - 1 <= 12
38
42
  *
39
- * 1. the child reset auto-placed, spanning the whole canvas
40
- * 2. .column-start-* a definite start line, running to the canvas end
41
- * 3. .column-span-* the end only, so it refines either of the above
43
+ * The start stays `auto` unless set: a definite start on every child would
44
+ * take all of them out of auto-placement, and an 8 + 4 pair would stack.
42
45
  *
43
- * Moving tier 3 above tier 2 makes every start+span pair run to column 12;
44
- * moving either above tier 1 makes every placement full-span. Both failures
45
- * are silent. tests/css-audit.test.js locks the order and
46
- * tests/browser/column-layout.test.js locks the resulting geometry.
47
- *
48
- * Zero specificity is the point: an application's own container-query rules
49
- * override placement additively, with a single-class selector and without
50
- * having to undo framework specificity first.
51
- */
52
-
53
- /* Tier 1. A child with no placement class stays a readable full-width region
54
- * instead of collapsing to one twelfth. Expressed as a span, not as
55
- * `1 / -1`: a definite start line would give every child a definite column
56
- * position and disable auto-placement, so `.column-span-8` next to
57
- * `.column-span-4` would stack instead of composing 8 + 4.
46
+ * The reset makes the two hooks inputs of this grid item rather than an
47
+ * inherited context: a nested .column-layout keeps the coordinates it was
48
+ * given for its own placement, and its children start from nothing. Zero
49
+ * specificity, so any author rule on the child wins.
58
50
  *
59
51
  * min-inline-size: 0 keeps the item box inside its track so the canvas never
60
52
  * grows past its container. It does not stop content from painting outside a
61
53
  * narrow track — that distinction is the primitive's contract, not a bug. */
62
54
  :where(.column-layout) > * {
63
- grid-column-end: span 12;
64
- min-inline-size: 0;
65
- }
66
-
67
- /* Tier 2. A definite start line, running to the end of the canvas unless a
68
- * .column-span-* refines the end. `column-start-9` alone is therefore the
69
- * last four columns rather than an implicit 21-line overflow. */
70
- :where(.column-start-1) {
71
- grid-column: 1 / -1;
72
- }
73
- :where(.column-start-2) {
74
- grid-column: 2 / -1;
75
- }
76
- :where(.column-start-3) {
77
- grid-column: 3 / -1;
78
- }
79
- :where(.column-start-4) {
80
- grid-column: 4 / -1;
81
- }
82
- :where(.column-start-5) {
83
- grid-column: 5 / -1;
84
- }
85
- :where(.column-start-6) {
86
- grid-column: 6 / -1;
87
- }
88
- :where(.column-start-7) {
89
- grid-column: 7 / -1;
90
- }
91
- :where(.column-start-8) {
92
- grid-column: 8 / -1;
93
- }
94
- :where(.column-start-9) {
95
- grid-column: 9 / -1;
96
- }
97
- :where(.column-start-10) {
98
- grid-column: 10 / -1;
99
- }
100
- :where(.column-start-11) {
101
- grid-column: 11 / -1;
102
- }
103
- :where(.column-start-12) {
104
- grid-column: 12 / -1;
105
- }
55
+ --column-start: initial;
56
+ --column-span: initial;
106
57
 
107
- /* Tier 3. The end only. Combined with a start, keep the placement inside the
108
- * canvas: S + N - 1 <= 12. Exceeding it adds an auto-sized implicit column,
109
- * which reshapes every 1fr track — the whole canvas loses its alignment, not
110
- * just the offending region. */
111
- :where(.column-span-1) {
112
- grid-column-end: span 1;
113
- }
114
- :where(.column-span-2) {
115
- grid-column-end: span 2;
116
- }
117
- :where(.column-span-3) {
118
- grid-column-end: span 3;
119
- }
120
- :where(.column-span-4) {
121
- grid-column-end: span 4;
122
- }
123
- :where(.column-span-5) {
124
- grid-column-end: span 5;
125
- }
126
- :where(.column-span-6) {
127
- grid-column-end: span 6;
128
- }
129
- :where(.column-span-7) {
130
- grid-column-end: span 7;
131
- }
132
- :where(.column-span-8) {
133
- grid-column-end: span 8;
134
- }
135
- :where(.column-span-9) {
136
- grid-column-end: span 9;
137
- }
138
- :where(.column-span-10) {
139
- grid-column-end: span 10;
140
- }
141
- :where(.column-span-11) {
142
- grid-column-end: span 11;
143
- }
144
- :where(.column-span-12) {
145
- grid-column-end: span 12;
58
+ grid-column-start: var(--column-start, auto);
59
+ grid-column-end: span var(--column-span, calc(13 - var(--column-start, 1)));
60
+ min-inline-size: 0;
146
61
  }
@@ -1,7 +1,7 @@
1
1
  /*
2
- * Optional utility helpers.
3
- *
4
- * These classes are intentionally not part of the core API.
2
+ * Extra utility helpers — explicit property/value names beside the curated
3
+ * shortcuts of base.css. Both are imported by utilities/index.css and are
4
+ * equally public (docs/design-notes/naming.md § Utilities).
5
5
  * They are escape hatches for layout nudges, not a full atomic scale.
6
6
  *
7
7
  * Core principle:
@@ -177,8 +177,9 @@
177
177
  overflow-y: auto;
178
178
  }
179
179
 
180
- /* Text */
181
- .white-space-normal {
180
+ /* Text. .text-wrap is the counterpart of base's .text-nowrap: it restores
181
+ wrapping inside a component that sets nowrap (.badge). */
182
+ .text-wrap {
182
183
  white-space: normal;
183
184
  }
184
185
 
@@ -251,7 +252,7 @@
251
252
  /* Surface. These are semantic surfaces, not arbitrary paint: each owns its
252
253
  matching ink and rebinds --heading to that ink, so it stays readable when it
253
254
  recreates a light surface inside a context that installed a dark one
254
- (.inverted). Intent ink is not inherited through a surface utility; use
255
+ (e.g. a --surface-solid band). Intent ink is not inherited through a surface utility; use
255
256
  .intent-color for that. */
256
257
  .background-surface {
257
258
  --heading: currentColor;
package/src/js/dialog.js CHANGED
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * The runtime assumes native <dialog> with show()/showModal()/close() — the
26
26
  * Minimal browser tier. requestClose() is newer than the floor and is used
27
- * when present, falling back to close() otherwise.
27
+ * when present, falling back to a cancel event before close() otherwise.
28
28
  */
29
29
 
30
30
  import { registerCommands, targetFor } from "./command.js";
@@ -48,34 +48,69 @@ function boolData(el, name) {
48
48
  return el.hasAttribute(name) && el.getAttribute(name) !== "false";
49
49
  }
50
50
 
51
- function ensureId(el, prefix) {
51
+ function setOwnedAttribute(state, el, name, value) {
52
+ const current = el.getAttribute(name);
53
+ if (current === value) return;
54
+
55
+ const previous = state.ownedAttributes.find((entry) => entry.el === el && entry.name === name);
56
+ if (previous && current !== previous.written) previous.original = current;
57
+ if (value === null) el.removeAttribute(name);
58
+ else el.setAttribute(name, value);
59
+
60
+ if (previous) previous.written = value;
61
+ else state.ownedAttributes.push({ el, name, original: current, written: value });
62
+ }
63
+
64
+ function restoreOwnedAttributes(state) {
65
+ for (const { el, name, original, written } of state.ownedAttributes) {
66
+ // An author may have changed an attribute while the dialog was connected.
67
+ if (el.getAttribute(name) !== written) continue;
68
+ if (original === null) el.removeAttribute(name);
69
+ else el.setAttribute(name, original);
70
+ }
71
+ }
72
+
73
+ function ensureId(el, prefix, state) {
52
74
  if (!el.id) {
53
75
  uid++;
54
- el.id = `${prefix}-${uid}`;
76
+ setOwnedAttribute(state, el, "id", `${prefix}-${uid}`);
55
77
  }
56
78
  return el.id;
57
79
  }
58
80
 
59
- function syncDialogSemantics(dialog, modal = isModal(dialog)) {
60
- if (modal) {
61
- dialog.setAttribute("aria-modal", "true");
62
- } else {
63
- dialog.removeAttribute("aria-modal");
64
- }
81
+ function ownsCurrentAttribute(state, el, name) {
82
+ return state.ownedAttributes.some(
83
+ (entry) => entry.el === el && entry.name === name && el.getAttribute(name) === entry.written,
84
+ );
85
+ }
65
86
 
66
- if (dialog.hasAttribute("aria-label") || dialog.hasAttribute("aria-labelledby")) {
87
+ function syncDialogSemantics(dialog, state, modal = isModal(dialog)) {
88
+ setOwnedAttribute(state, dialog, "aria-modal", modal ? "true" : null);
89
+
90
+ const ownsLabel = ownsCurrentAttribute(state, dialog, "aria-label");
91
+ const ownsLabelledby = ownsCurrentAttribute(state, dialog, "aria-labelledby");
92
+ if (dialog.hasAttribute("aria-labelledby") && !ownsLabelledby) {
93
+ if (ownsLabel) setOwnedAttribute(state, dialog, "aria-label", null);
94
+ return;
95
+ }
96
+ if (dialog.hasAttribute("aria-label") && !ownsLabel) {
97
+ if (ownsLabelledby) setOwnedAttribute(state, dialog, "aria-labelledby", null);
67
98
  return;
68
99
  }
69
100
 
70
101
  const label = dialog.getAttribute("data-title")?.trim();
71
102
  if (label) {
72
- dialog.setAttribute("aria-label", label);
103
+ if (ownsLabelledby) setOwnedAttribute(state, dialog, "aria-labelledby", null);
104
+ setOwnedAttribute(state, dialog, "aria-label", label);
73
105
  return;
74
106
  }
75
107
 
76
108
  const title = dialog.querySelector(DIALOG_TITLE_SELECTOR);
109
+ if (ownsLabel) setOwnedAttribute(state, dialog, "aria-label", null);
77
110
  if (title) {
78
- dialog.setAttribute("aria-labelledby", ensureId(title, "dialog-title"));
111
+ setOwnedAttribute(state, dialog, "aria-labelledby", ensureId(title, "dialog-title", state));
112
+ } else if (ownsLabelledby) {
113
+ setOwnedAttribute(state, dialog, "aria-labelledby", null);
79
114
  }
80
115
  }
81
116
 
@@ -262,13 +297,19 @@ export function closeDialog(dialog, returnValue = "") {
262
297
  export function requestDialogClose(dialog, returnValue = "") {
263
298
  if (!isDialogElement(dialog) || !dialog.open) return;
264
299
 
265
- if (typeof dialog.requestClose === "function") {
266
- ensureDialogWired(dialog).returnValue = returnValue;
267
- dialog.requestClose(returnValue);
268
- return;
300
+ const state = ensureDialogWired(dialog);
301
+ state.returnValue = returnValue;
302
+ state.requestingClose = true;
303
+ try {
304
+ if (typeof dialog.requestClose === "function") {
305
+ dialog.requestClose(returnValue);
306
+ } else {
307
+ // Older browsers still owe consumers the same cancel lifecycle.
308
+ dialog.dispatchEvent(new Event("cancel", { cancelable: true }));
309
+ }
310
+ } finally {
311
+ state.requestingClose = false;
269
312
  }
270
-
271
- closeDialog(dialog, returnValue);
272
313
  }
273
314
 
274
315
  export function openDialog(dialog, trigger = null, forcedModal = null) {
@@ -276,7 +317,7 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
276
317
 
277
318
  const state = ensureDialogWired(dialog);
278
319
  const modal = resolveModalMode(dialog, forcedModal);
279
- syncDialogSemantics(dialog, modal);
320
+ syncDialogSemantics(dialog, state, modal);
280
321
 
281
322
  state.closing = false;
282
323
  state.restoreFocusTo = trigger || dialog.ownerDocument.activeElement;
@@ -285,6 +326,7 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
285
326
  // while doing so; capture the position first and restore it so the page does
286
327
  // not jump to the top behind the modal.
287
328
  const viewport = dialog.ownerDocument.defaultView;
329
+ const scrollX = viewport?.scrollX ?? 0;
288
330
  const scrollY = viewport?.scrollY ?? 0;
289
331
 
290
332
  if (modal) {
@@ -295,17 +337,33 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
295
337
  state.modalOpen = false;
296
338
  }
297
339
 
298
- if (scrollY > 0) {
299
- viewport?.scrollTo(0, scrollY);
340
+ if (scrollX !== 0 || scrollY > 0) {
341
+ viewport?.scrollTo(scrollX, scrollY);
300
342
  }
301
343
 
302
344
  syncModalOpenClass(dialog.ownerDocument);
303
345
  }
304
346
 
347
+ function handleDialogPointerDown(event) {
348
+ const dialog = event.currentTarget;
349
+ const state = dialogMap.get(dialog);
350
+ if (!state) return;
351
+
352
+ // Only a press that starts on the backdrop may dismiss on click. A
353
+ // text selection started inside the dialog and released over the backdrop
354
+ // produces a click on <dialog> with outside coordinates, which must not
355
+ // close (or flash static on) the dialog.
356
+ state.backdropPress = event.target === dialog && isOutsideDialog(dialog, event);
357
+ }
358
+
305
359
  function handleDialogClick(event) {
306
360
  const dialog = event.currentTarget;
361
+ const state = dialogMap.get(dialog);
362
+ const backdropPress = state?.backdropPress === true;
363
+ if (state) state.backdropPress = false;
307
364
 
308
365
  if (event.target !== dialog || !isOutsideDialog(dialog, event)) return;
366
+ if (!backdropPress) return;
309
367
 
310
368
  // A native closedby="any" dialog owns its light dismiss; the runtime only
311
369
  // takes over when the author opted in via data-dialog-dismissible (which
@@ -338,7 +396,15 @@ function handleDialogCancel(event) {
338
396
  const dialog = event.currentTarget;
339
397
  const state = dialogMap.get(dialog);
340
398
 
341
- // Escape and close requests always close, whatever the dismissible flag:
399
+ // Older dialogs may dispatch cancel for Escape without implementing
400
+ // closedBy. Explicit requestDialogClose() remains available to close them.
401
+ const closedBy = "closedBy" in dialog ? dialog.closedBy : dialog.getAttribute("closedby");
402
+ if (closedBy === "none" && !state?.requestingClose) {
403
+ event.preventDefault();
404
+ return;
405
+ }
406
+
407
+ // Escape and close requests close regardless of the dismissible flag:
342
408
  // data-dialog-dismissible only gates light dismiss (backdrop click, handled
343
409
  // in handleDialogClick). Applications intercept via actual:dialog-cancel.
344
410
  const request = new CustomEvent(EVENTS.dialogCancel, {
@@ -395,15 +461,19 @@ function ensureDialogWired(dialog) {
395
461
  controller,
396
462
  closing: false,
397
463
  modalOpen: isModalOpen(dialog),
464
+ ownedAttributes: [],
465
+ requestingClose: false,
398
466
  restoreFocusTo: null,
399
467
  returnValue: "",
400
468
  staticTimer: null,
469
+ backdropPress: false,
401
470
  };
402
471
 
403
472
  // A dialog that is already `open` in the markup is non-modal per spec,
404
473
  // whatever data-dialog-modal says; infer semantics from its actual state.
405
- syncDialogSemantics(dialog, dialog.open ? isModalOpen(dialog) : isModal(dialog));
474
+ syncDialogSemantics(dialog, state, dialog.open ? isModalOpen(dialog) : isModal(dialog));
406
475
 
476
+ dialog.addEventListener("pointerdown", handleDialogPointerDown, { signal: controller.signal });
407
477
  dialog.addEventListener("click", handleDialogClick, { signal: controller.signal });
408
478
  dialog.addEventListener("submit", handleDialogSubmit, { signal: controller.signal });
409
479
  dialog.addEventListener("cancel", handleDialogCancel, { signal: controller.signal });
@@ -414,7 +484,7 @@ function ensureDialogWired(dialog) {
414
484
  // closedby keeps its native meaning — "any" closes on backdrop click,
415
485
  // "closerequest" on Escape, "none" disables both. Never override "none".
416
486
  if (isDismissible(dialog) && "closedBy" in dialog && dialog.closedBy === "any") {
417
- dialog.closedBy = "closerequest";
487
+ setOwnedAttribute(state, dialog, "closedby", "closerequest");
418
488
  }
419
489
 
420
490
  dialogMap.set(dialog, state);
@@ -437,6 +507,7 @@ function disconnectDialog(dialog) {
437
507
 
438
508
  state.controller.abort();
439
509
  dialog.classList.remove(CLASSES.static);
510
+ restoreOwnedAttributes(state);
440
511
  state.modalOpen = false;
441
512
  dialogMap.delete(dialog);
442
513
  wiredDialogs.delete(dialog);
package/src/js/enhance.js CHANGED
@@ -8,9 +8,13 @@
8
8
  * about final state after a batch, so a moved element (removed then
9
9
  * reinserted in the same batch) survives without a spurious disconnect.
10
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.
11
+ * Attribute changes are intentionally not observed. The owner of a handle
12
+ * calls refresh(node) after adding a behavior attribute to an
13
+ * already-connected element; for a data-enhance token, applyEnhancement()
14
+ * does it. The built-in data-* behaviors (mask, filter, context menu, …)
15
+ * keep their handle private, so for them insert the element with the
16
+ * attribute already set. Behavior attributes are setup-time contracts, not
17
+ * live enable/disable switches.
14
18
  *
15
19
  * enhancers: { [selector]: (el) => cleanup | void }
16
20
  * Returns: { refresh, disconnect }
package/src/js/escape.js CHANGED
@@ -30,6 +30,7 @@ function onDocumentEscape(event) {
30
30
  if (
31
31
  event.defaultPrevented ||
32
32
  event.key !== "Escape" ||
33
+ event.isComposing ||
33
34
  event.ctrlKey ||
34
35
  event.altKey ||
35
36
  event.shiftKey ||
package/src/js/flyout.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { registerEnhancement } from "./enhance.js";
2
2
  import { focusFirstDescendant } from "./focus.js";
3
+ import { shouldIgnoreKey } from "./keys.js";
3
4
  import { connectMenu, focusFirstMenuItem, focusLastMenuItem } from "./menu.js";
4
5
  import { closeSurface, isSurfaceOpen, openSurface, retainSurface } from "./surface.js";
5
6
 
@@ -26,6 +27,13 @@ function isMenuFlyout(panel) {
26
27
  return panel.matches("menu") || panel.classList.contains("menu");
27
28
  }
28
29
 
30
+ // ARIA-wise only [role="menu"] is a menu: <menu> carries the implicit list
31
+ // role and .menu is a visual class. aria-haspopup="menu" must follow the
32
+ // role, not the tag or class.
33
+ function isAriaMenu(panel) {
34
+ return panel.getAttribute("role") === "menu";
35
+ }
36
+
29
37
  function openFlyout(panel, trigger) {
30
38
  return openSurface(panel, {
31
39
  trigger,
@@ -57,6 +65,7 @@ function onTriggerClick(e) {
57
65
 
58
66
  function onTriggerKeydown(e) {
59
67
  const trigger = e.currentTarget;
68
+ if (shouldIgnoreKey(e)) return;
60
69
  const state = triggerMap.get(trigger);
61
70
  if (!state) return;
62
71
  const panel = resolvePanel(trigger, state);
@@ -153,7 +162,7 @@ function resolvePanel(trigger, state) {
153
162
  close: (menu) => closeSurface(menu),
154
163
  });
155
164
  }
156
- if (!trigger.hasAttribute("aria-haspopup") && isMenuFlyout(panel)) {
165
+ if (!trigger.hasAttribute("aria-haspopup") && isAriaMenu(panel)) {
157
166
  trigger.setAttribute("aria-haspopup", "menu");
158
167
  }
159
168
 
@@ -5,7 +5,7 @@
5
5
  * candidate list; the primitive does not observe the subtree.
6
6
  */
7
7
 
8
- import { itemForKey } from "./keys.js";
8
+ import { itemForKey, shouldIgnoreKey } from "./keys.js";
9
9
 
10
10
  const ORIENTATIONS = new Set(["horizontal", "vertical"]);
11
11
 
@@ -82,6 +82,7 @@ export function connectFocusGroup(root, opts = {}) {
82
82
  }
83
83
 
84
84
  function onKeydown(event) {
85
+ if (shouldIgnoreKey(event)) return;
85
86
  const items = sync();
86
87
  const current = root.ownerDocument.activeElement;
87
88
  if (!items.includes(current)) return;
package/src/js/keys.js CHANGED
@@ -5,6 +5,20 @@
5
5
  * centralize item lookup, edge handling, and optional wrapping.
6
6
  */
7
7
 
8
+ // Shared guard for roving-focus key handlers. Composition (IME) and
9
+ // ctrl/alt/meta-modified keys belong to the input method or the browser
10
+ // (Alt+Arrow = history navigation) and must never be claimed by a widget.
11
+ // Shift is deliberately excluded: Shift+Tab carries focus semantics that
12
+ // callers handle themselves.
13
+ export function shouldIgnoreKey(event) {
14
+ return (
15
+ event?.isComposing === true ||
16
+ event?.ctrlKey === true ||
17
+ event?.altKey === true ||
18
+ event?.metaKey === true
19
+ );
20
+ }
21
+
8
22
  export function firstItem(items) {
9
23
  return items[0] || null;
10
24
  }
package/src/js/menu.js CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { isElementVisible } from "./focus.js";
12
12
  import { connectFocusGroup } from "./focus-group.js";
13
- import { firstItem, itemForKey, lastItem } from "./keys.js";
13
+ import { firstItem, itemForKey, lastItem, shouldIgnoreKey } from "./keys.js";
14
14
 
15
15
  const MENU_ITEM_SELECTOR = ":scope > li > .menu-item";
16
16
  const MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
@@ -62,6 +62,7 @@ export function focusLastMenuItem(menu) {
62
62
  }
63
63
 
64
64
  export function onMenuKeydown(e, { close, navigate = true }) {
65
+ if (shouldIgnoreKey(e)) return;
65
66
  const menu = e.currentTarget;
66
67
  if (navigate) {
67
68
  const items = getMenuItems(menu);
package/src/js/status.js CHANGED
@@ -67,6 +67,21 @@ let statusTimer;
67
67
  let statusGeneration = 0;
68
68
  let viewportTracker = null;
69
69
  const statusHomes = new WeakMap();
70
+ // Reference kept only while the bar is temporarily mounted in a dialog. If
71
+ // that dialog is removed from the DOM (htmx/Turbo swap), document lookup can
72
+ // no longer find the bar — this reference lets the next status()/clear()
73
+ // restore it to its home instead of silently no-opping forever.
74
+ let mountedStatusTarget = null;
75
+
76
+ function healDetachedStatusTarget() {
77
+ if (!mountedStatusTarget || mountedStatusTarget.isConnected) return;
78
+ if (!statusHomes.has(mountedStatusTarget)) {
79
+ mountedStatusTarget = null;
80
+ return;
81
+ }
82
+ restoreStatusTarget(mountedStatusTarget);
83
+ mountedStatusTarget = null;
84
+ }
70
85
 
71
86
  function statusTarget() {
72
87
  if (typeof document === "undefined") return null;
@@ -83,6 +98,7 @@ function restoreStatusTarget(target) {
83
98
  parent.insertBefore(target, reference);
84
99
  }
85
100
  statusHomes.delete(target);
101
+ if (mountedStatusTarget === target) mountedStatusTarget = null;
86
102
  }
87
103
 
88
104
  function mountStatusTarget(target, source) {
@@ -97,6 +113,7 @@ function mountStatusTarget(target, source) {
97
113
  statusHomes.set(target, { parent: target.parentNode, nextSibling: target.nextSibling });
98
114
  }
99
115
  dialog.append(target);
116
+ mountedStatusTarget = target;
100
117
  }
101
118
 
102
119
  // The bar is fixed to the layout viewport, so the mobile software keyboard
@@ -149,6 +166,7 @@ function clearIntentClasses(target) {
149
166
  }
150
167
 
151
168
  export function status(message, options = {}) {
169
+ healDetachedStatusTarget();
152
170
  const target = statusTarget();
153
171
  if (!target || message == null) return;
154
172
 
@@ -186,6 +204,7 @@ export function status(message, options = {}) {
186
204
 
187
205
  status.clear = function clear() {
188
206
  clearTimeout(statusTimer);
207
+ healDetachedStatusTarget();
189
208
 
190
209
  const target = statusTarget();
191
210
  if (!target) {
package/src/js/surface.js CHANGED
@@ -222,7 +222,10 @@ function startSurfaceResources(menu, state) {
222
222
  }
223
223
 
224
224
  export function isSurfaceOpen(menu) {
225
- return menu.classList.contains(CLASSES.open);
225
+ // The open set is the source of truth; .is-open is only its visual
226
+ // projection. A morph touching classes must not orphan Escape entries,
227
+ // position tracking, or the open set.
228
+ return openSurfaces.has(menu);
226
229
  }
227
230
 
228
231
  export function prepareSurface(menu) {
@@ -304,8 +307,14 @@ export function openSurface(menu, opts = {}) {
304
307
  state.coordinateSpace = mode.space;
305
308
  menu.style.position = mode.position;
306
309
 
310
+ // Register before promoting: a position failure below closes the surface
311
+ // again, and that close must observe the open state to tear down.
312
+ openSurfaces.add(menu);
307
313
  // Promote before measuring: a closed popover has no box to position.
308
- if (!showTransport(menu)) return false;
314
+ if (!showTransport(menu)) {
315
+ openSurfaces.delete(menu);
316
+ return false;
317
+ }
309
318
  menu.classList.add(CLASSES.open);
310
319
  syncExpanded(menu, true);
311
320
 
@@ -335,13 +344,12 @@ export function openSurface(menu, opts = {}) {
335
344
  * from; placing focus in the new surface is the caller's job, and both
336
345
  * callers in tree do it as soon as this returns.
337
346
  */
338
- for (const other of openSurfaces) {
347
+ for (const other of [...openSurfaces]) {
339
348
  if (other !== menu && other.ownerDocument === menu.ownerDocument) {
340
349
  closeSurface(other, { restoreFocus: false });
341
350
  }
342
351
  }
343
352
 
344
- openSurfaces.add(menu);
345
353
  startSurfaceResources(menu, state);
346
354
  return true;
347
355
  }
@@ -411,7 +419,3 @@ function ensureDocumentClick(menu) {
411
419
  clickBoundDocuments.add(doc);
412
420
  doc.addEventListener("click", onDocumentClick);
413
421
  }
414
-
415
- export function getSurfaceAutoClose(menu) {
416
- return surfaceMap.get(menu)?.autoClose ?? "outside";
417
- }
package/src/js/tab.js CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { registerEnhancement } from "./enhance.js";
20
- import { itemForKey } from "./keys.js";
20
+ import { itemForKey, shouldIgnoreKey } from "./keys.js";
21
21
 
22
22
  const TABLIST_SELECTOR = '[data-enhance~="tabs"]';
23
23
 
@@ -100,6 +100,7 @@ function initialize(list) {
100
100
 
101
101
  function onKeydown(e) {
102
102
  const list = e.currentTarget;
103
+ if (shouldIgnoreKey(e)) return;
103
104
  const tab = e.target.closest('[role="tab"]');
104
105
  if (!tab || tab.closest(TABLIST_SELECTOR) !== list) return;
105
106