actual-css 0.9.2 → 0.11.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 (136) hide show
  1. package/CHANGELOG.md +169 -1
  2. package/README.md +65 -44
  3. package/components.json +35 -14
  4. package/dist/actual.full.css +877 -583
  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/enhancement-contract.md +16 -7
  10. package/docs/design-notes/forced-colors.md +12 -1
  11. package/docs/design-notes/naming.md +1 -1
  12. package/docs/design-notes/platform-alignment.md +8 -4
  13. package/docs/design-notes/rich-text.md +61 -0
  14. package/docs/design-notes/steps.md +2 -2
  15. package/docs/design-notes/surface-reparenting.md +8 -9
  16. package/docs/navigation.json +4 -0
  17. package/docs/pages/components/accordion.md +55 -1
  18. package/docs/pages/components/alert.md +9 -15
  19. package/docs/pages/components/aura.md +12 -13
  20. package/docs/pages/components/badge.md +16 -7
  21. package/docs/pages/components/bleed.md +42 -0
  22. package/docs/pages/components/button.md +30 -4
  23. package/docs/pages/components/card.md +21 -86
  24. package/docs/pages/components/chat.md +5 -0
  25. package/docs/pages/components/close.md +48 -0
  26. package/docs/pages/components/data-list.md +26 -1
  27. package/docs/pages/components/dialog.md +21 -18
  28. package/docs/pages/components/drawer.md +9 -9
  29. package/docs/pages/components/navbar.md +5 -5
  30. package/docs/pages/components/pagination.md +15 -15
  31. package/docs/pages/components/shimmer.md +57 -0
  32. package/docs/pages/components/skeleton.md +2 -1
  33. package/docs/pages/components/spinner.md +14 -9
  34. package/docs/pages/components/table.md +13 -0
  35. package/docs/pages/enhancements/flyout.md +2 -0
  36. package/docs/pages/enhancements/overview.md +5 -0
  37. package/docs/pages/enhancements/tabs.md +4 -0
  38. package/docs/pages/enhancements/tooltip.md +3 -1
  39. package/docs/pages/examples/overview.md +2 -1
  40. package/docs/pages/forms/choice-cards.md +116 -4
  41. package/docs/pages/forms/combobox.md +39 -12
  42. package/docs/pages/forms/joined-controls.md +2 -5
  43. package/docs/pages/forms/overview.md +10 -1
  44. package/docs/pages/forms/range.md +110 -5
  45. package/docs/pages/forms/rich-text.md +110 -0
  46. package/docs/pages/forms/validation.md +5 -1
  47. package/docs/pages/foundations/theming.md +110 -18
  48. package/docs/pages/foundations/tokens.md +41 -13
  49. package/docs/pages/guides/bootstrap.md +2 -1
  50. package/docs/pages/guides/introduction.md +15 -23
  51. package/docs/pages/guides/modular-import.md +54 -7
  52. package/docs/pages/guides/progressive-enhancement.md +19 -17
  53. package/docs/pages/guides/tailwind.md +21 -7
  54. package/docs/pages/layout/column-layout.md +92 -54
  55. package/docs/pages/layout/media.md +1 -1
  56. package/docs/pages/layout/overview.md +1 -1
  57. package/docs/pages/utilities/overview.md +1 -1
  58. package/docs/pages/utilities/sizing-wrapping.md +5 -3
  59. package/llms.txt +25 -4
  60. package/package.json +10 -6
  61. package/reserved-classes.json +6 -30
  62. package/skills/actual-css/PROJECT.md.example +65 -0
  63. package/skills/actual-css/README.md +64 -0
  64. package/skills/actual-css/SKILL.md +234 -0
  65. package/skills/actual-css/references/accessibility.md +44 -0
  66. package/skills/actual-css/references/api-and-vocabulary.md +94 -0
  67. package/skills/actual-css/references/enhancements.md +59 -0
  68. package/skills/actual-css/references/forms.md +49 -0
  69. package/skills/actual-css/references/layout-and-theming.md +82 -0
  70. package/skills/actual-css/references/review-checklist.md +51 -0
  71. package/skills/actual-css/scripts/inspect-actual-css.mjs +171 -0
  72. package/src/css/components/accordion.css +44 -7
  73. package/src/css/components/alert.css +23 -43
  74. package/src/css/components/app-nav.css +9 -0
  75. package/src/css/components/avatar.css +1 -1
  76. package/src/css/components/badge.css +11 -39
  77. package/src/css/components/bleed.css +79 -0
  78. package/src/css/components/button.css +6 -17
  79. package/src/css/components/card.css +7 -40
  80. package/src/css/components/close.css +91 -0
  81. package/src/css/components/data-list.css +18 -0
  82. package/src/css/components/drawer.css +15 -56
  83. package/src/css/components/fab.css +10 -0
  84. package/src/css/components/flyout.css +5 -1
  85. package/src/css/components/index.css +2 -0
  86. package/src/css/components/join.css +36 -20
  87. package/src/css/components/menu.css +12 -0
  88. package/src/css/components/meter.css +1 -1
  89. package/src/css/components/modal.css +10 -57
  90. package/src/css/components/navbar.css +27 -0
  91. package/src/css/components/pagination.css +3 -2
  92. package/src/css/components/rating.css +1 -1
  93. package/src/css/components/status-bar.css +2 -2
  94. package/src/css/components/steps.css +3 -3
  95. package/src/css/components/tab.css +3 -3
  96. package/src/css/components/table.css +6 -0
  97. package/src/css/components/tooltip.css +1 -1
  98. package/src/css/core/focus.css +19 -3
  99. package/src/css/core/theme.css +33 -43
  100. package/src/css/core/tokens.css +9 -5
  101. package/src/css/core/variants.css +5 -34
  102. package/src/css/effects/aura.css +51 -33
  103. package/src/css/effects/index.css +1 -0
  104. package/src/css/effects/shimmer.css +57 -0
  105. package/src/css/forms/choice-card.css +142 -24
  106. package/src/css/forms/choice.css +2 -3
  107. package/src/css/forms/control.css +17 -10
  108. package/src/css/forms/custom-select.css +4 -4
  109. package/src/css/forms/native.css +4 -4
  110. package/src/css/forms/otp.css +9 -1
  111. package/src/css/forms/range.css +72 -14
  112. package/src/css/forms/switch.css +2 -3
  113. package/src/css/layout/app-layout.css +1 -1
  114. package/src/css/layout/column-layout.css +26 -111
  115. package/src/css/utilities/extra.css +7 -6
  116. package/src/js/command.js +61 -32
  117. package/src/js/dialog.js +103 -47
  118. package/src/js/dismiss.js +1 -2
  119. package/src/js/enhance.js +91 -126
  120. package/src/js/escape.js +1 -0
  121. package/src/js/flyout.js +10 -1
  122. package/src/js/focus-group.js +2 -1
  123. package/src/js/full.js +1 -0
  124. package/src/js/keys.js +14 -0
  125. package/src/js/menu.js +2 -1
  126. package/src/js/password.js +1 -8
  127. package/src/js/range.js +151 -0
  128. package/src/js/scrollspy.js +4 -5
  129. package/src/js/status.js +21 -10
  130. package/src/js/surface.js +12 -8
  131. package/src/js/tab.js +2 -1
  132. package/src/js/tooltip.js +29 -2
  133. package/src/js/validation.js +8 -15
  134. package/dist/actual.css +0 -876
  135. package/dist/actual.js +0 -1
  136. 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/command.js CHANGED
