actual-css 0.6.0 → 0.7.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.
@@ -17,6 +17,18 @@ with the `hidden` attribute.
17
17
  - The arrow inherits the tooltip background — custom gradients carry through automatically.
18
18
  - Avoid relying on the native `title` attribute as the primary implementation.
19
19
 
20
+ A `.tooltip[popover]` may use a native or third-party-owned popover lifecycle.
21
+ Actual removes the native inset, margin, and overflow that conflict with
22
+ positioning and with the projecting arrow, while leaving bare `[popover]`
23
+ elements untouched. The lifecycle owner must still write the fixed viewport
24
+ coordinates and arrow position; Actual only supplies the presentation.
25
+
26
+ That reset is gated on `@supports selector(:popover-open)`. Native Popover is
27
+ optional and is not part of Actual's Minimal contract: browsers within the
28
+ Minimal range may not support it, and there the attribute carries no behavior,
29
+ so nothing shows or hides the tip. Use `data-tooltip` and the built-in runtime
30
+ when the lifecycle must work across the full Minimal range.
31
+
20
32
  ```html demo
21
33
  <button class="btn ghost"
22
34
  type="button"
@@ -75,6 +75,19 @@ As inherited contexts, one class tightens or loosens the whole subtree:
75
75
  </div>
76
76
  ```
77
77
 
78
+ Application chrome can use the same scope; no separate `.compact` mode is
79
+ needed:
80
+
81
+ ```html demo
82
+ <header class="navbar sm">
83
+ <a class="navbar-brand" href="/">Workspace</a>
84
+ <nav class="cluster" aria-label="Workspace actions">
85
+ <button class="btn ghost">Search</button>
86
+ <button class="btn primary">Create</button>
87
+ </nav>
88
+ </header>
89
+ ```
90
+
78
91
  The same class on a component is local density, not a second meaning:
79
92
 
80
93
  ```html demo
@@ -86,7 +99,8 @@ This is not "`.sm` sometimes means density and sometimes size": `.sm` always
86
99
  establishes the same density tokens — only the scope changes. The density
87
100
  tokens are `--gap`, `--density-space`, `--control-size`, and
88
101
  `--density-compact-size`. Components consume the dimensions that make sense
89
- for them and opt out of the rest.
102
+ for them and opt out of the rest. The absolute `--space-*` scale is not rebound
103
+ by density scopes.
90
104
 
91
105
  Components with bespoke sizing (`.avatar`, `.spinner`) define local `.sm`/`.lg`
92
106
  instead of consuming the shared tokens.
@@ -70,6 +70,14 @@ Native controls that no component class restyles — a bare checkbox, radio, ran
70
70
 
71
71
  → Forms · Overview · Foundations · Tokens (theme contract)
72
72
 
73
+ ### Store a theme name without applying it
74
+
75
+ `data-theme` is an active theme boundary on every element that carries it, not
76
+ a generic storage attribute. Use a different name such as `data-theme-value`,
77
+ or a form control's `value`, when the element should only hold a theme name.
78
+ Apply `data-theme` deliberately when that element and its subtree should paint
79
+ with the selected palette — a theme swatch is a useful example.
80
+
73
81
  ### Loading / busy overlay on a container
74
82
 
75
83
  Use `aria-busy="true"` with a direct last-child `.spinner`; tint the overlay with `--busy-overlay-bg`.
@@ -124,19 +124,18 @@ The JS is designed to run on its own, without Actual's stylesheet.
124
124
  ## The runtime as a primitive kit
125
125
 
126
126
  For building custom widgets (select, tags, date picker) on Actual's primitives,
127
- see the widget-primitives design note. Every primitive subpath is published in
128
- `package.json#exports`:
129
-
130
- | Subpath | Purpose |
131
- | ------------------------ | ---------------------------------------------------------------- |
132
- | `actual-css/js/enhance` | DOM lifecycle engine: `enhance()`, `registerEnhancement`, tokens |
133
- | `actual-css/js/escape` | Per-document LIFO Escape dismissal stack |
134
- | `actual-css/js/events` | The `actual:*` event name constants |
135
- | `actual-css/js/floating` | Positioning engine (`reposition`, `repositionAt`, `autoUpdate`) |
136
- | `actual-css/js/focus` | Focusable-item lookup and helpers |
137
- | `actual-css/js/keys` | Roving-focus item navigation helpers |
138
- | `actual-css/js/menu` | Menu keyboard/click wiring, item vocabulary |
139
- | `actual-css/js/surface` | Surface lifecycle (`openSurface`, `closeSurface`, …) |
127
+ see the widget-primitives design note. Every Actual runtime primitive subpath is
128
+ published in `package.json#exports`:
129
+
130
+ | Subpath | Purpose |
131
+ | ----------------------- | ---------------------------------------------------------------- |
132
+ | `actual-css/js/enhance` | DOM lifecycle engine: `enhance()`, `registerEnhancement`, tokens |
133
+ | `actual-css/js/escape` | Per-document LIFO Escape dismissal stack |
134
+ | `actual-css/js/events` | The `actual:*` event name constants |
135
+ | `actual-css/js/focus` | Focusable-item lookup and helpers |
136
+ | `actual-css/js/keys` | Roving-focus item navigation helpers |
137
+ | `actual-css/js/menu` | Menu keyboard/click wiring, item vocabulary |
138
+ | `actual-css/js/surface` | Surface lifecycle (`openSurface`, `closeSurface`, …) |
140
139
 
