@wippy-fe/theme 0.0.43 → 0.0.45

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/THEMING.md CHANGED
@@ -680,9 +680,17 @@ The `tailwind.css` master file imports all component CSS files organized by cate
680
680
 
681
681
  PrimeVue overlays — `Dialog`, `Drawer`, `Select`, `Popover`, `Menu`, `MultiSelect`, … — teleport their panel through one shared primitive (`primevue/portal`) whose default target is `document.body`. Inside a **web component** that escapes the shadow root, so the panel loses the module's shadow-scoped CSS (and the injected `primeVueCssUrl`) and renders unstyled next to `<body>`.
682
682
 
683
- `PrimeVuePlugin` handles this for you: at `install()` it flips the shared Portal's `appendTo` default from `'body'` to `'self'`, so every overlay renders **inline, inside the shadow root**, by default — you do nothing. A component that passes its own `appendTo` (e.g. `<Dialog appendTo="body">`) still wins; the override only changes the default. This matters most for `Drawer`, which hard-wires its Portal with no `appendTo` and so is the one overlay that otherwise *always* escapes.
683
+ `PrimeVuePlugin` handles this for you: at `install()` it rewrites the shared Portal so the default `appendTo` (`'body'`) teleports each overlay to a per-shadow-root `position: fixed` zero-size anchor. The overlay stays **inside the shadow root** (so the shadow-scoped CSS and the injected `primeVueCssUrl` apply) and — because `appendTo` is not `'self'` — PrimeVue positions it with `absolutePosition` at the trigger, **unclipped** by any scrolling ancestor (e.g. a `Dialog` / `Drawer` body with `overflow: auto`). Outside a shadow root (the host SPA) it falls back to `document.body`. You do nothing per component.
684
684
 
685
- > PrimeVue 4 exposes no global overlay / `appendTo` config, and all overlays share the one Portal — so this single default-flip is the whole fix. No per-component `appendTo="self"` boilerplate and no per-app `patch-package` needed. (Requires `@wippy-fe/theme` with this behavior; see the package version.)
685
+ The anchor is **measured, not assumed**. `absolutePosition` writes *document* coordinates, so the anchor must sit at the true viewport origin for an absolutely-positioned overlay to land correctly. A `position: fixed` element is only at the viewport origin when no ancestor establishes a containing block — but `transform` / `filter` / `perspective` / `will-change` / `contain` on any ancestor (a `translateZ(0)` GPU hint, a slide/scale animation, a virtualized list) makes *that* ancestor the containing block instead, and a scrolled window shifts it too. So the plugin reads the anchor's own `getBoundingClientRect()` and offsets it by `-scrollX / -scrollY` to pin it at viewport `(0,0)`, re-syncing when an overlay opens (a `MutationObserver`) and on scroll/resize. This is what keeps overlays aligned under a transformed ancestor or a scrolled page. A reactive `append-to` is honored (`resolvedTarget` is a computed). **`Tooltip` is the one exception** — it is a directive that appends to `document.body`, bypassing `primevue/portal`, so it renders in the light DOM and is not shadow-scoped by this plugin.
686
+
687
+ > **Do not set `append-to="self"`.** `'self'` makes PrimeVue render the overlay *inline* and position it relatively, so an overlay whose trigger sits inside a scrolling `Dialog` / `Drawer` body gets clipped — this was the regression in `@wippy-fe/theme` ≤ 0.0.43 (web-host ≤ 1.0.43). With this plugin, leave `appendTo` unset and inherit the shadow-root portal. (PrimeVue 4 has no global `appendTo` config and the `@primeuix/utils` positioning helpers are immutable, but the shared Portal is a singleton rewritten once at install — no per-app `patch-package` needed.)
688
+ >
689
+ > **Residuals** (all uncommon; none affects a static, translation-only layout):
690
+ > - **Scale / rotate / skew ancestor:** the compensation cancels a containing block's *translation* only, so a `scale()`/`rotate()`/`skew()` ancestor mis-positions the overlay. `translate` / `translateZ(0)` (the common case) is handled.
691
+ > - **Clipping:** an ancestor that both establishes the fixed containing block *and* clips (`transform`/`contain`/`will-change` + `overflow != visible`, or `contain: paint/strict/content`, or `overflow: clip`) can still clip an overlay extending beyond its box — intrinsic to staying in the shadow root.
692
+ > - **Animating ancestor:** a transform animating while an overlay is open lags until the next scroll/resize.
693
+ > - **`Toast`:** it self-positions `position: fixed`, so under a transformed ancestor it resolves against that ancestor, not the viewport. Wippy routes toasts to the host, so this isn't hit in practice.
686
694
 
