actual-css 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/components.json +2 -0
  3. package/dist/actual.css +10 -3
  4. package/dist/actual.full.css +238 -71
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/dist/actual.min.css +1 -1
  8. package/docs/pages/foundations/tokens.md +1 -1
  9. package/docs/pages/guides/progressive-enhancement.md +12 -8
  10. package/package.json +159 -159
  11. package/src/css/components/accordion.css +2 -0
  12. package/src/css/components/actions.css +2 -0
  13. package/src/css/components/alert.css +2 -0
  14. package/src/css/components/app-nav.css +2 -0
  15. package/src/css/components/avatar.css +2 -0
  16. package/src/css/components/badge.css +2 -0
  17. package/src/css/components/breadcrumb.css +2 -0
  18. package/src/css/components/busy.css +2 -0
  19. package/src/css/components/button.css +2 -0
  20. package/src/css/components/card.css +2 -0
  21. package/src/css/components/chat.css +2 -0
  22. package/src/css/components/data-list.css +2 -0
  23. package/src/css/components/drawer.css +35 -22
  24. package/src/css/components/fab.css +2 -0
  25. package/src/css/components/flyout.css +2 -0
  26. package/src/css/components/indicator.css +2 -0
  27. package/src/css/components/join.css +2 -0
  28. package/src/css/components/key.css +2 -0
  29. package/src/css/components/list.css +2 -0
  30. package/src/css/components/meter.css +2 -0
  31. package/src/css/components/modal.css +47 -40
  32. package/src/css/components/navbar.css +2 -0
  33. package/src/css/components/pagination.css +2 -0
  34. package/src/css/components/progress.css +2 -0
  35. package/src/css/components/rating.css +2 -0
  36. package/src/css/components/scrollspy.css +2 -0
  37. package/src/css/components/skeleton.css +2 -0
  38. package/src/css/components/spinner.css +2 -0
  39. package/src/css/components/status-bar.css +2 -0
  40. package/src/css/components/steps.css +2 -0
  41. package/src/css/components/tab.css +2 -0
  42. package/src/css/components/table.css +2 -0
  43. package/src/css/components/tooltip.css +2 -0
  44. package/src/css/core/print.css +3 -0
  45. package/src/css/core/variants.css +7 -3
  46. package/src/css/effects/aura.css +2 -0
  47. package/src/css/forms/choice-card.css +4 -0
  48. package/src/css/forms/control.css +2 -0
  49. package/src/css/forms/floating-field.css +5 -1
  50. package/src/css/forms/form-actions.css +2 -0
  51. package/src/css/forms/form.css +2 -0
  52. package/src/css/forms/input-icon.css +2 -0
  53. package/src/css/forms/native.css +2 -0
  54. package/src/css/forms/otp.css +10 -0
  55. package/src/css/forms/range.css +2 -0
  56. package/src/css/forms/select.css +2 -0
  57. package/src/css/forms/switch.css +2 -0
  58. package/src/css/forms/validation.css +2 -0
  59. package/src/css/layout/app-layout.css +2 -0
  60. package/src/css/layout/app-shell.css +5 -1
  61. package/src/css/layout/center.css +2 -0
  62. package/src/css/layout/cluster.css +2 -0
  63. package/src/css/layout/column-layout.css +2 -0
  64. package/src/css/layout/frame.css +2 -0
  65. package/src/css/layout/grid.css +2 -0
  66. package/src/css/layout/media.css +2 -1
  67. package/src/css/layout/scroll-snap.css +2 -0
  68. package/src/css/layout/scroll-target.css +2 -0
  69. package/src/css/layout/sidebar-layout.css +2 -0
  70. package/src/css/layout/stack.css +2 -1
  71. package/src/css/layout/switcher.css +2 -0
  72. package/src/css/layout/topbar.css +2 -0
  73. package/src/css/typography/fluid.css +2 -0
  74. package/src/css/typography/lead.css +2 -1
  75. package/src/css/typography/overline.css +2 -0
  76. package/src/css/typography/prose.css +2 -0
  77. package/src/css/utilities/base.css +6 -1
  78. package/src/js/context-menu.js +56 -15
  79. package/src/js/enhancement-loader.js +5 -1
  80. package/src/js/escape.js +11 -1
  81. package/src/js/flyout.js +6 -1
  82. package/src/js/focus.js +8 -3
  83. package/src/js/surface.js +28 -0
  84. package/src/js/tooltip.js +10 -4
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Text controls — input, textarea, select.
3
3
  *