141
140
  The exports map is explicit — it *is* the statement of what is public.
142
141
 
@@ -149,33 +148,36 @@ computes and writes inline `left`/`top`, `data-placement`,
149
148
  caller owns the floating element's presentation entirely.
150
149
 
151
150
  ```js
152
- import { autoUpdate, reposition, repositionAt } from "actual-css/js/floating";
151
+ import { autoUpdate, reposition, repositionAt } from "@lekoala/floating";
153
152
 
154
- const stop = autoUpdate(float, () => {
153
+ const stop = autoUpdate(trigger, float, () => {
155
154
  reposition(trigger, float, { placement: "bottom-start", distance: 4 });
156
155
  });
157
156
  ```
158
157
 
159
158
  Options passed to `reposition()`:
160
159
 
161
- | Option | Default | Contract |
162
- | -------------- | ---------------- | ------------------------------------------- |
163
- | `placement` | `"bottom-start"` | Preferred side and alignment |
164
- | `distance` | `0` | Gap from the reference element |
165
- | `flip` | `true` | Flip when the preferred side is out of view |
166
- | `shift` | `true` | Shift along the main axis to stay in bounds |
167
- | `shiftPadding` | `4` | Minimum space kept from the boundary |
168
- | `scope` | viewport | Boundary element for overflow decisions |
160
+ | Option | Default | Contract |
161
+ | -------------- | ---------------- | -------------------------------------------- |
162
+ | `placement` | `"bottom-start"` | Preferred side and alignment |
163
+ | `distance` | `0` | Gap from the reference element |
164
+ | `flip` | `true` | Flip when the preferred side is out of view |
165
+ | `shift` | `true` | Shift along the cross axis to stay in bounds |
166
+ | `shiftPadding` | `4` | Minimum space kept from the boundary |
167
+ | `scope` | viewport | Boundary element for overflow decisions |
169
168
 
170
169
  `placement` takes a `-start` or `-end` alignment suffix.
171
170
 
172
- `autoUpdate(element, callback)` batches scroll, resize, and element-resize
173
- callbacks per document and returns a stop handle; it does not keep a closed or
171
+ `@lekoala/floating` is the standalone positioning dependency used by the
172
+ runtime. `autoUpdate(reference, float, callback)` batches scroll, resize, and
173
+ element-resize callbacks per document and returns a stop handle; it observes
174
+ both the reference and floating element while the surface is open. Pass `null`
175
+ as the reference for point-positioned surfaces. It does not keep a closed or
174
176
  hidden element positioned. `repositionAt(x, y, float, opts)` positions relative