687
695
  ### Available PrimeVue component styles
688
696
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wippy-fe/theme",
3
- "version": "0.0.43",
3
+ "version": "0.0.45",
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",
@@ -50,6 +50,34 @@
50
50
  @apply w-4 h-4
51
51
  }
52
52
 
53
+ /*
54
+ * Screen-reader-only content. PrimeVue marks accessibility text with these classes
55
+ * and expects the theme's base CSS to hide them — e.g. Select's `aria-live` selection
56
+ * announcer ("No selected item"), and dialog focus-trap sentinels. In `theme: 'none'`
57
+ * mode we own the base CSS, so without this the announcer text renders VISIBLY (it
58
+ * showed up under an open Select once overlays moved into the shadow root). Matches
59
+ * PrimeVue's canonical `.p-hidden-accessible` definition.
60
+ */
61
+ .p-hidden-accessible,
62
+ .p-hidden-focusable {
63
+ border: 0;
64
+ clip: rect(0 0 0 0);
65
+ clip-path: inset(50%);
66
+ height: 1px;
67
+ margin: -1px;
68
+ overflow: hidden;
69
+ padding: 0;
70
+ position: absolute;
71
+ width: 1px;
72
+ white-space: nowrap;
73
+ user-select: none;
74
+ }
75
+
76
+ .p-hidden-accessible input,
77
+ .p-hidden-accessible select {
78
+ transform: scale(0);
79
+ }
80
+
53
81
  .p-overlay-mask {
54
82
  @apply bg-black/50 text-surface-200 transition-colors duration-150 fixed top-0 start-0 w-full h-full
55
83
  }