@@ -7,6 +7,12 @@
7
7
  * late targets, changed commandfor values, and same-id replacements therefore
8
8
  * work immediately without an observer, scan, refresh call, or per-element
9
9
  * listener.
10
+ *
11
+ * A routed command cancels the click, so the browser never dispatches the
12
+ * native `command` event to the target, and the router does not re-dispatch
13
+ * one. Listen to the behavior's own events instead. Accessibility state that
14
+ * must exist before interaction (`aria-controls`, `aria-haspopup`,
15
+ * `aria-pressed`) belongs in the markup: the router has no connection step.
10
16
  */
11
17
 
12
18
  /* One registry per document owns its delegated listener and command handlers.
@@ -14,18 +20,45 @@
14
20
  * behavior definitions only, never DOM triggers or resolved targets. */
15
21
  const registries = new WeakMap();
16
22
 
17
- function commandNames(commands, caller) {
18
- const names = Array.isArray(commands) ? commands : [commands];
19
- if (!names.length || names.some((name) => typeof name !== "string" || !name)) {
20
- throw new TypeError(`${caller} requires one or more command names.`);
21
- }
22
- return [...new Set(names)];
23
+ // The HTML `command` keywords. Any other value that does not start with "--"
24
+ // is the invalid state, which the browser ignores. Extend this list when HTML
25
+ // adds a keyword: registerCommands() throws on an unknown non-"--" name, so a
26
+ // stale list fails loudly instead of silently swallowing the new keyword.
27
+ // A future path that listens to the native `command` event instead of the
28
+ // click would need a per-tier matrix (native support varies) plus
29
+ // no-double-execution tests — the click stays the single routing event until
30
+ // then, which is why a routed command cancels it by design (see above).
31
+ const NATIVE_COMMANDS = new Set([
32
+ "toggle-popover",
33
+ "show-popover",
34
+ "hide-popover",
35
+ "close",
36
+ "request-close",
37
+ "show-modal",
38
+ ]);
39
+
40
+ function isCustom(command) {
41
+ return command.startsWith("--");
23
42
  }
24
43
 
25
44
  // Native command keywords are ASCII case-insensitive. Custom commands keep
26
45
  // their exact spelling, as required by the command invoker contract.
27
46
  function commandKey(command) {
28
- return command.startsWith("--") ? command : command.toLowerCase();
47
+ return isCustom(command) ? command : command.toLowerCase();
48
+ }
49
+
50
+ function commandNames(commands, caller) {
51
+ const names = Array.isArray(commands) ? commands : [commands];
52
+ if (!names.length || names.some((name) => typeof name !== "string")) {
53
+ throw new TypeError(`${caller} requires one or more command names.`);
54
+ }
55
+ const invalid = names.find((name) => !isCustom(name) && !NATIVE_COMMANDS.has(commandKey(name)));
56
+ if (invalid !== undefined) {
57
+ throw new TypeError(
58
+ `${caller}: "${invalid}" is neither a native command nor a custom "--" command.`,
59
+ );
60
+ }
61
+ return [...new Set(names.map(commandKey))];
29
62
  }
30
63
 
31
64
  /**
@@ -46,23 +79,25 @@ export function targetFor(trigger) {
46
79
  /**
47
80
  * Build the button selector corresponding to one or more command names.
48
81
  *
82
+ * Native keywords match case-insensitively, custom commands exactly — the
83
+ * same rule the router applies.
84
+ *
49
85
  * @param {string | string[]} commands Command name or names to include.
50
86
  * @returns {string} A selector matching command buttons with `commandfor`.
51
- * @throws {TypeError} When no valid command name is provided.
87
+ * @throws {TypeError} When a name is neither native nor a custom `--` command.
52
88
  */
53
89
  export function commandSelector(commands) {
54
- const attributes = commandNames(commands, "commandSelector()").map(
55
- (name) => `[command="${name.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"]`,
56
- );
90
+ const attributes = commandNames(commands, "commandSelector()").map((name) => {
91
+ const value = name.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
92
+ return `[command="${value}"${isCustom(name) ? "" : " i"}]`;
93
+ });
57
94
  return `button[commandfor]:is(${attributes.join(", ")})`;
58
95
  }
59
96
 
60
97
  function triggerFromEvent(event, doc) {
61
- const ElementClass = doc.defaultView?.Element;
62
- if (!ElementClass) return null;
63
-
64
98
  for (const node of event.composedPath?.() ?? [event.target]) {
65
- if (node instanceof ElementClass && node.matches("button[commandfor][command]")) {
99
+ // nodeType rather than instanceof: nodes from another realm still count.
100
+ if (node.nodeType === 1 && node.matches("button[commandfor][command]")) {
66
101
  return node;
67
102
  }
68
103
  if (node === doc) break;
@@ -78,7 +113,7 @@ function createRegistry(doc) {
78
113
  if (event.defaultPrevented) return;
79
114
 
80
115
  const trigger = triggerFromEvent(event, doc);
81
- if (!trigger || trigger.disabled) return;
116
+ if (!trigger || trigger.matches(":disabled")) return;
82
117
 
83
118
  const command = commandKey(trigger.getAttribute("command"));
84
119
  const registration = commands.get(command);
@@ -87,7 +122,10 @@ function createRegistry(doc) {
87
122
  const target = registration.resolve(trigger);
88
123
  if (!target) return;
89
124
 
90
- registration.prepare?.(trigger, target, command);
125
+ // One routed command owns the click, even when the handler throws. This
126
+ // is also what stops a second copy of this module, with its own registry
127
+ // and listener, from handling the same activation.
128
+ event.preventDefault();
91
129
  registration.handle(event, trigger, target, command);
92
130
  }
93
131
 
@@ -98,31 +136,22 @@ function createRegistry(doc) {
98
136
  /**
99
137
  * Register one behavior for one or more command names.
100
138
  *
101
- * `prepare`, when present, runs immediately before `handle` on every matching
102
- * action. It must be idempotent; it is useful for semantics derived from the
103
- * resolved target, without introducing a connection lifecycle.
139
+ * The router cancels the click once `resolve` returns a target, so `handle`
140
+ * runs with `event.defaultPrevented` already true.
104
141
  *
105
142
  * @param {string | string[]} commands Command name or names owned by this behavior.
106
143
  * @param {object} options Behavior callbacks.
107
144
  * @param {(trigger: HTMLButtonElement) => Element | null} [options.resolve=targetFor]
108
145
  * Resolver evaluated for every action.
109
- * @param {(trigger: HTMLButtonElement, target: Element, command: string) => void} [options.prepare]
110
- * Optional idempotent callback run immediately before the handler.
111
146
  * @param {(event: MouseEvent, trigger: HTMLButtonElement, target: Element, command: string) => void} options.handle
112
147
  * Command handler.
113
148
  * @returns {{ disconnect: () => void }} An idempotent registration teardown handle.
114
149
  * @throws {TypeError} When command names or callbacks are invalid.
115
150
  * @throws {Error} When a command already has an owner in the current document.
116
151
  */
117
- export function registerCommands(commands, { resolve = targetFor, prepare, handle } = {}) {
118
- const names = [
119
- ...new Set(commandNames(commands, "registerCommands()").map((name) => commandKey(name))),
120
- ];
121
- if (
122
- typeof resolve !== "function" ||
123
- (prepare !== undefined && typeof prepare !== "function") ||
124
- typeof handle !== "function"
125
- ) {
152
+ export function registerCommands(commands, { resolve = targetFor, handle } = {}) {
153
+ const names = commandNames(commands, "registerCommands()");
154
+ if (typeof resolve !== "function" || typeof handle !== "function") {
126
155
  throw new TypeError("registerCommands() requires function callbacks.");
127
156
  }
128
157
  if (typeof document === "undefined") return { disconnect() {} };
@@ -139,7 +168,7 @@ export function registerCommands(commands, { resolve = targetFor, prepare, handl
139
168
  throw new Error(`Command "${conflict}" is already registered in this document.`);
140
169
  }
141
170
 
142
- const registration = { resolve, prepare, handle };
171
+ const registration = { resolve, handle };
143
172
  for (const name of names) registry.commands.set(name, registration);
144
173
 
145
174
  let connected = true;
package/src/js/dialog.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * Close button: <button commandfor="dialog-id" command="request-close">
6
6
  * Dialog: <dialog id="dialog-id" data-dialog-dismissible>
7
7
  *
8
- * Command semantics are explicit: show-modal calls showModal(), show calls
8
+ * Command semantics are explicit: show-modal calls showModal(), --show calls
9
9
  * show(). data-dialog-modal still defines the default for direct openDialog()
10
10
  * calls when no command mode is forced.
11
11
  * data-dialog-dismissible gates backdrop click only: with it, the runtime
@@ -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";
@@ -34,7 +34,9 @@ import { CLASSES } from "./selectors.js";
34
34
 
35
35
  const dialogMap = new WeakMap();
36
36
  const wiredDialogs = new Set();
37
- const DIALOG_COMMANDS = ["show-modal", "show", "request-close", "close"];
37
+ // HTML has no non-modal dialog keyword, so non-modal opening is the custom
38
+ // `--show` command.
39
+ const DIALOG_COMMANDS = ["show-modal", "--show", "request-close", "close"];
38
40
  const DIALOG_SELECTOR = "dialog";
39
41
  const DIALOG_TITLE_SELECTOR = "[data-title], h1, h2, h3, h4, h5, h6";
40
42
  const STATIC_SHAKE_MS = 250;
@@ -48,34 +50,69 @@ function boolData(el, name) {
48
50
  return el.hasAttribute(name) && el.getAttribute(name) !== "false";
49
51
  }
50
52
 
51
- function ensureId(el, prefix) {
53
+ function setOwnedAttribute(state, el, name, value) {
54
+ const current = el.getAttribute(name);
55
+ if (current === value) return;
56
+
57
+ const previous = state.ownedAttributes.find((entry) => entry.el === el && entry.name === name);
58
+ if (previous && current !== previous.written) previous.original = current;
59
+ if (value === null) el.removeAttribute(name);
60
+ else el.setAttribute(name, value);
61
+
62
+ if (previous) previous.written = value;
63
+ else state.ownedAttributes.push({ el, name, original: current, written: value });
64
+ }
65
+
66
+ function restoreOwnedAttributes(state) {
67
+ for (const { el, name, original, written } of state.ownedAttributes) {
68
+ // An author may have changed an attribute while the dialog was connected.
69
+ if (el.getAttribute(name) !== written) continue;
70
+ if (original === null) el.removeAttribute(name);
71
+ else el.setAttribute(name, original);
72
+ }
73
+ }
74
+
75
+ function ensureId(el, prefix, state) {
52
76
  if (!el.id) {
53
77
  uid++;
54
- el.id = `${prefix}-${uid}`;
78
+ setOwnedAttribute(state, el, "id", `${prefix}-${uid}`);
55
79
  }
56
80
  return el.id;
57
81
  }
58
82
 
59
- function syncDialogSemantics(dialog, modal = isModal(dialog)) {
60
- if (modal) {
61
- dialog.setAttribute("aria-modal", "true");
62
- } else {
63
- dialog.removeAttribute("aria-modal");
64
- }
83
+ function ownsCurrentAttribute(state, el, name) {
84
+ return state.ownedAttributes.some(
85
+ (entry) => entry.el === el && entry.name === name && el.getAttribute(name) === entry.written,
86
+ );
87
+ }
88
+
89
+ function syncDialogSemantics(dialog, state, modal = isModal(dialog)) {
90
+ setOwnedAttribute(state, dialog, "aria-modal", modal ? "true" : null);
65
91
 
66
- if (dialog.hasAttribute("aria-label") || dialog.hasAttribute("aria-labelledby")) {
92
+ const ownsLabel = ownsCurrentAttribute(state, dialog, "aria-label");
93
+ const ownsLabelledby = ownsCurrentAttribute(state, dialog, "aria-labelledby");
94
+ if (dialog.hasAttribute("aria-labelledby") && !ownsLabelledby) {
95
+ if (ownsLabel) setOwnedAttribute(state, dialog, "aria-label", null);
96
+ return;
97
+ }
98
+ if (dialog.hasAttribute("aria-label") && !ownsLabel) {
99
+ if (ownsLabelledby) setOwnedAttribute(state, dialog, "aria-labelledby", null);
67
100
  return;
68
101
  }
69
102
 
70
103
  const label = dialog.getAttribute("data-title")?.trim();
71
104
  if (label) {
72
- dialog.setAttribute("aria-label", label);
105
+ if (ownsLabelledby) setOwnedAttribute(state, dialog, "aria-labelledby", null);
106
+ setOwnedAttribute(state, dialog, "aria-label", label);
73
107
  return;
74
108
  }
75
109
 
76
110
  const title = dialog.querySelector(DIALOG_TITLE_SELECTOR);
111
+ if (ownsLabel) setOwnedAttribute(state, dialog, "aria-label", null);
77
112
  if (title) {
78
- dialog.setAttribute("aria-labelledby", ensureId(title, "dialog-title"));
113
+ setOwnedAttribute(state, dialog, "aria-labelledby", ensureId(title, "dialog-title", state));
114
+ } else if (ownsLabelledby) {
115
+ setOwnedAttribute(state, dialog, "aria-labelledby", null);
79
116
  }
80
117
  }
81
118
 
@@ -262,13 +299,19 @@ export function closeDialog(dialog, returnValue = "") {
262
299
  export function requestDialogClose(dialog, returnValue = "") {
263
300
  if (!isDialogElement(dialog) || !dialog.open) return;
264
301
 
265
- if (typeof dialog.requestClose === "function") {
266
- ensureDialogWired(dialog).returnValue = returnValue;
267
- dialog.requestClose(returnValue);
268
- return;
302
+ const state = ensureDialogWired(dialog);
303
+ state.returnValue = returnValue;
304
+ state.requestingClose = true;
305
+ try {
306
+ if (typeof dialog.requestClose === "function") {
307
+ dialog.requestClose(returnValue);
308
+ } else {
309
+ // Older browsers still owe consumers the same cancel lifecycle.
310
+ dialog.dispatchEvent(new Event("cancel", { cancelable: true }));
311
+ }
312
+ } finally {
313
+ state.requestingClose = false;
269
314
  }
270
-
271
- closeDialog(dialog, returnValue);
272
315
  }
273
316
 
274
317
  export function openDialog(dialog, trigger = null, forcedModal = null) {
@@ -276,7 +319,7 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
276
319
 
277
320
  const state = ensureDialogWired(dialog);
278
321
  const modal = resolveModalMode(dialog, forcedModal);
279
- syncDialogSemantics(dialog, modal);
322
+ syncDialogSemantics(dialog, state, modal);
280
323
 
281
324
  state.closing = false;
282
325
  state.restoreFocusTo = trigger || dialog.ownerDocument.activeElement;
@@ -285,6 +328,7 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
285
328
  // while doing so; capture the position first and restore it so the page does
286
329
  // not jump to the top behind the modal.
287
330
  const viewport = dialog.ownerDocument.defaultView;
331
+ const scrollX = viewport?.scrollX ?? 0;
288
332
  const scrollY = viewport?.scrollY ?? 0;
289
333
 
290
334
  if (modal) {
@@ -295,17 +339,33 @@ export function openDialog(dialog, trigger = null, forcedModal = null) {
295
339
  state.modalOpen = false;
296
340
  }
297
341
 
298
- if (scrollY > 0) {
299
- viewport?.scrollTo(0, scrollY);
342
+ if (scrollX !== 0 || scrollY > 0) {
343
+ viewport?.scrollTo(scrollX, scrollY);
300
344
  }
301
345
 
302
346
  syncModalOpenClass(dialog.ownerDocument);
303
347
  }
304
348
 
349
+ function handleDialogPointerDown(event) {
350
+ const dialog = event.currentTarget;
351
+ const state = dialogMap.get(dialog);
352
+ if (!state) return;
353
+
354
+ // Only a press that starts on the backdrop may dismiss on click. A
355
+ // text selection started inside the dialog and released over the backdrop
356
+ // produces a click on <dialog> with outside coordinates, which must not
357
+ // close (or flash static on) the dialog.
358
+ state.backdropPress = event.target === dialog && isOutsideDialog(dialog, event);
359
+ }
360
+
305
361
  function handleDialogClick(event) {
306
362
  const dialog = event.currentTarget;
363
+ const state = dialogMap.get(dialog);
364
+ const backdropPress = state?.backdropPress === true;
365
+ if (state) state.backdropPress = false;
307
366
 
308
367
  if (event.target !== dialog || !isOutsideDialog(dialog, event)) return;
368
+ if (!backdropPress) return;
309
369
 
310
370
  // A native closedby="any" dialog owns its light dismiss; the runtime only
311
371
  // takes over when the author opted in via data-dialog-dismissible (which
@@ -338,7 +398,15 @@ function handleDialogCancel(event) {
338
398
  const dialog = event.currentTarget;
339
399
  const state = dialogMap.get(dialog);
340
400
 
341
- // Escape and close requests always close, whatever the dismissible flag:
401
+ // Older dialogs may dispatch cancel for Escape without implementing
402
+ // closedBy. Explicit requestDialogClose() remains available to close them.
403
+ const closedBy = "closedBy" in dialog ? dialog.closedBy : dialog.getAttribute("closedby");
404
+ if (closedBy === "none" && !state?.requestingClose) {
405
+ event.preventDefault();
406
+ return;
407
+ }
408
+
409
+ // Escape and close requests close regardless of the dismissible flag:
342
410
  // data-dialog-dismissible only gates light dismiss (backdrop click, handled
343
411
  // in handleDialogClick). Applications intercept via actual:dialog-cancel.
344
412
  const request = new CustomEvent(EVENTS.dialogCancel, {
@@ -395,15 +463,19 @@ function ensureDialogWired(dialog) {
395
463
  controller,
396
464
  closing: false,
397
465
  modalOpen: isModalOpen(dialog),
466
+ ownedAttributes: [],
467
+ requestingClose: false,
398
468
  restoreFocusTo: null,
399
469
  returnValue: "",
400
470
  staticTimer: null,
471
+ backdropPress: false,
401
472
  };
402
473
 
403
474
  // A dialog that is already `open` in the markup is non-modal per spec,
404
475
  // whatever data-dialog-modal says; infer semantics from its actual state.
405
- syncDialogSemantics(dialog, dialog.open ? isModalOpen(dialog) : isModal(dialog));
476
+ syncDialogSemantics(dialog, state, dialog.open ? isModalOpen(dialog) : isModal(dialog));
406
477
 
478
+ dialog.addEventListener("pointerdown", handleDialogPointerDown, { signal: controller.signal });
407
479
  dialog.addEventListener("click", handleDialogClick, { signal: controller.signal });
408
480
  dialog.addEventListener("submit", handleDialogSubmit, { signal: controller.signal });
409
481
  dialog.addEventListener("cancel", handleDialogCancel, { signal: controller.signal });
@@ -414,7 +486,7 @@ function ensureDialogWired(dialog) {
414
486
  // closedby keeps its native meaning — "any" closes on backdrop click,
415
487
  // "closerequest" on Escape, "none" disables both. Never override "none".
416
488
  if (isDismissible(dialog) && "closedBy" in dialog && dialog.closedBy === "any") {
417
- dialog.closedBy = "closerequest";
489
+ setOwnedAttribute(state, dialog, "closedby", "closerequest");
418
490
  }
419
491
 
420
492
  dialogMap.set(dialog, state);
@@ -437,6 +509,7 @@ function disconnectDialog(dialog) {
437
509
 
438
510
  state.controller.abort();
439
511
  dialog.classList.remove(CLASSES.static);
512
+ restoreOwnedAttributes(state);
440
513
  state.modalOpen = false;
441
514
  dialogMap.delete(dialog);
442
515
  wiredDialogs.delete(dialog);
@@ -448,28 +521,11 @@ registerCommands(DIALOG_COMMANDS, {
448
521
  const dialog = targetFor(trigger);
449
522
  return isDialogElement(dialog) ? dialog : null;
450
523
  },
451
- prepare: (trigger, dialog, command) => {
452
- if (isDialogElement(dialog)) {
453
- ensureDialogWired(dialog);
454
- }
455
-
456
- trigger.setAttribute("aria-controls", dialog.id);
457
-
458
- if (
459
- !trigger.hasAttribute("aria-haspopup") &&
460
- (command === "show-modal" || command === "show")
461
- ) {
462
- trigger.setAttribute("aria-haspopup", "dialog");
463
- }
464
- },
465
- handle: (event, trigger, dialog, command) => {
466
- event.preventDefault();
467
-
468
- if (!isDialogElement(dialog)) {
469
- return;
470
- }
524
+ handle: (_event, trigger, dialog, command) => {
525
+ // A dialog inserted since the last enhance() pass is wired on first use.
526
+ ensureDialogWired(dialog);
471
527
 
472
- if (command === "show-modal" || command === "show") {
528
+ if (command === "show-modal" || command === "--show") {
473
529
  openDialogWithViewTransition(dialog, trigger, command === "show-modal");
474
530
  return;
475
531
  }
package/src/js/dismiss.js CHANGED
@@ -7,8 +7,7 @@ import { registerCommands } from "./command.js";
7
7
  import { EVENTS } from "./events.js";
8
8
 
9
9
  registerCommands("--dismiss", {
10
- handle(event, trigger, target) {
11
- event.preventDefault();
10
+ handle(_event, trigger, target) {
12
11
  target.hidden = true;
13
12
  target.dispatchEvent(
14
13
  new CustomEvent(EVENTS.dismiss, {