4
+ * Docs: docs/pages/forms/overview.md
5
+ *
4
6
  * Neutral geometry and interaction states shared across all text-like
5
7
  * controls. select shares this base recipe; its chevron/appearance layer
6
8
  * lives in select.css. checkbox, radio, range, switch, and file have their
@@ -2,6 +2,8 @@
2
2
  * Floating labels — optional composition over the text controls
3
3
  * (.input, .textarea, .select) and .field-label.
4
4
  *
5
+ * Docs: docs/pages/forms/floating-field.md
6
+ *
5
7
  * The cell owns a control plus its label; the label overlays the control and
6
8
  * floats to the top when the control is focused or non-empty. Help and error
7
9
  * messages stay in the surrounding .field, not inside the cell, so the
@@ -43,7 +45,9 @@
43
45
 
44
46
  .floating-field > .field-label {
45
47
  position: absolute;
46
- inset-inline-start: calc(var(--space-30) + var(--border-width));
48
+ /* Same start edge as the control's text: --control-pad-x owns that shared
49
+ geometry, so .sm/.lg keep label and value aligned. */
50
+ inset-inline-start: calc(var(--control-pad-x) + var(--border-width));
47
51
  color: var(--text-muted);
48
52
  font-size: var(--control-font-size);
49
53
  font-weight: var(--font-weight-normal);
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Form actions — bottom form bar with sticky variant.
3
3
  *
4
+ * Docs: docs/pages/forms/form-actions.md
5
+ *
4
6
  * A flex cluster of buttons/actions that closes a form. The sticky variant
5
7
  * docks to the viewport bottom with a translucent blur where supported; at
6
8
  * rest it stays visually neutral so it does not double as a separator.
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Form layout and supporting text.
3
3
  *
4
+ * Docs: docs/pages/forms/overview.md
5
+ *
4
6
  * Individual controls keep their own files; .choice label layout lives in
5
7
  * choice.css; .form-actions lives in form-actions.css.
6
8
  *
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Input Icon — decorative icon inside a relative wrapper, leading or trailing.
3
3
  *
4
+ * Docs: docs/pages/patterns/icon-input.md
5
+ *
4
6
  * Icon-library agnostic: detects position by DOM order. First child that is
5
7
  * not .input = leading icon; icon after .input = trailing. The icon follows
6
8
  * local typography; density contexts shrink the control geometry around it
@@ -3,6 +3,8 @@
3
3
  * custom styling, so the framework stays out of the way and just keeps them
4
4
  * readable and theme-aware.
5
5
  *
6
+ * Docs: docs/pages/forms/controls.md
7
+ *
6
8
  * Range has its own custom-styled file (range.css) so the focus ring can
7
9
  * target the thumb pseudo-element rather than the rectangular CSS box.
8
10
  */
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * One-time code input with visual cells and one native input.
3
3
  *
4
+ * Docs: docs/pages/forms/otp.md
5
+ *
4
6
  * Add one direct span per accepted character. The cells determine the
5
7
  * component width; the overlaid input remains the only interactive control.
6
8
  *
@@ -129,4 +131,12 @@
129
131
  .needs-validation.was-validated .otp > input:invalid ~ span {
130
132
  --otp-cell-border: Mark;
131
133
  }
134
+
135
+ /* biome-ignore lint/style/noDescendingSpecificity: the :user-invalid route
136
+ mirrors the non-forced-colors block above and must come last — the three
137
+ invalid markers write the same value, so source order is inert. An engine
138
+ without the pseudo drops this rule alone. */
139
+ .needs-validation .otp > input:user-invalid ~ span {
140
+ --otp-cell-border: Mark;
141
+ }
132
142
  }
@@ -2,6 +2,8 @@
2
2
  * Range slider — custom-styled so the focus ring always targets the
3
3
  * user-manipulated surface (the thumb) rather than the CSS box.
4
4
  *
5
+ * Docs: docs/pages/forms/range.md
6
+ *
5
7
  * appearance: none gives us full control over track and thumb; without it,
6
8
  * pseudo-element rules for ::-webkit-slider-thumb and ::-moz-range-thumb
7
9
  * are not reliably applied across browsers.
@@ -2,6 +2,8 @@
2
2
  * Select — chevron and appearance layer on top of the shared text-control
3
3
  * recipe.
