@wippy-fe/theme 0.0.47 → 0.0.49

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wippy-fe/theme",
3
- "version": "0.0.47",
3
+ "version": "0.0.49",
4
4
  "description": "Theme variables, shared Tailwind config, and PrimeVue styling for Wippy web components.",
5
5
  "type": "module",
6
6
  "main": "theme-config.css",
@@ -10,11 +10,16 @@
10
10
  "./tailwind.config": "./tailwind.config.ts",
11
11
  "./primevue-plugin": "./primevue-plugin.ts",
12
12
  "./tokens.json": "./tokens.json",
13
+ "./effective-contract.json": "./effective-contract.json",
14
+ "./tailwind-contract.json": "./tailwind-contract.json",
13
15
  "./primevue/*": "./primevue/*"
14
16
  },
15
17
  "files": [
16
18
  "theme-config.css",
17
19
  "tokens.json",
20
+ "effective-contract.json",
21
+ "tailwind-contract.json",
22
+ "contracts/",
18
23
  "tailwind.config.ts",
19
24
  "primevue-plugin.ts",
20
25
  "primevue/",
@@ -144,17 +144,6 @@
144
144
  focus-visible:outline-help-500 dark:focus-visible:outline-help-400
145
145
  }
146
146
 
147
- .p-button-help {
148
- @apply bg-help-500 enabled:hover:bg-help-600 enabled:active:bg-help-700
149
- border-help-500 enabled:hover:border-help-600 enabled:active:border-help-700
150
- text-white enabled:hover:text-white enabled:active:text-white
151
- dark:bg-help-400 dark:enabled:hover:bg-help-300 dark:enabled:active:bg-help-200
152
- dark:border-help-400 dark:enabled:hover:border-help-300 dark:enabled:active:border-help-200
153
- dark:text-help-950 dark:enabled:hover:text-help-950 dark:enabled:active:text-help-950
154
- focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2
155
- focus-visible:outline-help-500 dark:focus-visible:outline-help-400
156
- }
157
-
158
147
  .p-button-danger {
159
148
  @apply bg-danger-500 enabled:hover:bg-danger-600 enabled:active:bg-danger-700
160
149
  border-danger-500 enabled:hover:border-danger-600 enabled:active:border-danger-700
@@ -1,42 +1,42 @@
1
- .p-inputtext {
2
- @apply appearance-none
3
- border border-surface-300 dark:border-surface-700
4
- enabled:hover:border-surface-400 dark:enabled:hover:border-surface-600
5
- enabled:focus:border-primary dark:enabled:focus:border-primary
6
- bg-surface-0 dark:bg-surface-800
7
- text-surface-700 dark:text-surface-0
8
- disabled:bg-surface-200 disabled:text-surface-500 dark:disabled:bg-surface-700 dark:disabled:text-surface-400 disabled:opacity-100
9
- placeholder:text-surface-500 dark:placeholder:text-surface-400
10
- transition-colors
11
- outline-none;
12
- border-radius: var(--p-form-field-border-radius);
13
- padding: var(--p-form-field-padding-y) var(--p-form-field-padding-x);
14
- transition-duration: var(--p-form-field-transition-duration);
15
- box-shadow: var(--p-form-field-shadow);
16
- }
17
-
18
- .p-inputtext.p-invalid {
19
- @apply border-danger-400 dark:border-danger-300
20
- }
21
-
22
- .p-inputtext.p-variant-filled {
23
- @apply bg-surface-50 dark:bg-surface-800
24
- }
25
-
26
- .p-inputtext-sm {
27
- @apply text-sm;
28
- padding: var(--p-form-field-sm-padding-y) var(--p-form-field-sm-padding-x);
29
- }
30
-
31
- .p-inputtext-xs {
32
- @apply text-xs px-2 py-1
33
- }
34
-
35
- .p-inputtext-lg {
36
- @apply text-lg;
37
- padding: var(--p-form-field-lg-padding-y) var(--p-form-field-lg-padding-x);
38
- }
39
-
40
- .p-inputtext-fluid {
41
- @apply w-full
42
- }
1
+ .p-inputtext {
2
+ @apply appearance-none
3
+ border border-surface-300 dark:border-surface-700
4
+ enabled:hover:border-surface-400 dark:enabled:hover:border-surface-600
5
+ enabled:focus:border-primary dark:enabled:focus:border-primary
6
+ bg-surface-0 dark:bg-surface-800
7
+ text-surface-700 dark:text-surface-0
8
+ disabled:bg-surface-200 disabled:text-surface-500 dark:disabled:bg-surface-700 dark:disabled:text-surface-400 disabled:opacity-100
9
+ placeholder:text-surface-500 dark:placeholder:text-surface-400
10
+ transition-colors
11
+ outline-none;
12
+ border-radius: var(--p-form-field-border-radius);
13
+ padding: var(--p-form-field-padding-y) var(--p-form-field-padding-x);
14
+ transition-duration: var(--p-form-field-transition-duration);
15
+ box-shadow: var(--p-form-field-shadow);
16
+ }
17
+
18
+ .p-inputtext.p-invalid {
19
+ @apply border-danger-400 dark:border-danger-300
20
+ }
21
+
22
+ .p-inputtext.p-variant-filled {
23
+ @apply bg-surface-50 dark:bg-surface-800
24
+ }
25
+
26
+ .p-inputtext-sm {
27
+ @apply text-sm;
28
+ padding: var(--p-form-field-sm-padding-y) var(--p-form-field-sm-padding-x);
29
+ }
30
+
31
+ .p-inputtext-xs {
32
+ @apply text-xs px-2 py-1
33
+ }
34
+
35
+ .p-inputtext-lg {
36
+ @apply text-lg;
37
+ padding: var(--p-form-field-lg-padding-y) var(--p-form-field-lg-padding-x);
38
+ }
39
+
40
+ .p-inputtext-fluid {
41
+ @apply w-full
42
+ }
@@ -5,6 +5,9 @@ import { createCommentVNode, h, renderSlot, Teleport } from 'vue'
5
5
 
6
6
  const LAYER_ATTR = 'data-wippy-overlay-layer'
7
7
 
8
+ /** A per-overlay layer div carrying its overlay's trigger anchor-name for the observer. */
9
+ interface LayerWithAnchor extends HTMLElement { __wippyAnchorName?: string }
10
+
8
11
  /**
9
12
  * Client (browser) guard, equivalent to `@primeuix/utils`' `isClient` — inlined so
10
13
  * this plugin (which ships as raw `.ts` that every consumer typechecks) takes no
@@ -15,6 +18,135 @@ function isClient(): boolean {
15
18
  return typeof window !== 'undefined' && typeof window.document !== 'undefined'
16
19
  }
17
20
 
21
+ /**
22
+ * The browser Popover API (and thus the top layer) is available.
23
+ *
24
+ * We promote each overlay's own layer into the document's **top layer**
25
+ * so overlays escape every ancestor stacking context in that document — nested
26
+ * shadow hosts, sticky toolbars, a later sibling's positive `z-index` — for BOTH
27
+ * paint and hit-testing, with no `z-index` anywhere. The overlay stays inside its
28
+ * owning shadow root, so module-local CSS and inherited `--p-*` tokens are
29
+ * untouched (inheritance follows the flat tree, not the top-layer position). Widely
30
+ * available in modern engines (Chrome/Edge 114+, Firefox 125+, Safari 17+); older
31
+ * engines fall back to the pinned-layer-only behavior below (stacking imperfect,
32
+ * everything else unchanged). Evaluated lazily so SSR never touches `HTMLElement`.
33
+ */
34
+ function supportsTopLayer(): boolean {
35
+ return (
36
+ isClient()
37
+ && typeof HTMLElement !== 'undefined'
38
+ && Object.hasOwn(HTMLElement.prototype, 'popover')
39
+ )
40
+ }
41
+
42
+ /**
43
+ * CSS anchor positioning is available (S2). Implies the top layer. Lets a promoted
44
+ * overlay anchor to its trigger with pure CSS — no JS reposition loop — for the
45
+ * SELF-ANCHORED overlay families whose PrimeVue panel aligns to the component root,
46
+ * which is the Portal placeholder's DOM parent (`Select`, `MultiSelect`, `TreeSelect`,
47
+ * `CascadeSelect` — live-verified on `Select`; the others share the same align-to-root
48
+ * shape. `AutoComplete` is excluded — see {@link SELF_ANCHORED_OVERLAY}). Chrome/Edge
49
+ * 125+, Firefox 147+, Safari 26+. Anything else (older engine, or menus / popovers that
50
+ * align to an arbitrary caller-supplied target) keeps S1's pinned-layer positioning. S2 runs
51
+ * only behind this feature gate, and `applyAnchor` restores PrimeVue's own inline positioning
52
+ * verbatim (S1) if a promoted overlay can't enter the top layer — so the stacking fix always
53
+ * holds and positioning never regresses.
54
+ * (Tooltips never reach this plugin at all: PrimeVue's Tooltip is a directive that
55
+ * appends to `document.body`, outside `primevue/portal`.)
56
+ */
57
+ function supportsAnchor(): boolean {
58
+ return (
59
+ supportsTopLayer()
60
+ && typeof CSS !== 'undefined'
61
+ && typeof CSS.supports === 'function'
62
+ && CSS.supports('anchor-name: --a')
63
+ && CSS.supports('top', 'anchor(--a bottom)')
64
+ )
65
+ }
66
+
67
+ /**
68
+ * Overlay classes whose panel aligns to the COMPONENT ROOT — the Portal placeholder's
69
+ * DOM parent — so a unique `anchor-name` stamped on that root is the correct CSS anchor.
70
+ * `AutoComplete` is deliberately EXCLUDED: it aligns its panel to an inner element
71
+ * (`$refs.focusInput`/`multiContainer`), not the root, so anchoring to the root would
72
+ * offset it; it keeps S1 (PrimeVue's own positioning against the correct element).
73
+ */
74
+ const SELF_ANCHORED_OVERLAY = /\bp-(?:select|multiselect|treeselect|cascadeselect)-overlay\b/
75
+
76
+ /** Sequential id for per-trigger `anchor-name`s — collision-free and readable (Math.random isn't). */
77
+ let anchorSeq = 0
78
+
79
+ /** Per-overlay observer that re-applies S2 styles when PrimeVue re-aligns the overlay. */
80
+ const anchorStyleObservers = new WeakMap<HTMLElement, MutationObserver>()
81
+
82
+ /** Write the S2 positioning declarations that make the overlay a fixed, anchored popover. */
83
+ function setAnchorStyles(overlay: HTMLElement, anchorName: string): void {
84
+ const s = overlay.style
85
+ // Own `position:fixed` top-layer popover: promotion needs a popover, and `fixed` gives
86
+ // the viewport containing block that `anchor()` and the top layer resolve against
87
+ // (instead of the pinned 0×0 layer). PrimeVue's theme border/background/padding still
88
+ // win over the UA `[popover]` box (author > UA); we override only positioning.
89
+ s.setProperty('position', 'fixed', 'important')
90
+ s.setProperty('inset', 'auto', 'important')
91
+ s.setProperty('margin', '0', 'important')
92
+ s.setProperty('position-anchor', anchorName)
93
+ s.setProperty('top', `anchor(${anchorName} bottom)`, 'important')
94
+ s.setProperty('left', `anchor(${anchorName} left)`, 'important')
95
+ s.setProperty('position-try-fallbacks', 'flip-block')
96
+ }
97
+
98
+ /**
99
+ * S2: make a self-anchored overlay its own fixed, CSS-anchored top-layer popover (no-op
100
+ * for other overlays — they keep S1).
101
+ *
102
+ * PrimeVue keeps RE-aligning the overlay while it is open (`alignOverlay()` on every
103
+ * filter keystroke), plain-writing `top`/`margin` on the same
104
+ * inline `style`. Since an inline style has ONE slot per property, that plain write
105
+ * REPLACES our `!important` value+priority — so a one-shot write is not durable. We
106
+ * therefore also watch the overlay's own `style` and re-apply whenever PrimeVue clobbers
107
+ * it; the guard (`top` is no longer `!important`) makes the re-apply idempotent (never a
108
+ * loop). The observer is released when the overlay leaves its layer ({@link releaseAnchor}).
109
+ */
110
+ function applyAnchor(overlay: HTMLElement, anchorName: string): void {
111
+ if (!SELF_ANCHORED_OVERLAY.test(overlay.className))
112
+ return
113
+ const restore = overlay.style.cssText
114
+ setAnchorStyles(overlay, anchorName)
115
+ if (overlay.getAttribute('popover') !== 'manual')
116
+ overlay.setAttribute('popover', 'manual')
117
+ try {
118
+ if (!overlay.matches(':popover-open'))
119
+ overlay.showPopover()
120
+ }
121
+ catch { /* not connected / already open */ }
122
+ if (!overlay.matches(':popover-open')) {
123
+ // Promotion didn't take — restore PrimeVue's ORIGINAL inline styles verbatim (rather
124
+ // than clearing the S2 properties piecemeal, which would also wipe the top/left/margin
125
+ // PrimeVue computed for the overlay), so it degrades to a correctly-positioned S1
126
+ // overlay inside the layer.
127
+ overlay.removeAttribute('popover')
128
+ overlay.style.cssText = restore
129
+ return
130
+ }
131
+ if (!anchorStyleObservers.has(overlay)) {
132
+ const obs = new MutationObserver(() => {
133
+ if (overlay.style.getPropertyPriority('top') !== 'important')
134
+ setAnchorStyles(overlay, anchorName)
135
+ })
136
+ obs.observe(overlay, { attributes: true, attributeFilter: ['style'] })
137
+ anchorStyleObservers.set(overlay, obs)
138
+ }
139
+ }
140
+
141
+ /** Detach an overlay's S2 style observer once it leaves its layer (closed) or unmounts. */
142
+ function releaseAnchor(overlay: HTMLElement): void {
143
+ const obs = anchorStyleObservers.get(overlay)
144
+ if (obs) {
145
+ obs.disconnect()
146
+ anchorStyleObservers.delete(overlay)
147
+ }
148
+ }
149
+
18
150
  /** The `ShadowRoot` a node lives in, or null (light DOM). */
19
151
  function shadowRootOf(el: Node | null): ShadowRoot | null {
20
152
  const root = el && typeof el.getRootNode === 'function' ? el.getRootNode() : null
@@ -40,7 +172,10 @@ function shadowRootOf(el: Node | null): ShadowRoot | null {
40
172
  * origin; we then offset by it plus the scroll. This holds for any
41
173
  * TRANSLATION-only containing block and any window scroll.
42
174
  *
43
- * Residuals (documented):
175
+ * Residuals (documented) — these apply ONLY to the degraded, non-top-layer fallback
176
+ * (older engines, {@link supportsTopLayer} false). Once the layer is promoted (the
177
+ * primary path), its containing block is unconditionally the viewport, so the measured
178
+ * rect at `top:0;left:0` is (0,0) and none of these arise:
44
179
  * - SCALE / ROTATE / SKEW containing block: compensating via CSS `top`/`left`
45
180
  * only cancels the containing block's translation, not a non-identity linear
46
181
  * transform, so a `scale()`/`rotate()`/`skew()` ancestor mispositions the
@@ -67,10 +202,11 @@ function syncLayer(layer: HTMLElement): void {
67
202
  layer.style.left = `${-window.scrollX - rect.left}px`
68
203
  }
69
204
 
70
- // A SINGLE viewport-driven resync loop shared by every shadow-root layer — not a
71
- // listener pair per layer. When a `view.component` unmounts, its layer detaches with
72
- // the shadow root; this loop prunes detached layers on the next scroll/resize, so a
73
- // mount/unmount cycle leaks no window listeners, observers, or detached subtree.
205
+ // A SINGLE viewport-driven resync loop shared by every per-overlay layer — not a
206
+ // listener pair per layer. Layers are removed by `teardownOverlayLayer` (on unmount or a
207
+ // switch to inline); this loop is the backstop that also prunes any layer whose shadow
208
+ // root detached out from under it (e.g. a `view.component` torn down), so a cycle leaks
209
+ // no window listener, observer, or detached subtree.
74
210
  const activeLayers = new Set<HTMLElement>()
75
211
  const layerObservers = new WeakMap<HTMLElement, MutationObserver>()
76
212
  let viewportListenersAttached = false
@@ -80,6 +216,12 @@ function syncActiveLayers(): void {
80
216
  viewportSyncScheduled = false
81
217
  for (const layer of activeLayers) {
82
218
  if (!layer.isConnected) {
219
+ // Backstop for a layer whose shadow root detached without the Portal's `unmounted`
220
+ // running: release any overlay's S2 observer too (symmetry with `unmounted`).
221
+ for (const child of Array.from(layer.children)) {
222
+ if (child instanceof HTMLElement)
223
+ releaseAnchor(child)
224
+ }
83
225
  activeLayers.delete(layer)
84
226
  layerObservers.get(layer)?.disconnect()
85
227
  continue
@@ -97,19 +239,60 @@ function onViewportChange(): void {
97
239
  }
98
240
 
99
241
  /**
100
- * Get (or create) the per-shadow-root overlay layer, pinned to viewport origin and
101
- * kept in sync as overlays open (a per-layer `MutationObserver`) and as the page
102
- * scrolls/resizes (the one shared listener pair over {@link activeLayers}).
242
+ * Create a fresh per-OVERLAY layer (one per Portal instance, NOT shared per shadow root),
243
+ * pinned to viewport origin and kept in sync as its overlay opens (a per-layer
244
+ * `MutationObserver`) and as the page scrolls/resizes (the one shared listener pair over
245
+ * {@link activeLayers}). One overlay per layer keeps S2 anchor routing unambiguous even
246
+ * for components that open several overlays at once; {@link teardownOverlayLayer} removes
247
+ * it on unmount or a switch to inline.
103
248
  */
104
- function ensureLayer(root: ShadowRoot): HTMLElement {
105
- const existing = root.querySelector<HTMLElement>(`[${LAYER_ATTR}]`)
106
- if (existing)
107
- return existing
108
-
249
+ function createLayer(root: ShadowRoot): HTMLElement {
109
250
  const layer = document.createElement('div')
110
251
  layer.setAttribute(LAYER_ATTR, '')
111
- layer.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;'
252
+ const topLayer = supportsTopLayer()
253
+ // Inline reset of the UA `[popover]` styling — scoped to this one private element,
254
+ // NEVER a global rule that could leak across modules. A shown popover gets the UA
255
+ // box (position:fixed; inset:0; margin:auto; width/height:fit-content; border:solid;
256
+ // padding; background:Canvas; color:CanvasText). Left unreset, our empty 0×0 layer
257
+ // paints as a small white BORDERED box centered in the viewport (margin:auto + inset:0),
258
+ // and recolors the teleported overlay's text through inheritance. We zero every UA-set
259
+ // box property (including margin, so our syncLayer-owned top/left position it at origin);
260
+ // `color:inherit` restores the shadow-host inheritance chain for the teleported
261
+ // overlay; `overflow:visible` lets the absolutely-positioned overlay paint outside
262
+ // the 0×0 box. `!important` guards against a non-compliant theme's `[popover]` rule.
263
+ // `top`/`left` are deliberately OMITTED and left author-normal — syncLayer owns them,
264
+ // and its plain setProperty must keep winning (so we also never set `inset`, whose
265
+ // longhands would otherwise fight syncLayer's top/left).
266
+ layer.style.cssText = topLayer
267
+ ? 'position:fixed;width:0 !important;height:0 !important;margin:0 !important;border:0 !important;padding:0 !important;background:transparent !important;color:inherit !important;overflow:visible !important;'
268
+ : 'position:fixed;top:0;left:0;width:0;height:0;'
269
+ if (topLayer)
270
+ layer.setAttribute('popover', 'manual')
112
271
  root.appendChild(layer)
272
+ if (topLayer) {
273
+ // Enter the top layer. `manual` never light-dismisses, so PrimeVue stays the sole
274
+ // controller of open/close, Escape, and outside-click; focus stays untouched too, since
275
+ // neither the bare layer div nor PrimeVue's overlay markup carries an `autofocus` target
276
+ // for the popover focusing steps to act on (those run regardless of popover type).
277
+ // `teardownOverlayLayer` removes the layer (on unmount or a switch to inline),
278
+ // evicting it from the top layer; syncActiveLayers is the backstop if the shadow root
279
+ // detaches first.
280
+ try {
281
+ layer.showPopover()
282
+ }
283
+ catch {
284
+ // showPopover only throws if disconnected/already-open; the degrade below covers
285
+ // a layer that ended up not promoted regardless of throw-vs-silent-noop.
286
+ }
287
+ if (!layer.matches(':popover-open')) {
288
+ // Promotion did not take. NEVER leave `popover` set on a not-open element: the
289
+ // UA rule `[popover]:not(:popover-open){display:none}` would hide the layer AND
290
+ // every overlay teleported into it — silently worse than the original bug.
291
+ // Degrade cleanly to the pinned-layer-only behavior (syncLayer still positions).
292
+ layer.removeAttribute('popover')
293
+ layer.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;'
294
+ }
295
+ }
113
296
  syncLayer(layer)
114
297
 
115
298
  // Re-pin whenever an overlay is inserted — it may open after the page scrolled or
@@ -117,7 +300,23 @@ function ensureLayer(root: ShadowRoot): HTMLElement {
117
300
  // the layer's own style, so it never re-triggers this childList observer. The
118
301
  // observer is referenced only via the WeakMap, so it is disconnected when the layer
119
302
  // detaches (in syncActiveLayers) and otherwise GC'd with the layer.
120
- const observer = new MutationObserver(() => {
303
+ const observer = new MutationObserver((records) => {
304
+ // S2: this layer serves exactly ONE overlay (one Portal instance), so its
305
+ // `__wippyAnchorName` — the trigger anchor-name stamped by `setupOverlayLayer` — is unambiguous.
306
+ // Anchor the overlay when it teleports in; release its style observer when it leaves.
307
+ const anchorName = (layer as LayerWithAnchor).__wippyAnchorName
308
+ for (const rec of records) {
309
+ if (anchorName) {
310
+ for (const node of Array.from(rec.addedNodes)) {
311
+ if (node instanceof HTMLElement)
312
+ applyAnchor(node, anchorName)
313
+ }
314
+ }
315
+ for (const node of Array.from(rec.removedNodes)) {
316
+ if (node instanceof HTMLElement)
317
+ releaseAnchor(node)
318
+ }
319
+ }
121
320
  if (layer.isConnected && layer.childElementCount > 0)
122
321
  syncLayer(layer)
123
322
  })
@@ -143,6 +342,7 @@ interface PortalOptions {
143
342
  computed?: Record<string, (this: PortalInstance) => unknown>
144
343
  data?: (this: PortalInstance) => Record<string, unknown>
145
344
  mounted?: (this: PortalInstance) => void
345
+ unmounted?: (this: PortalInstance) => void
146
346
  render?: (this: PortalInstance) => VNode
147
347
  }
148
348
 
@@ -157,7 +357,54 @@ interface PortalInstance {
157
357
  mounted: boolean
158
358
  /** The shadow root this overlay lives in (captured once on mount), or null. */
159
359
  hostRoot: ShadowRoot | null
360
+ /** This overlay's OWN layer div (one per Portal instance); null in light DOM or while inline. */
361
+ layer: HTMLElement | null
362
+ /** Unique `anchor-name` wired onto this overlay's trigger for S2, or null. */
363
+ anchorName: string | null
364
+ /** The trigger element the anchor-name was stamped on (to clear it on teardown). */
365
+ anchorTrigger: HTMLElement | null
160
366
  resolvedTarget: Element | string | null
367
+ /** Vue instance watcher — used to react to `inline` toggling after mount. */
368
+ $watch: (source: string, cb: (value: boolean) => void) => () => void
369
+ }
370
+
371
+ /** Create this overlay's layer and wire its S2 anchor. Idempotent per instance. */
372
+ function setupOverlayLayer(inst: PortalInstance): void {
373
+ if (!inst.hostRoot || inst.layer)
374
+ return
375
+ // Eager: the layer is promoted to the top layer BEFORE the overlay opens, so PrimeVue
376
+ // never measures a not-yet-promoted box.
377
+ inst.layer = createLayer(inst.hostRoot)
378
+ // S2: stamp a unique `anchor-name` on the trigger (the placeholder's parent is the
379
+ // component root the self-anchored families align to) and hand it to this layer.
380
+ if (supportsAnchor()) {
381
+ const trigger = inst.$el ? (inst.$el as Node).parentElement : null
382
+ if (trigger) {
383
+ inst.anchorName = `--wippy-a-${++anchorSeq}`
384
+ inst.anchorTrigger = trigger
385
+ trigger.style.setProperty('anchor-name', inst.anchorName)
386
+ ;(inst.layer as LayerWithAnchor).__wippyAnchorName = inst.anchorName
387
+ }
388
+ }
389
+ }
390
+
391
+ /** Remove this overlay's layer and undo its S2 wiring (on unmount or a switch to inline). */
392
+ function teardownOverlayLayer(inst: PortalInstance): void {
393
+ if (inst.anchorTrigger) {
394
+ inst.anchorTrigger.style.removeProperty('anchor-name')
395
+ inst.anchorTrigger = null
396
+ }
397
+ inst.anchorName = null
398
+ if (inst.layer) {
399
+ for (const child of Array.from(inst.layer.children)) {
400
+ if (child instanceof HTMLElement)
401
+ releaseAnchor(child)
402
+ }
403
+ activeLayers.delete(inst.layer)
404
+ layerObservers.get(inst.layer)?.disconnect()
405
+ inst.layer.remove()
406
+ inst.layer = null
407
+ }
161
408
  }
162
409
 
163
410
  let patched = false
@@ -169,8 +416,8 @@ let patched = false
169
416
  * PrimeVue 4 has no global `appendTo` config, and the `@primeuix/utils` positioning
170
417
  * helpers are immutable ESM bindings. But every overlay teleports through the ONE
171
418
  * shared `primevue/portal`, whose options object is a mutable singleton. We rewrite it
172
- * so the DEFAULT `appendTo` (`'body'`, or null/undefined) teleports to a per-shadow-root
173
- * layer ({@link ensureLayer}) that is measured-and-pinned to true viewport origin
419
+ * so the DEFAULT `appendTo` (`'body'`, or null/undefined) teleports to a per-overlay
420
+ * layer ({@link createLayer}) that is measured-and-pinned to true viewport origin
174
421
  * ({@link syncLayer}) instead of `document.body`.
175
422
  *
176
423
  * Because `appendTo` is then not `'self'`, PrimeVue positions with `absolutePosition`
@@ -194,44 +441,68 @@ export function patchOverlayPortal(): void {
194
441
  portal.computed.inline = function (this: PortalInstance) {
195
442
  return Boolean(this.disabled) || this.appendTo === 'self'
196
443
  }
197
- // Reactive to `appendTo`: the default ('body'/null) resolves to the shadow-root
444
+ // Reactive to `appendTo`: the default ('body'/null) resolves to this overlay's own
198
445
  // layer (or document.body in light DOM); an explicit target passes through.
199
446
  portal.computed.resolvedTarget = function (this: PortalInstance) {
200
447
  if (this.appendTo == null || this.appendTo === 'body')
201
- return this.hostRoot ? ensureLayer(this.hostRoot) : document.body
448
+ return this.hostRoot ? this.layer : document.body
202
449
  return this.appendTo as Element | string
203
450
  }
204
451
 
205
452
  portal.data = function (this: PortalInstance) {
206
- return { mounted: false, hostRoot: null }
453
+ return { mounted: false, hostRoot: null, layer: null, anchorName: null, anchorTrigger: null }
207
454
  }
208
455
 
209
456
  portal.mounted = function (this: PortalInstance) {
210
457
  // Match upstream Portal: teleport is client-only. Guards DOM access below
211
- // (ensureLayer → createElement) if this ever runs without a document.
458
+ // (createLayer → createElement) if this ever runs without a document.
212
459
  if (!isClient())
213
460
  return
214
461
  // `$el` here is the placeholder comment node — capture its shadow root once.
215
462
  this.hostRoot = shadowRootOf(this.$el)
216
463
  this.mounted = true
217
- // Ensure the layer (and its resync observers) exist before the first render.
218
- // The per-open re-pin is driven by the layer's MutationObserver, not here —
219
- // this hook fires when the overlay component mounts, not when it opens.
220
- if (this.hostRoot)
221
- ensureLayer(this.hostRoot)
464
+ // Light DOM (no shadow root) always teleports to document.body — no layer, ever.
465
+ if (!this.hostRoot)
466
+ return
467
+ // Create the layer now if this overlay teleports; skip it while inline
468
+ // (`appendTo:'self'`/`disabled`) so nothing is allocated unused.
469
+ if (!this.inline)
470
+ setupOverlayLayer(this)
471
+ // `inline` is a computed over reactive props, and consumers DO flip `appendTo`
472
+ // ('body'↔'self') after mount (e.g. `use-shadow-append-to`). Mirror the layer
473
+ // lifecycle on that toggle: a switch to inline tears down the now-unused layer
474
+ // instead of orphaning it; a switch back re-creates it (else `resolvedTarget` would
475
+ // fall back to document.body, breaking out of the shadow root). Auto-cleaned on unmount.
476
+ this.$watch('inline', (nowInline) => {
477
+ if (nowInline)
478
+ teardownOverlayLayer(this)
479
+ else
480
+ setupOverlayLayer(this)
481
+ })
222
482
  }
223
483
 
224
484
  portal.render = function (this: PortalInstance) {
225
- // Keys mirror upstream's `key: 0`/`key: 1` so Vue replaces (never patches)
226
- // across the inline↔teleport branches if `inline` ever toggles reactively.
485
+ // The inline / teleport / not-yet-mounted branches return different vnode TYPES
486
+ // (slot fragment vs Teleport vs comment), so Vue replaces (never patches) across them
487
+ // if `inline` toggles reactively; the `key`s mirror upstream Portal's for parity.
227
488
  if (this.inline)
228
489
  return renderSlot(this.$slots as never, 'default', { key: 0 }) as unknown as VNode
229
490
  if (!this.mounted)
230
491
  return createCommentVNode('', true)
492
+ // Teleport the overlay AS-IS, a direct child of this overlay's own layer. Its
493
+ // anchor-name rides on the layer (set by `setupOverlayLayer`), so the observer anchors
494
+ // it — no per-render vnode surgery, so PrimeVue's overlay markup is untouched.
231
495
  return h(Teleport, { key: 1, to: this.resolvedTarget || document.body } as never, [
232
496
  renderSlot(this.$slots as never, 'default'),
233
497
  ])
234
498
  }
499
+
500
+ portal.unmounted = function (this: PortalInstance) {
501
+ // Each overlay OWNS its layer, so it removes it (and undoes its S2 wiring) on unmount
502
+ // — no orphaned 0×0 popovers, observers, listeners, or stale `anchor-name` across
503
+ // mount/unmount cycles. (The old shared per-root layer detached with the shadow root.)
504
+ teardownOverlayLayer(this)
505
+ }
235
506
  }
236
507
 
237
508
  export const PrimeVuePlugin = {