175
177
  to a fixed point — used by context menus.
176
178
 
177
- A third-party widget importing only `floating` with no Actual stylesheet must be
178
- able to position a listbox under an input — that is the test.
179
+ A third-party widget importing only `@lekoala/floating` with no Actual stylesheet
180
+ must be able to position a listbox under an input — that is the test.
179
181
 
180
182
  ## Surface contract
181
183
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "actual-css",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Plain CSS component framework with semantic classes, universal variants, small tokens, theme hooks, and progressive enhancements.",
5
5
  "type": "module",
6
- "packageManager": "bun@1.4.0",
6
+ "packageManager": "bun@1.4.1",
7
7
  "license": "MIT",
8
8
  "author": "Thomas <thomas@lekoala.be>",
9
9
  "repository": {
@@ -58,7 +58,6 @@
58
58
  "./js/parse-config": "./src/js/parse-config.js",
59
59
  "./js/events": "./src/js/events.js",
60
60
  "./js/filter": "./src/js/filter.js",
61
- "./js/floating": "./src/js/floating.js",
62
61
  "./js/flyout": "./src/js/flyout.js",
63
62
  "./js/focus": "./src/js/focus.js",
64
63
  "./js/focus-group": "./src/js/focus-group.js",
@@ -143,5 +142,8 @@
143
142
  "devDependencies": {
144
143
  "@biomejs/biome": "^2.5",
145
144
  "happy-dom": "^20.11"
145
+ },
146
+ "dependencies": {
147
+ "@lekoala/floating": "^0.1.0"
146
148
  }
147
149
  }
@@ -79,12 +79,50 @@
79
79
  transition: opacity var(--duration-fast) ease;
80
80
  }
81
81
 
82
- .flyout.is-open:not(.is-sheet) {
82
+ /* Native popovers enter the top layer with UA geometry that conflicts with
83
+ anchored positioning. The reset is gated on real Popover support because an
84
+ attribute selector matches whether or not the engine implements Popover:
85
+ without it the UA neither hides nor promotes the element, so an ungated
86
+ reset would strand a permanently visible panel at inset: auto. Popover is
87
+ not part of Actual's Minimal contract — engines inside the Minimal range
88
+ may lack it — so the gate is about the capability, not the floor. Kept
89
+ scoped to Actual surfaces so bare [popover] elements retain their platform
90
+ presentation. */
91
+ @supports selector(:popover-open) {
92
+ .flyout[popover] {
93
+ position: fixed;
94
+ inset: auto;
95
+ margin: 0;
96
+ }
97
+
98
+ /* A presentation class may own its *visible* display type, but must not
99
+ override the platform's hidden state. The UA closes a popover with
100
+ display: none, and an author declaration beats the UA origin whatever the
101
+ specificity — so .flyout's own display: grid would leave a closed popover
102
+ painted at its static position. Restoring both properties here also gives
103
+ the native path the exit target the runtime path already has in
104
+ .flyout[hidden]: without an opacity to animate, the overlay/display
105
+ allow-discrete transition below would hold a fully opaque panel in the top
106
+ layer and then snap it away. */
107
+ .flyout[popover]:not(:popover-open) {
108
+ display: none;
109
+ opacity: 0;
110
+ }
111
+ }
112
+
113
+ /* compat-ok: :popover-open rides in a forgiving :is(), where an engine without
114
+ Popover discards just that member and .is-open alone keeps driving the open
115
+ state. This is a property of the forgiving list, not of the pseudo-class: in
116
+ a plain selector list an unsupported member would invalidate the whole rule.
117
+ Keep it inside :is()/:where() or gate it. */
118
+ .flyout:is(.is-open, :popover-open):not(.is-sheet) {
83
119
  inset: auto;
84
120
  }
85
121
 
122
+ /* compat-ok: forgiving :is() again, and @starting-style is itself unsupported
123
+ across part of the Minimal range, so the rule simply does not exist there. */
86
124
  @starting-style {
87
- .flyout.is-open {
125
+ .flyout:is(.is-open, :popover-open) {
88
126
  opacity: 0;
89
127
  }
90
128
  }
@@ -123,16 +161,23 @@
123
161
  translate: 0 var(--space-30);
124
162
  }
125
163
 
164
+ /* compat-ok: forgiving :is() inside @starting-style, as above. */
126
165
  @starting-style {
127
- .flyout.is-sheet.is-open {
166
+ .flyout.is-sheet:is(.is-open, :popover-open) {
128
167
  translate: 0 var(--space-30);
129
168
  }
130
169
  }
131
170
 
171
+ /* display keeps the exit transition alive for the [hidden] path; overlay does
172
+ the same for the native popover path, where hidePopover() would otherwise
173
+ drop the panel out of the top layer on the first frame and let a parent
174
+ overflow or a dialog clip the fade. An engine that cannot transition either
175
+ property ignores it and the exit is instant. */
132
176
  @supports (transition-behavior: allow-discrete) {
133
177
  .flyout {
134
178
  transition:
135
179
  opacity var(--duration-fast) ease,
180
+ overlay var(--duration-fast) allow-discrete,
136
181
  display var(--duration-fast) allow-discrete;
137
182
  }
138
183
 
@@ -140,6 +185,7 @@
140
185
  transition:
141
186
  opacity var(--duration-fast) ease,
142
187
  translate var(--duration-fast) ease,
188
+ overlay var(--duration-fast) allow-discrete,
143
189
  display var(--duration-fast) allow-discrete;
144
190
  }
145
191
  }
@@ -63,6 +63,13 @@
63
63
  border-end-start-radius: var(--join-es);
64
64
  }