@@ -1,33 +1,244 @@
1
- import type { App } from 'vue'
2
- import PrimeVue from 'primevue/config'
3
- import Portal from 'primevue/portal'
4
-
5
- /**
6
- * Make PrimeVue overlays render inside the shadow root by default.
7
- *
8
- * Every PrimeVue overlay (Dialog, Drawer, Select, Popover, Menu, …) teleports
9
- * through the ONE shared `primevue/portal`, whose `appendTo` default is `'body'`.
10
- * Inside a web component that escapes the shadow root, so the module's
11
- * shadow-scoped CSS no longer styles the overlay. Most components expose
12
- * `appendTo` (an app can pass `'self'`), but `Drawer` hard-wires its Portal with
13
- * no `appendTo`, so it ALWAYS escapes — and PrimeVue 4 has no global config for
14
- * this. Flipping the shared Portal's default to `'self'` fixes every overlay at
15
- * once; the components that pass their own `appendTo` are unaffected.
16
- *
17
- * Must run before the first overlay renders (install time is early enough — Vue
18
- * normalizes a component's prop defaults lazily on first instantiation).
19
- */
20
- function keepOverlaysInShadowRoot(): void {
21
- const appendTo = (Portal as any)?.props?.appendTo
22
- if (appendTo && appendTo.default === 'body')
23
- appendTo.default = 'self'
24
- }
25
-
26
- export const PrimeVuePlugin = {
27
- install: (app: App) => {
28
- keepOverlaysInShadowRoot()
29
- app.use(PrimeVue, { theme: 'none' })
30
- },
31
- }
32
-
33
- export default PrimeVuePlugin
1
+ import type { App, VNode } from 'vue'
2
+ import PrimeVue from 'primevue/config'
3
+ import Portal from 'primevue/portal'
4
+ import { createCommentVNode, h, renderSlot, Teleport } from 'vue'
5
+
6
+ const LAYER_ATTR = 'data-wippy-overlay-layer'
7
+
8
+ /**
9
+ * Client (browser) guard, equivalent to `@primeuix/utils`' `isClient` — inlined so
10
+ * this plugin (which ships as raw `.ts` that every consumer typechecks) takes no
11
+ * dependency on `@primeuix/utils`, which is only a transitive dep of `primevue` and
12
+ * is not guaranteed to resolve at the `/dom` subpath in a consumer's build.
13
+ */
14
+ function isClient(): boolean {
15
+ return typeof window !== 'undefined' && typeof window.document !== 'undefined'
16
+ }
17
+
18
+ /** The `ShadowRoot` a node lives in, or null (light DOM). */
19
+ function shadowRootOf(el: Node | null): ShadowRoot | null {
20
+ const root = el && typeof el.getRootNode === 'function' ? el.getRootNode() : null
21
+ return root instanceof ShadowRoot ? root : null
22
+ }
23
+
24
+ /**
25
+ * Pin `layer` (a `position: fixed` element) so its viewport origin is exactly
26
+ * `(-scrollX, -scrollY)` — i.e. document coordinate `(0,0)` maps to the layer's
27
+ * top-left corner.
28
+ *
29
+ * PrimeVue's `absolutePosition` writes DOCUMENT coordinates to an overlay
30
+ * (`trigger.getBoundingClientRect()` + window scroll). The overlay is
31
+ * `position: absolute`, so those coordinates resolve against this layer. Placing
32
+ * the layer at `(-scrollX, -scrollY)` makes an absolute child at document coord D
33
+ * render at viewport `D - scroll` — the correct on-screen spot.
34
+ *
35
+ * We MEASURE instead of assuming "fixed == viewport origin": a fixed element's
36
+ * containing block is the nearest ancestor with `transform` / `filter` /
37
+ * `perspective` / `will-change` / `contain` (extremely common — `translateZ(0)`
38
+ * GPU hints, slide animations, virtualized lists), NOT necessarily the viewport.
39
+ * Reading the rect at `top:0;left:0` yields that containing block's viewport
40
+ * origin; we then offset by it plus the scroll. This holds for any
41
+ * TRANSLATION-only containing block and any window scroll.
42
+ *
43
+ * Residuals (documented):
44
+ * - SCALE / ROTATE / SKEW containing block: compensating via CSS `top`/`left`
45
+ * only cancels the containing block's translation, not a non-identity linear
46
+ * transform, so a `scale()`/`rotate()`/`skew()` ancestor mispositions the
47
+ * overlay (by `(s-1)·distance` under `scale(s)`). Rare for a shadow-host
48
+ * ancestor; a full fix would need the ancestor's transform matrix. Translation
49
+ * (`translate`, `translateZ(0)`) is the common case and is handled.
50
+ * - CLIPPING: an ancestor that both establishes the fixed containing block AND
51
+ * clips (`transform`/`contain`/`will-change` + `overflow != visible`, or
52
+ * `contain: paint/strict/content`, or `overflow: clip`) can still clip an
53
+ * overlay that extends beyond its box — intrinsic to staying in the shadow root.
54
+ * - ANIMATING ancestor: if the containing block MOVES between resyncs (a
55
+ * transform animating while an overlay is open) the overlay lags until the
56
+ * next scroll/resize.
57
+ * - `position: fixed` self-positioning overlays (Toast) resolve against a
58
+ * transformed containing block, not the viewport, so they are offset under such
59
+ * an ancestor (their own fixed CSS, not `absolutePosition`, is unaffected by the
60
+ * layer). Wippy bridges toasts to the host, so this is not hit in practice.
61
+ */
62
+ function syncLayer(layer: HTMLElement): void {
63
+ layer.style.top = '0px'
64
+ layer.style.left = '0px'
65
+ const rect = layer.getBoundingClientRect()
66
+ layer.style.top = `${-window.scrollY - rect.top}px`
67
+ layer.style.left = `${-window.scrollX - rect.left}px`
68
+ }
69
+
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.
74
+ const activeLayers = new Set<HTMLElement>()
75
+ const layerObservers = new WeakMap<HTMLElement, MutationObserver>()
76
+ let viewportListenersAttached = false
77
+ let viewportSyncScheduled = false
78
+
79
+ function syncActiveLayers(): void {
80
+ viewportSyncScheduled = false
81
+ for (const layer of activeLayers) {
82
+ if (!layer.isConnected) {
83
+ activeLayers.delete(layer)
84
+ layerObservers.get(layer)?.disconnect()
85
+ continue
86
+ }
87
+ if (layer.childElementCount > 0)
88
+ syncLayer(layer)
89
+ }
90
+ }
91
+
92
+ function onViewportChange(): void {
93
+ if (viewportSyncScheduled)
94
+ return
95
+ viewportSyncScheduled = true
96
+ requestAnimationFrame(syncActiveLayers)
97
+ }
98
+
99
+ /**
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}).
103
+ */
104
+ function ensureLayer(root: ShadowRoot): HTMLElement {
105
+ const existing = root.querySelector<HTMLElement>(`[${LAYER_ATTR}]`)
106
+ if (existing)
107
+ return existing
108
+
109
+ const layer = document.createElement('div')
110
+ layer.setAttribute(LAYER_ATTR, '')
111
+ layer.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;'
112
+ root.appendChild(layer)
113
+ syncLayer(layer)
114
+
115
+ // Re-pin whenever an overlay is inserted — it may open after the page scrolled or
116
+ // the layout shifted (the layer isn't synced while empty). syncLayer only mutates
117
+ // the layer's own style, so it never re-triggers this childList observer. The
118
+ // observer is referenced only via the WeakMap, so it is disconnected when the layer
119
+ // detaches (in syncActiveLayers) and otherwise GC'd with the layer.
120
+ const observer = new MutationObserver(() => {
121
+ if (layer.isConnected && layer.childElementCount > 0)
122
+ syncLayer(layer)
123
+ })
124
+ observer.observe(layer, { childList: true })
125
+ layerObservers.set(layer, observer)
126
+
127
+ activeLayers.add(layer)
128
+ if (!viewportListenersAttached) {
129
+ viewportListenersAttached = true
130
+ // Capture phase so a scrolling ancestor (not just the window) also triggers a
131
+ // resync; passive since we never preventDefault. One pair total, for all layers.
132
+ window.addEventListener('scroll', onViewportChange, { capture: true, passive: true })
133
+ window.addEventListener('resize', onViewportChange, { passive: true })
134
+ }
135
+ return layer
136
+ }
137
+
138
+ /**
139
+ * PrimeVue's shared `primevue/portal` is an Options-API component object (a singleton
140
+ * shared by every overlay). We rewrite the render-relevant options on it in place.
141
+ */
142
+ interface PortalOptions {
143
+ computed?: Record<string, (this: PortalInstance) => unknown>
144
+ data?: (this: PortalInstance) => Record<string, unknown>
145
+ mounted?: (this: PortalInstance) => void
146
+ render?: (this: PortalInstance) => VNode
147
+ }
148
+
149
+ interface PortalInstance {
150
+ /** The Portal's `$el` — a comment `Node` (placeholder vnode) before teleport. */
151
+ $el: Node | null
152
+ $slots: Record<string, unknown>
153
+ disabled?: boolean
154
+ /** `'self'` (inline), `'body'`/null/undefined (teleport), or an explicit target. */
155
+ appendTo?: unknown
156
+ inline: boolean
157
+ mounted: boolean
158
+ /** The shadow root this overlay lives in (captured once on mount), or null. */
159
+ hostRoot: ShadowRoot | null
160
+ resolvedTarget: Element | string | null
161
+ }
162
+
163
+ let patched = false
164
+
165
+ /**
166
+ * Make PrimeVue overlays render inside the shadow root — unclipped and correctly
167
+ * positioned — by default, without any per-component `appendTo`.
168
+ *
169
+ * PrimeVue 4 has no global `appendTo` config, and the `@primeuix/utils` positioning
170
+ * helpers are immutable ESM bindings. But every overlay teleports through the ONE
171
+ * 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
174
+ * ({@link syncLayer}) instead of `document.body`.
175
+ *
176
+ * Because `appendTo` is then not `'self'`, PrimeVue positions with `absolutePosition`
177
+ * (document-origin coords), which the pinned layer matches — so overlays are NOT
178
+ * clipped by a scrolling Dialog/Drawer body and land at the trigger, even under a
179
+ * transformed/filtered ancestor or a scrolled window. `'self'` keeps its inline
180
+ * semantics (explicit opt-in); outside a shadow root everything falls back to
181
+ * `document.body`. `resolvedTarget` is a computed, so a reactive `appendTo` is honored.
182
+ *
183
+ * Runs once at plugin install (before the first overlay renders); the guard makes
184
+ * repeat installs safe.
185
+ */
186
+ export function patchOverlayPortal(): void {
187
+ if (patched)
188
+ return
189
+ patched = true
190
+
191
+ const portal = Portal as unknown as PortalOptions
192
+
193
+ portal.computed = portal.computed || {}
194
+ portal.computed.inline = function (this: PortalInstance) {
195
+ return Boolean(this.disabled) || this.appendTo === 'self'
196
+ }
197
+ // Reactive to `appendTo`: the default ('body'/null) resolves to the shadow-root
198
+ // layer (or document.body in light DOM); an explicit target passes through.
199
+ portal.computed.resolvedTarget = function (this: PortalInstance) {
200
+ if (this.appendTo == null || this.appendTo === 'body')
201
+ return this.hostRoot ? ensureLayer(this.hostRoot) : document.body
202
+ return this.appendTo as Element | string
203
+ }
204
+
205
+ portal.data = function (this: PortalInstance) {
206
+ return { mounted: false, hostRoot: null }
207
+ }
208
+
209
+ portal.mounted = function (this: PortalInstance) {
210
+ // Match upstream Portal: teleport is client-only. Guards DOM access below
211
+ // (ensureLayer → createElement) if this ever runs without a document.
212
+ if (!isClient())
213
+ return
214
+ // `$el` here is the placeholder comment node — capture its shadow root once.
215
+ this.hostRoot = shadowRootOf(this.$el)
216
+ 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)
222
+ }
223
+
224
+ 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.
227
+ if (this.inline)
228
+ return renderSlot(this.$slots as never, 'default', { key: 0 }) as unknown as VNode
229
+ if (!this.mounted)
230
+ return createCommentVNode('', true)
231
+ return h(Teleport, { key: 1, to: this.resolvedTarget || document.body } as never, [
232
+ renderSlot(this.$slots as never, 'default'),
233
+ ])
234
+ }
235
+ }
236
+
237
+ export const PrimeVuePlugin = {
238
+ install: (app: App) => {
239
+ patchOverlayPortal()
240
+ app.use(PrimeVue, { theme: 'none' })
241
+ },
242
+ }
243
+
244
+ export default PrimeVuePlugin
package/tokens.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "generatedFrom": "theme-config.css",
3
- "version": "1.0.43",
3
+ "version": "1.0.45",
4
4
  "grammar": "every color family = --p-<fam> + --p-<fam>-{50..950} + --p-<fam>-{color,contrast-color,hover-color,active-color}; --p-surface-<shade>; text/content/highlight semantic aliases; --p-font-<role>-<prop>",
5
5
  "families": [
6
6
  "primary",