doxa-maps/library/utils/viewportScrim.js
Doxa Maps e19697673d
Some checks are pending
Deploy to GitHub Pages / build (push) Waiting to run
Deploy to GitHub Pages / deploy (push) Blocked by required conditions
Doxa Maps — the map bundler, 7 production plugins and the Experimental shelf
2026-09-25 20:07:59 +00:00

145 lines
9.2 KiB
JavaScript

/**
* viewportScrim.js — make a `position:fixed` full-screen SCRIM actually cover the VIEWPORT,
* in every host, including one that has captured the fixed containing block.
*
* ── THE DEFECT ────────────────────────────────────────────────────────────────────────────────
* A modal scrim is written `position:fixed; inset:0` on the assumption that fixed coordinates are
* viewport coordinates. That holds only while NOTHING in the ancestor chain establishes a
* containing block for fixed descendants. VerticalTabsLayout — the cap-5 shell every plugin is
* hosted in as a tab — sets `contain:layout` on .vtl-content, so inside the studio the SAME scrim
* stretches to the CONTENT PANE instead. MEASURED 2026-09-22 at a 1280x900 viewport, probe
* inserted into each component's real parent chain in the running studio:
*
* upg poster-wall .modal-scrim ......... 1024x793 @ (256,107)
* PosterWall .modal-scrim .............. 1024x793 @ (256,107)
* DataTable .dt-scrim .................. 1024x793 @ (256,107)
* SemanticTreeLegend / LegendTools ..... 1024x793 @ (256,107)
* ...all captured by: div.vtl-content [contain:layout]
*
* 256 is the rail width and 107 the header height. The scrim still LOOKS like a modal — it dims
* the pane it sits in — while leaving the rail and header UNCOVERED AND CLICKABLE behind it. That
* is why this reads as cosmetic and is not: a modal that does not capture input is not modal.
*
* ── WHY NOT THE OBVIOUS FIXES ─────────────────────────────────────────────────────────────────
* ✗ Drop `contain` from the layout. It is load-bearing for a different reason (it stops a rail
* collapse from reflowing a heavy mounted map) and removing it trades this bug for that one.
* Identical reasoning to FilterDropdown.fixedOrigin() — the fix belongs where the ASSUMPTION
* lives, not where the containment does.
* ✗ width:100vw; height:100vh. MEASURED: fixes the SIZE but NOT the offset — the box still starts
* at (256,64), so it overflows off-screen right and bottom while the rail stays uncovered. A
* viewport-sized scrim in the wrong place is still the wrong scrim.
* ✗ <Teleport to="body">. These components render INSIDE SHADOW ROOTS (measured: .dt-wrap's root
* is a ShadowRoot whose host is <cities-internet-map>). Teleporting to document.body leaves the
* shadow-scoped CSS behind and the modal renders UNSTYLED.
*
* ── THE FIX ───────────────────────────────────────────────────────────────────────────────────
* Do not ASSUME the containing block is the viewport — MEASURE it, exactly as fixedOrigin() does,
* then subtract it and size to the viewport. Verified to produce 0,0 1280x900 inside the captured
* pane. Correct in EVERY host, including hosts that do not exist yet: when nothing captures, the
* measured origin is (0,0) and the style collapses to plain inset:0.
*
* ── SHADOW-DOM NOTE (a false reassurance this replaces) ───────────────────────────────────────
* poster-wall.vue carried a comment saying "position:fixed inside a shadow root is still
* viewport-relative, so this covers the whole app". The premise is TRUE and IRRELEVANT: a shadow
* boundary scopes STYLES and SELECTORS, it does not create a containing block. What creates one is
* transform / filter / perspective / backdrop-filter / will-change of those — or contain:layout|
* paint|strict|content on an ancestor, which is exactly what the shell sets. Reasoning about the
* shadow boundary answers a question nobody asked and hides the one that matters.
*
* USAGE (in any component that owns a fixed full-screen scrim):
* import { useViewportScrim } from '<path>/library/utils/viewportScrim.js'
* const { scrimRef, scrimStyle } = useViewportScrim(someOpenRef)
* <div v-if="open" ref="scrimRef" :style="scrimStyle" class="modal-scrim">
* Keep the class: it still carries background, z-index, display/centring. Only the GEOMETRY moves
* into the inline style, because only the geometry depends on the host.
*/
import { ref, shallowRef, watch, onBeforeUnmount } from 'vue'
/**
* Measure the origin a `position:fixed` box inside `el` is laid out from.
*
* Reads the offset from a probe that is ITSELF position:fixed at 0,0: whatever it reports as its
* client rect IS the origin this element's fixed coordinates will be measured from. offsetParent
* walking is unreliable through shadow roots, so we measure rather than traverse.
*
* The probe MUST be inserted into the same parent as the real scrim so it shares the scrim's
* ancestor chain, and therefore its containing block. A probe appended to document.body would
* report the viewport even when the scrim is captured — confirming the assumption instead of
* testing it.
*/
export function measureFixedOrigin(el) {
const parent = el?.parentElement || el
if (!parent || !parent.ownerDocument) return { x: 0, y: 0 }
const probe = parent.ownerDocument.createElement('div')
probe.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;pointer-events:none;opacity:0'
parent.appendChild(probe)
const r = probe.getBoundingClientRect()
probe.remove()
return { x: r.left, y: r.top }
}
/**
* The geometry half of a full-viewport scrim, as an inline style object.
* Re-measured whenever the scrim opens and on resize — the rail can collapse while a modal is up,
* which moves the pane origin without remounting anything.
*
* @param {import('vue').Ref} openRef truthy while the scrim is rendered
* @returns {{ scrimRef: import('vue').Ref, scrimStyle: import('vue').Ref<object> }}
*/
export function useViewportScrim(openRef) {
const scrimRef = ref(null)
const scrimStyle = shallowRef({ position: 'fixed', inset: '0' })
const remeasure = () => {
const el = scrimRef.value
if (!el) return
const o = measureFixedOrigin(el)
// ★ NO `inset` KEY HERE. `inset` is a SHORTHAND for top/right/bottom/left; listing it beside
// the longhands in one style object lets it win on serialization order and SILENTLY DROP them.
// Measured exactly that: the element rendered `position:fixed; inset:auto; width:100vw;
// height:100vh` — right size (1280x900), wrong place (287,895), because top/left never made it
// into the inline style. Setting right/bottom to auto expresses "anchor by top/left only"
// without ever naming the shorthand.
// No capture (o is 0,0): this collapses to a box at 0,0 sized to the viewport — i.e. exactly
// what inset:0 meant in the first place.
// PIXELS FROM A MEASUREMENT rather than vw/vh: documentElement.clientWidth/Height is the
// laid-out viewport box and excludes any classic scrollbar gutter, which is what a scrim
// should cover. (Both forms measured identically once box-sizing was fixed below — the
// 1324x944 reading that first looked like a unit problem was padding, not units.)
const doc = el.ownerDocument || (typeof document !== 'undefined' ? document : null)
const vw = doc?.documentElement?.clientWidth || 0
const vh = doc?.documentElement?.clientHeight || 0
scrimStyle.value = {
position: 'fixed',
top: `${-o.y}px`,
left: `${-o.x}px`,
right: 'auto',
bottom: 'auto',
width: `${vw}px`,
height: `${vh}px`,
// ★ REQUIRED, because box-sizing is NOT inherited into a shadow root from the host page's
// reset (poster-wall.vue's own CSS says so at .tape). Several scrims carry padding — the upg
// .modal-scrim has padding:22px to inset its dialog — so under the default content-box the
// border box became width+44 x height+44: MEASURED 1324x944 for an explicit 1280px/900px.
// inset:0 never exposed this because pinning all four edges sizes the BORDER box directly;
// switching to width/height is what made box-sizing start to matter.
boxSizing: 'border-box',
}
}
// Measure AFTER the scrim is in the DOM — its own ancestors are what we are measuring.
// TWO frames, not one: PosterDialog renders its backdrop inside a <transition>, so on the first
// frame the element can be present but not yet laid out at its final place. Measuring once gave
// a stale origin there. The second pass is idempotent when the first was already right, so this
// costs nothing in the common case and removes a per-component timing assumption.
watch(openRef, (v) => {
if (!v) return
requestAnimationFrame(() => { remeasure(); requestAnimationFrame(remeasure) })
}, { flush: 'post' })
const onResize = () => { if (openRef.value) remeasure() }
if (typeof window !== 'undefined') window.addEventListener('resize', onResize)
onBeforeUnmount(() => { if (typeof window !== 'undefined') window.removeEventListener('resize', onResize) })
return { scrimRef, scrimStyle }
}