65
65
 
66
+ /* A square icon button has an explicit block size, which otherwise prevents
67
+ align-items: stretch from matching a taller labelled sibling. The button's
68
+ existing min-block-size keeps the standalone square-size invariant intact. */
69
+ .join > .btn.icon-only {
70
+ block-size: auto;
71
+ }
72
+
66
73
  .join > :first-child {
67
74
  --join-ss: var(--join-radius);
68
75
  --join-es: var(--join-radius);
@@ -75,30 +75,36 @@
75
75
  /* Checkable menu items are one command with one accessible state. Their
76
76
  indicator is generated so authors do not add a second interactive input. */
77
77
  .menu-item:is([role="menuitemcheckbox"], [role="menuitemradio"])::before {
78
- display: grid;
78
+ content: "";
79
+ display: block;
79
80
  flex: 0 0 var(--menu-item-icon-size);
80
81
  inline-size: var(--menu-item-icon-size);
81
- place-items: center;
82
- }
83
-
84
- .menu-item[role="menuitemcheckbox"]::before {
85
- content: "";
82
+ block-size: var(--menu-item-icon-size);
86
83
  }
87
84
 
88
85
  .menu-item[role="menuitemcheckbox"][aria-checked="true"]::before {
89
- content: "✓";
86
+ background: currentColor;
87
+ -webkit-mask: var(--icon-check) center / contain no-repeat;
88
+ mask: var(--icon-check) center / contain no-repeat;
90
89
  }
91
90
 
92
91
  .menu-item[role="menuitemcheckbox"][aria-checked="mixed"]::before {
93
- content: "−";
92
+ background:
93
+ linear-gradient(currentColor 0 0) center / 60% max(var(--border-width), 0.125em) no-repeat;
94
94
  }
95
95
 
96
96
  .menu-item[role="menuitemradio"]::before {
97
- content: "○";
97
+ background: radial-gradient(circle, transparent 0 46%, currentColor 48% 58%, transparent 60%);
98
98
  }
99
99
 
100
100
  .menu-item[role="menuitemradio"][aria-checked="true"]::before {
101
- content: "●";
101
+ background: radial-gradient(
102
+ circle,
103
+ currentColor 0 25%,
104
+ transparent 27% 46%,
105
+ currentColor 48% 58%,
106
+ transparent 60%
107
+ );
102
108
  }
103
109
 
104
110
  .menu-item:focus-visible {
@@ -39,21 +39,57 @@
39
39
  transition: opacity var(--duration-fast) ease;
40
40
  }
41
41
 
42
+ /* Neutralize only the native geometry that conflicts with a positioned
43
+ tooltip. In particular, the UA overflow would turn the arrow into scroll
44
+ overflow instead of letting it project beyond the bubble. Gated on real
45
+ Popover support: the attribute selector matches whether or not the engine
46
+ implements Popover, and without it nothing hides the element, so an ungated
47
+ reset would leave a stray bubble parked at inset: auto. Popover sits outside
48
+ Actual's Minimal contract, so the gate tracks the capability rather than the
49
+ floor. */
50
+ @supports selector(:popover-open) {
51
+ .tooltip[popover] {
52
+ inset: auto;
53
+ margin: 0;
54
+ overflow: visible;
55
+ }
56
+
57
+ /* .tooltip declares no display of its own, so the UA already hides a closed
58
+ popover here. The rule is written anyway: both components advertised as
59
+ Popover-compatible should cede the hidden state the same way, and the
60
+ opacity target is what actually makes the native exit fade — see the
61
+ overlay note below. */
62
+ .tooltip[popover]:not(:popover-open) {
63
+ display: none;
64
+ opacity: 0;
65
+ }
66
+ }
67
+
42
68
  .tooltip[hidden] {
43
69
  display: none;
44
70
  opacity: 0;
45
71
  }
46
72
 
73
+ /* Entry opacity for both lifecycles: a runtime-driven tip is visible when it
74
+ is neither a popover nor [hidden], a native one when it is :popover-open.
75
+ compat-ok: [popover] appears here only under negation, which every engine
76
+ evaluates correctly whether or not it implements Popover, :popover-open
77
+ rides in a forgiving :is(), and @starting-style is itself unsupported across
78
+ part of the Minimal range — so the rule does not exist there at all. */
47
79
  @starting-style {
48
- .tooltip:not([hidden]) {
80
+ .tooltip:is(:not([popover]):not([hidden]), :popover-open) {
49
81
  opacity: 0;
50
82
  }
51
83
  }
52
84
 
85
+ /* overlay is the native popover counterpart of display: it holds the tip in
86
+ the top layer until the fade finishes, instead of letting hidePopover()
87
+ demote it on the first frame. Ignored where unsupported. */
53
88
  @supports (transition-behavior: allow-discrete) {
54
89
  .tooltip {
55
90
  transition:
56
91
  opacity var(--duration-fast) ease,
92
+ overlay var(--duration-fast) allow-discrete,
57
93
  display var(--duration-fast) allow-discrete;
58
94
  }
59
95
  }
package/src/js/status.js CHANGED
@@ -31,6 +31,12 @@
31
31
  * - Any other code, without importing this module: dispatch `actual:status`
32
32
  * directly — `detail: { message, intent, duration }`, or `{}` to clear.
33
33
  *
34
+ * A message originating inside an open dialog temporarily mounts the singleton
35
+ * bar in that dialog. Content outside a modal dialog is inert and cannot sit
36
+ * above its top layer, so this keeps both the visual feedback and live region
37
+ * in the active subtree. Programmatic callers pass the originating element as
38
+ * `options.source`; event and command entry points propagate it automatically.
39
+ *
34
40
  * The element contract is `[data-status][role="status"]`. Both are required:
35
41
  * "status" is a generic domain word, so `role="status"` keeps the
36
42
  * document-wide lookup from matching an unrelated app element that happens to
@@ -59,12 +65,39 @@ let statusTimer;
59
65
  // never emptied by the previous message's cleanup.
60
66
  let statusGeneration = 0;
61
67
  let viewportTracker = null;
68
+ const statusHomes = new WeakMap();
62
69
 
63
70
  function statusTarget() {
64
71
  if (typeof document === "undefined") return null;
65
72
  return document.querySelector(STATUS_SELECTOR);
66
73
  }
67
74
 
75
+ function restoreStatusTarget(target) {
76
+ const home = statusHomes.get(target);
77
+ if (!home) return;
78
+
79
+ const parent = home.parent?.isConnected ? home.parent : target.ownerDocument?.body;
80
+ if (parent) {
81
+ const reference = home.nextSibling?.parentNode === parent ? home.nextSibling : null;
82
+ parent.insertBefore(target, reference);
83
+ }
84
+ statusHomes.delete(target);
85
+ }
86
+
87
+ function mountStatusTarget(target, source) {
88
+ const dialog = source?.closest?.("dialog[open]");
89
+ if (!dialog || dialog.ownerDocument !== target.ownerDocument) {
90
+ restoreStatusTarget(target);
91
+ return;
92
+ }
93
+ if (dialog.contains(target)) return;
94
+
95
+ if (!statusHomes.has(target)) {
96
+ statusHomes.set(target, { parent: target.parentNode, nextSibling: target.nextSibling });
97
+ }
98
+ dialog.append(target);
99
+ }
100
+
68
101
  // The bar is fixed to the layout viewport, so the mobile software keyboard
69
102
  // covers it. visualViewport reports the occluded band; the CSS adds it to the
70
103
  // bottom inset. Tracking runs only while a message is on screen.
@@ -130,6 +163,8 @@ export function status(message, options = {}) {
130
163
  clearTimeout(statusTimer);
131
164
  statusGeneration++;
132
165
 
166
+ mountStatusTarget(target, options.source);
167
+
133
168
  clearIntentClasses(target);
134
169
  const intent = intentClasses(options.intent);
135
170
  if (intent.length) {
@@ -168,6 +203,7 @@ status.clear = function clear() {
168
203
  target.textContent = "";
169
204
  clearIntentClasses(target);
170
205
  releaseViewport();
206
+ restoreStatusTarget(target);
171
207
  });
172
208
  };
173
209
 
@@ -198,7 +234,7 @@ function connectStatusTrigger(trigger, target) {
198
234
  if (typeof document !== "undefined") {
199
235
  document.addEventListener(EVENTS.invalid, (event) => {
200
236
  const message = event.detail?.message;
201
- if (message) status(message, { intent: "danger" });
237
+ if (message) status(message, { intent: "danger", source: event.target });
202
238
  });
203
239
 
204
240
  // Public contract: any code can trigger the status bar this way, without
@@ -209,7 +245,7 @@ if (typeof document !== "undefined") {
209
245
  // paths end in the same exit, so a detail that lost its message never
210
246
  // strands a stale one on screen.
211
247
  if (message != null) {
212
- status(message, { intent, duration });
248
+ status(message, { intent, duration, source: event.target });
213
249
  } else {
214
250
  status.clear();
215
251
  }
package/src/js/surface.js CHANGED
@@ -1,7 +1,7 @@
1
+ import { autoUpdate, reposition, repositionAt } from "@lekoala/floating";
1
2
  import enhance from "./enhance.js";
2
3
  import { registerEscapeDismissal } from "./escape.js";
3
4
  import { EVENTS } from "./events.js";
4
- import { autoUpdate, reposition, repositionAt } from "./floating.js";
5
5
 
6
6
  import { CLASSES } from "./selectors.js";
7
7
  import { waitForTransitions } from "./transition.js";
@@ -210,6 +210,10 @@ function startSurfaceResources(menu, state) {
210
210
  const armKey = (event) => {
211
211
  if (!event.defaultPrevented && SCROLL_KEYS.has(event.key)) arm(event);
212
212
  };
213
+ const dismissOnScroll = (event) => {
214
+ if (state.scrollIntentAt == null || menu.contains(event.target)) return;
215
+ closeSurface(menu);
216
+ };
213
217
  const captureOptions = { signal: controller.signal, capture: true };
214
218
  const passiveCaptureOptions = { ...captureOptions, passive: true };
215
219
  const doc = menu.ownerDocument;
@@ -217,20 +221,13 @@ function startSurfaceResources(menu, state) {
217
221
  doc.addEventListener("touchmove", arm, passiveCaptureOptions);
218
222
  doc.addEventListener("wheel", arm, passiveCaptureOptions);
219
223
  doc.addEventListener("keydown", armKey, { signal: controller.signal });
224
+ doc.addEventListener("scroll", dismissOnScroll, passiveCaptureOptions);
220
225
  state.stopScrollIntent = () => controller.abort();
221
226
  }
222
- state.stopTracking = autoUpdate(menu, ({ type, targets, timeStamp }) => {
227
+ const anchor = state.point ? null : state.trigger || state.source;
228
+ state.stopTracking = autoUpdate(anchor, menu, ({ type }) => {
223
229
  if (menu.hidden) return;
224
- if (
225
- type === "scroll" &&
226
- state.dismissOnScroll &&
227
- state.scrollIntentAt != null &&
228
- timeStamp >= state.scrollIntentAt &&
229
- [...targets].some((target) => target !== menu && !menu.contains(target))
230
- ) {
231
- closeSurface(menu);
232
- return;
233
- }
230
+ if (type === "scroll" && state.dismissOnScroll) return;
234
231
  applyPresentation(menu, state);
235
232
  if (!positionSurface(menu)) closeSurface(menu);
236
233
  });
@@ -249,11 +246,30 @@ export function prepareSurface(menu) {
249
246
  reaperFor(menu)?.refresh(menu);
250
247
  }
251
248
 
249
+ /*
250
+ * One lifecycle owner per surface. surface.js opens by clearing [hidden] and
251
+ * adding .is-open; it never calls showPopover(). So a [popover] panel handed to
252
+ * the runtime has two owners and stays shut: the platform keeps a popover it
253
+ * was never asked to show closed, whatever the runtime writes.
254
+ *
255
+ * This covers every popover value. "manual" is a candidate future transport for
256
+ * this module, which does not make it a legitimate mix today. Delete this guard
257
+ * in the commit that makes surface.js call showPopover().
258
+ */
259
+ function warnPopoverConflict(panel) {
260
+ if (!panel.hasAttribute("popover")) return;
261
+ console.warn(
262
+ "Actual: the surface lifecycle cannot own a [popover] element — choose one lifecycle owner (remove the popover attribute, or drop the Actual enhancement).",
263
+ panel,
264
+ );
265
+ }
266
+
252
267
  export function retainSurface(panel) {
253
268
  if (!panel) return () => {};
254
269
 
255
270
  let entry = surfaceRetainers.get(panel);
256
271
  if (!entry) {
272
+ warnPopoverConflict(panel);
257
273
  prepareSurface(panel);
258
274
  entry = { count: 0 };
259
275
  surfaceRetainers.set(panel, entry);
package/src/js/tooltip.js CHANGED
@@ -27,9 +27,9 @@
27
27
  * Hide: blur, pointer leave, Escape
28
28
  */
29
29
 
30
+ import { autoUpdate, reposition } from "@lekoala/floating";
30
31
  import enhance from "./enhance.js";
31
32
  import { registerEscapeDismissal } from "./escape.js";
32
- import { autoUpdate, reposition } from "./floating.js";
33
33
  import { CLASSES } from "./selectors.js";
34
34
 
35
35
  const SHOW_DELAY_MS = 150;
@@ -163,7 +163,7 @@ function updateTrackedTip(tip, state) {
163
163
  function startTracking(tip, state) {
164
164
  if (state.stopTracking) return;
165
165
 
166
- state.stopTracking = autoUpdate(tip, () => updateTrackedTip(tip, state));
166
+ state.stopTracking = autoUpdate(state.activeRef, tip, () => updateTrackedTip(tip, state));
167
167
  }
168
168
 
169
169
  function stopTracking(state) {