4
4
  *
5
+ * Docs: docs/pages/forms/overview.md
6
+ *
5
7
  * Base geometry and interaction states come from control.css (select shares
6
8
  * the --control-* tokens). This file only removes the native arrow and draws
7
9
  * the CSS chevron, so the picker (appearance: base-select) enhancement in
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Switch controls for checkbox inputs with switch semantics.
3
3
  *
4
+ * Docs: docs/pages/forms/switch.md
5
+ *
4
6
  * The switch remains an input control, not a decorative toggle. Checked, hover,
5
7
  * focus-visible, and disabled states stay clear.
6
8
  *
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Form validation states.
3
3
  *
4
+ * Docs: docs/pages/forms/validation.md
5
+ *
4
6
  * Imported by default through forms/index.css. Custom builds may omit this
5
7
  * file when an app wants to own validation styling completely.
6
8
  *
@@ -5,6 +5,8 @@
5
5
  * chrome available without fixed offsets. This is not a generic page wrapper
6
6
  * or a public-site layout; all three direct regions are part of the contract.
7
7
  *
8
+ * Docs: docs/pages/layout/app-layout.md
9
+ *
8
10
  * Public hook:
9
11
  * --app-nav-side-size
10
12
  */
@@ -1,4 +1,8 @@
1
- /* Full-height single-column document shell. */
1
+ /*
2
+ * Full-height single-column document shell.
3
+ *
4
+ * Docs: docs/pages/layout/app-shell.md
5
+ */
2
6
  .app-shell {
3
7
  min-block-size: var(--viewport-block);
4
8
  display: flex;
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Centered content column with a maximum measure.
3
3
  *
4
+ * Docs: docs/pages/layout/center.md
5
+ *
4
6
  * Public hooks:
5
7
  * --center-size
6
8
  * --center-pad
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Inline group that wraps when space runs out.
3
3
  *
4
+ * Docs: docs/pages/layout/cluster.md
5
+ *
4
6
  * Public hooks:
5
7
  * --cluster-align
6
8
  * --cluster-justify
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Explicit 12-column composition canvas.
3
3
  *
4
+ * Docs: docs/pages/layout/column-layout.md
5
+ *
4
6
  * Neither space-driven like .grid nor count-driven like .grid-N: this is a
5
7
  * coordinate system. The author places regions with .column-span-N /
6
8
  * .column-start-N and owns narrow-container recomposition. There is
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Media frame with a fixed ratio.
3
3
  *
4
+ * Docs: docs/pages/layout/frame.md
5
+ *
4
6
  * Public hooks:
5
7
  * --frame-ratio
6
8
  *
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Responsive equal-width grids.
3
3
  *
4
+ * Docs: docs/pages/layout/grid.md
5
+ *
4
6
  * .grid is space-driven: it fits as many tracks as --grid-min allows.
5
7
  * .grid-N is count-driven: it declares a structural density of N and collapses
6
8
  * through natural divisors of N.
@@ -1,7 +1,8 @@
1
1
  /* Media object: fixed-width leading element (avatar, icon, image) + flexible
2
2
  trailing content. Replaces the repeated "auto minmax(0, 1fr)" pattern in
3
3
  author cards, comments, meta rows, and similar compositions.
4
- Child contract: exactly two direct children (leading media + content). */
4
+ Child contract: exactly two direct children (leading media + content).
5
+ Docs: docs/pages/layout/media.md */
5
6
  .media {
6
7
  display: grid;
7
8
  grid-template-columns: auto minmax(0, 1fr);
@@ -2,6 +2,8 @@
2
2
  * Scroll snap — optional horizontal rails for niche card strips and simple
3
3
  * touch-friendly overflow areas.
4
4
  *
5
+ * Docs: docs/pages/layout/scroll-snap.md
6
+ *
5
7
  * This is not a full carousel. For previous/next controls, pagination,
6
8
  * active state, drag behavior, looping, or robust desktop mouse interaction,
7
9
  * use a dedicated carousel library.
@@ -3,6 +3,8 @@
3
3
  * header (.topbar) or underlaps it; scroll-margin reserves space on the
4
4
  * target itself so the browser stops early.
5
5
  *
6
+ * Docs: docs/pages/layout/scroll-target.md
7
+ *
6
8
  * Apply to the elements that are link targets: <section id="...">, headings,
7
9
  * etc. The scrollspy enhancer only reads ids — this offset is what actually
8
10
  * shapes where the page lands, and the two are independent.
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Main content + secondary region.
3
3
  *
4
+ * Docs: docs/pages/layout/sidebar-layout.md
5
+ *
4
6
  * The children wrap according to the space allocated to this layout, without
5
7
  * an ancestor opt-in or viewport threshold. The large growth ratio lets the
6
8
  * main region reclaim the full row when the aside wraps instead of remaining
@@ -9,7 +9,8 @@
9
9
  region re-spaces every nested stack, cluster and grid at any depth. Setting
10
10
  the token stays correct on the element that consumes it, and
11
11
  .compact/.spacious set it deliberately so a density context reaches nested layouts. See
12
- docs/pages/layout/stack.md. */
12
+ docs/pages/layout/stack.md.
13
+ Docs: docs/pages/layout/stack.md */
13
14
  .stack {
14
15
  display: flex;
15
16
  flex-direction: column;
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Peer regions that switch as a group.
3
3
  *
4
+ * Docs: docs/pages/layout/switcher.md
5
+ *
4
6
  * All children sit side by side when they all fit; otherwise every child
5
7
  * stacks. Unlike .grid, this recipe cannot create a partial row such as two
6
8
  * items plus one orphan.
@@ -4,6 +4,8 @@
4
4
  * background only — row content (search, breadcrumb, actions) stays local
5
5
  * to the page.
6
6
  *
7
+ * Docs: docs/pages/layout/topbar.md
8
+ *
7
9
  * Not for marketing/editorial site headers — those are one-off visual
8
10
  * identity per page; see docs/pages/patterns/overview.md's Header Navigation
9
11
  * section. This is for the structural app-shell topbar, which recurs unchanged
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Optional fluid typography module.
3
3
  *
4
+ * Docs: docs/pages/foundations/typography.md
5
+ *
4
6
  * Part of the typography family (typography/index.css) and the full bundle; it
5
7
  * is not in the minimal core, so a core-only import adds it explicitly when
6
8
  * display, title, and lead sizes must scale with the viewport. Typical use
@@ -1,6 +1,7 @@
1
1
  /* Lead paragraph: a slightly larger, more relaxed reading-introduction text.
2
2
  Non-fluid and always available — use .text-lead from the optional fluid
3
- module for marketing/display surfaces. */
3
+ module for marketing/display surfaces.
4
+ Docs: docs/pages/foundations/typography.md */
4
5
  .lead {
5
6
  font-size: 1.125em;
6
7
  line-height: var(--line-height-relaxed);
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Overline — editorial small-cap label for section openers.
3
3
  *
4
+ * Docs: docs/pages/foundations/typography.md
5
+ *
4
6
  * Base form is unstyled chrome that takes its surrounding color. Add .pill for
5
7
  * the bordered rounded shape, and combine with an intent class (.primary,
6
8
  * .success, etc.) to tint the text, border, and background.
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Prose is the opt-in rich text scope for authored content.
3
3
  *
4
+ * Docs: docs/pages/foundations/typography.md
5
+ *
4
6
  * Global element styles stay mild so app UI is not shaped accidentally. Page
5
7
  * layout, forms, cards, navigation, and app tables remain outside this scope.
6
8
  *
@@ -143,7 +143,12 @@
143
143
  text-decoration: none;
144
144
  }
145
145
 
146
- .link-muted:hover,
146
+ /* :focus-visible stays in its own rule: in a mixed selector list an engine
147
+ without it would drop the whole rule, taking the :hover styling with it. */
148
+ .link-muted:hover {
149
+ color: var(--text);
150
+ }
151
+
147
152
  .link-muted:focus-visible {
148
153
  color: var(--text);
149
154
  }
@@ -48,7 +48,7 @@ function getContextScope(target) {
48
48
  }
49
49
 
50
50
  function getLongPressDelay(target, menu) {
51
- const value = target.dataset.contextMenuLongPress ?? menu.dataset.contextMenuLongPress;
51
+ const value = target.dataset.contextMenuLongPress ?? menu?.dataset.contextMenuLongPress;
52
52
  if (value == null || value === "false" || value === "none") return null;
53
53
  if (value === "") return LONG_PRESS_MS;
54
54
 
@@ -147,21 +147,52 @@ function clearLongPress(state) {
147
147
  state.pointerId = null;
148
148
  }
149
149
 
150
- function connectContextTarget(target) {
151
- if (contextMap.has(target)) return;
150
+ function releaseMenuReference(target, state) {
151
+ const menu = state.menu;
152
+ // contextByMenu backs the exported contextFor(): a released menu must not
153
+ // keep reporting a target it no longer belongs to. The context check keeps
154
+ // a menu another target has since claimed.
155
+ if (menu && contextFor(menu)?.context === target) {
156
+ contextByMenu.delete(menu);
157
+ }
158
+ state.releaseMenu?.();
159
+ state.releaseMenu = null;
160
+ state.release?.();
161
+ state.release = null;
162
+ state.menu = null;
163
+ }
164
+
165
+ /*
166
+ * The menu is resolved per interaction, not once at connect: a target can be
167
+ * declared before its <menu> exists (streamed markup, late AJAX), and a
168
+ * same-id replacement must rewire to the live element. An unchanged menu
169
+ * costs a WeakMap read and a connection check; a changed one pays for
170
+ * releasing the old retain/menu wiring and acquiring the new.
171
+ */
172
+ function resolveContextMenu(target, state) {
152
173
  const menu = menuFor(target);
153
- if (!menu) return;
174
+ if (state.menu === menu && menu?.isConnected) return menu;
154
175
 
155
- const release = retainSurface(menu);
156
- const controller = new AbortController();
157
- const releaseMenu = connectMenu(menu, {
176
+ releaseMenuReference(target, state);
177
+ if (!menu) return null;
178
+
179
+ state.release = retainSurface(menu);
180
+ state.menu = menu;
181
+ state.releaseMenu = connectMenu(menu, {
158
182
  close: (menu) => closeSurface(menu),
159
183
  });
184
+ return menu;
185
+ }
186
+
187
+ function connectContextTarget(target) {
188
+ if (contextMap.has(target)) return;
189
+
190
+ const controller = new AbortController();
160
191
  const state = {
161
192
  controller,
162
- releaseMenu,
163
- menu,
164
- release,
193
+ releaseMenu: null,
194
+ menu: null,
195
+ release: null,
165
196
  timer: null,
166
197
  pointerId: null,
167
198
  startX: 0,
@@ -169,10 +200,15 @@ function connectContextTarget(target) {
169
200
  suppressClickUntil: 0,
170
201
  };
171
202
 
203
+ resolveContextMenu(target, state);
204
+
172
205
  target.addEventListener(
173
206
  "contextmenu",
174
207
  (e) => {
175
208
  if (shouldIgnoreNativeTarget(e.target)) return;
209
+ const menu = resolveContextMenu(target, state);
210
+ // Without a resolvable menu the native context menu stays the fallback.
211
+ if (!menu) return;
176
212
  e.preventDefault();
177
213
  e.stopPropagation();
178
214
  openContextMenu(target, menu, {
@@ -190,6 +226,8 @@ function connectContextTarget(target) {
190
226
  (e) => {
191
227
  if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
192
228
  if (shouldIgnoreNativeTarget(e.target)) return;
229
+ const menu = resolveContextMenu(target, state);
230
+ if (!menu) return;
193
231
  e.preventDefault();
194
232
  openFromKeyboard(target, menu, e.target, target);
195
233
  },
@@ -199,7 +237,7 @@ function connectContextTarget(target) {
199
237
  target.addEventListener(
200
238
  "pointerdown",
201
239
  (e) => {
202
- const delay = getLongPressDelay(target, menu);
240
+ const delay = getLongPressDelay(target, menuFor(target));
203
241
  if (e.pointerType !== "touch" || delay == null || shouldIgnoreNativeTarget(e.target)) {
204
242
  return;
205
243
  }
@@ -210,6 +248,8 @@ function connectContextTarget(target) {
210
248
  state.startY = e.clientY;
211
249
  state.timer = setTimeout(() => {
212
250
  state.timer = null;
251
+ const menu = resolveContextMenu(target, state);
252
+ if (!menu) return;
213
253
  state.suppressClickUntil = Date.now() + LONG_PRESS_CLICK_SUPPRESSION_MS;
214
254
  openContextMenu(target, menu, {
215
255
  x: state.startX,
@@ -268,6 +308,9 @@ function connectContextTarget(target) {
268
308
  )
269
309
  return;
270
310
 
311
+ const menu = resolveContextMenu(target, state);
312
+ if (!menu) return;
313
+
271
314
  e.preventDefault();
272
315
  e.stopPropagation();
273
316
 
@@ -294,12 +337,10 @@ function disconnectContextTarget(target) {
294
337
  if (!state) return;
295
338
  clearLongPress(state);
296
339
  state.controller.abort();
297
- state.releaseMenu?.();
298
- if (contextFor(state.menu)?.context === target) {
299
- contextByMenu.delete(state.menu);
340
+ if (state.menu && contextFor(state.menu)?.context === target) {
300
341
  closeSurface(state.menu, { restoreFocus: false });
301
342
  }
302
- state.release();
343
+ releaseMenuReference(target, state);
303
344
  contextMap.delete(target);
304
345
  }
305
346
 
@@ -1,10 +1,14 @@
1
1
  /*
2
2
  * enhancement-loader.js — Server-declared enhancement manifest loader.
3
3
  *
4
- * Reads <script type="application/json" data-enhance-modules"> blocks or
4
+ * Reads <script type="application/json" data-enhance-modules> blocks or
5
5
  * the Enhance-Modules HTTP header, dynamically imports the declared ES
6
6
  * module entrypoints, and registers them with the Actual lifecycle engine.
7
7
  *
8
+ * A manifest is trusted application configuration: every declared URL is
9
+ * imported and executed (or loaded as script/style), so manifests must be
10
+ * produced by the server — never built from untrusted input.
11
+ *
8
12
  * Manifests declare document-wide modules: dynamically loaded enhancements
9
13
  * always register on the default document root. A scope passed to
10
14
  * loadEnhancements() only limits where manifest blocks are looked up.
package/src/js/escape.js CHANGED
@@ -25,7 +25,17 @@ export function registerEscapeDismissal(element, dismiss) {
25
25
  }
26
26
 
27
27
  function onDocumentEscape(event) {
28
- if (event.key !== "Escape" || event.ctrlKey || event.altKey || event.shiftKey) return;
28
+ // A closer handler that consumed the key (defaultPrevented) keeps its
29
+ // claim; Escape with any modifier is not a dismissal.
30
+ if (
31
+ event.defaultPrevented ||
32
+ event.key !== "Escape" ||
33
+ event.ctrlKey ||
34
+ event.altKey ||
35
+ event.shiftKey ||
36
+ event.metaKey
37
+ )
38
+ return;
29
39
 
30
40
  const stack = dismissableStacks.get(event.currentTarget);
31
41
  const entry = stack.at(-1);
package/src/js/flyout.js CHANGED
@@ -43,9 +43,14 @@ function onTriggerClick(e) {
43
43
  const trigger = e.currentTarget;
44
44
  const state = triggerMap.get(trigger);
45
45
  if (!state) return;
46
- e.stopPropagation();
47
46
  const panel = resolvePanel(trigger, state);
48
47
  if (!panel) return;
48
+ // A link trigger is legal markup; the interaction is claimed only once a
49
+ // panel has actually resolved — an unresolved trigger still navigates and
50
+ // still bubbles. The stop is what keeps the document-level surface handler
51
+ // from closing the panel this same click just opened.
52
+ e.preventDefault();
53
+ e.stopPropagation();
49
54
  if (isSurfaceOpen(panel)) closeSurface(panel);
50
55
  else openFlyout(panel, trigger);
51
56
  }
package/src/js/focus.js CHANGED
@@ -17,10 +17,15 @@ const FOCUSABLE_SELECTOR = [
17
17
  */
18
18
  export function isElementVisible(el) {
19
19
  if (!el || el.hidden) return false;
20
- if (typeof el.checkVisibility === "function") return el.checkVisibility();
20
+ if (typeof el.checkVisibility === "function") {
21
+ // Bare checkVisibility() only tests for a layout box — visibility:hidden
22
+ // requires the option. Both names are passed because the engines that
23
+ // shipped first knew it as checkVisibilityCSS.
24
+ return el.checkVisibility({ visibilityProperty: true, checkVisibilityCSS: true });
25
+ }
21
26
  if (el.getClientRects().length === 0) return false;
22
- // checkVisibility() covers visibility:hidden; without it, read the
23
- // computed direction of the cascade so a hidden element is not focusable.
27
+ // The fallback reproduces the option's coverage through the computed style
28
+ // so a hidden element is not focusable.
24
29
  const style = el.ownerDocument?.defaultView?.getComputedStyle?.(el);
25
30
  if (!style) return true;
26
31
  return style.visibility !== "hidden" && style.visibility !== "collapse";
package/src/js/surface.js CHANGED
@@ -152,9 +152,36 @@ function ensureSurfaceWired(menu) {
152
152
  };
153
153
 
154
154
  surfaceMap.set(menu, state);
155
+ menu.addEventListener(EVENTS.dismiss, onSurfaceDismiss);
155
156
  return state;
156
157
  }
157
158
 
159
+ /*
160
+ * The generic `--dismiss` command hides its target by writing `hidden` — but
161
+ * on a surface the attribute is not the closed state, the popover transport
162
+ * is. Left alone it would strand the open lifecycle: the Escape entry, the
163
+ * position tracker and the is-open class would survive, and openSurface()
164
+ * would refuse to act on a panel that reads open. The surface therefore
165
+ * consumes the event to finalize its own lifecycle, rather than dismiss.js
166
+ * learning about surfaces. `hidden` comes back off once the transport state
167
+ * is clean; prepareSurface() also strips it on the next open.
168
+ * A dismissal bubbled from a nested target is not the surface's own.
169
+ */
170
+ function onSurfaceDismiss(event) {
171
+ if (event.target !== event.currentTarget) return;
172
+ const menu = event.currentTarget;
173
+ // dismiss.js writes `hidden` before dispatching, and hiding an ancestor can
174
+ // already have dropped focus out of the surface — so the command's trigger,
175
+ // not activeElement, is the reliable witness of where the activation came
176
+ // from. A trigger inside the surface means focus was inside it and must be
177
+ // restored to the opener; a trigger outside keeps focus where it landed.
178
+ const trigger = event.detail?.trigger;
179
+ closeSurface(menu, {
180
+ restoreFocus: trigger ? menu.contains(trigger) : undefined,
181
+ });
182
+ menu.removeAttribute("hidden");
183
+ }
184
+
158
185
  // Escape and positional tracking belong to the *open* surface, not to the
159
186
  // wiring. They start when the surface opens and stop when it closes, so a
160
187
  // closed surface never participates in the dismissable stack or in
@@ -341,6 +368,7 @@ export function closeSurface(menu, opts = {}) {
341
368
  export function disconnectSurface(menu) {
342
369
  if (!menu) return;
343
370
  closeSurface(menu);
371
+ menu.removeEventListener(EVENTS.dismiss, onSurfaceDismiss);
344
372
  const state = surfaceMap.get(menu);
345
373
  if (state) {
346
374
  state.unregisterEscape?.();
package/src/js/tooltip.js CHANGED
@@ -24,7 +24,7 @@
24
24
  * work automatically via bubbling delegated listeners.
25
25
  *
26
26
  * Show: hover + focus (150ms delay), click toggle, or always visible
27
- * Hide: blur, pointer leave, Escape
27
+ * Hide: focus leaving the trigger (focusout), pointer leave, Escape
28
28
  *
29
29
  * Geometry: a trigger scrolled out of the positioning boundary takes its tip
30
30
  * down and keeps it tracked, so scrolling back brings the same tip
@@ -420,8 +420,12 @@ function ensureTip(trigger) {
420
420
  { signal: triggerController.signal },
421
421
  );
422
422
  trigger.addEventListener(
423
- "blur",
424
- () => {
423
+ // blur does not bubble: on a composite trigger (focusable children) the
424
+ // loss of focus is only observable through focusout. relatedTarget keeps
425
+ // a focus move between the trigger's own children from ending the tip.
426
+ "focusout",
427
+ (event) => {
428
+ if (event.relatedTarget && trigger.contains(event.relatedTarget)) return;
425
429
  state.focused = false;
426
430
  scheduleHide(tip);
427
431
  },
@@ -489,8 +493,10 @@ function handleTriggerIntent(e) {
489
493
  const trigger = e.target.closest?.(SEL);
490
494
  if (!trigger) return;
491
495
  if (isClickTrigger(trigger) || isAlwaysVisible(trigger)) return;
496
+ // A pointer or focus move between the trigger's own children is not a new
497
+ // intent: it must not restart the show delay or rewrite the intent flags.
492
498
  if (
493
- e.type === "mouseover" &&
499
+ (e.type === "mouseover" || e.type === "focusin") &&
494
500
  e.relatedTarget instanceof Node &&
495
501
  trigger.contains(e.relatedTarget)
496
502
  )