doxa-maps/library/layouts/StackLayout.vue

250 lines
15 KiB
Vue

<!--
StackLayout.vue — a brand-neutral, shared SCROLL-STACK layout for grouping elements top-to-bottom.
★ WHY THIS EXISTS (the second shared layout, alongside VerticalTabsLayout). VerticalTabsLayout shows
ONE element at a time (tabs, fixed-viewport panes). StackLayout does the OPPOSITE: it renders ALL of
its elements STACKED VERTICALLY, each at its NATURAL height, so the whole thing grows the page and the
HOST page scrolls through them — exactly how a normal document / a doxa-life / nuxtinator page flows.
Use it to GROUP two (or more) elements on top of each other and embed the group on a page you scroll.
★ SAME NESTED-ELEMENT CONTRACT as VerticalTabsLayout (so a shell composes other element-plugins). Pass
:sections="[{ key, title?, sub?, element, src?, attrs? }]". For each section:
element : a custom-element tag to render, e.g. "research-dashboard-map"
src? : a bundle URL to inject (once, deduped) if `element` isn't registered yet
attrs? : a map of attributes/props set on the element BEFORE it mounts ("props in advance")
title? : an optional section heading rendered above the element (omit for a bare stack)
A section can INSTEAD carry no `element` and use the #section-<key> slot (or the default slot) for
host-rendered content — `element` is an ALTERNATIVE, not a replacement.
★ EAGER, NOT LAZY (the deliberate difference from tabs). Because the whole point is to scroll through
everything, every section's element mounts on FIRST RENDER (not on activation). If you only want the
visible ones to load, that's the tabs layout's job; a scroll-stack shows all.
★ THE LAYOUT OWNS THE SCROLL — the elements do NOT. This is the Driver's scroll-page contract: a
plugin dropped in a section only needs ROOM to display at full height; it never needs its own
scrollbar. The root is `display:flow-root` with NO fixed height and NO `position:absolute; inset:0`
— so it takes its content's natural height and the PARENT PAGE scrolls through everything. Each
nested element is given `display:block; width:100%` and whatever height IT wants (a 900px dashboard
makes the page 900px taller). Because many of our plugins ship their own `position:absolute; inset:0`
/ `height:100%` (they were authored to fill a fixed-viewport tab pane), the host pane is defensive:
it is `position:relative` and DEMOTES an absolutely/fixed-positioned direct child back to
`position:static` so it lays out in the flow instead of collapsing the section to 0px. For such a
self-sizing element that still can't measure its own height in flow, pass :min-section-height (or a
section's own `minHeight`) to reserve room. Optionally cap a section with :max-section-height to give
a genuinely tall element an inner scroll instead of an endless page (default: no cap = pure page
scroll — the doxa-life / nuxtinator page-flow model).
★ EMBEDDING A LAYOUT INSIDE A LAYOUT (scroll-page in the cap-5 main). The cap-5 SPA's shell is
VerticalTabsLayout (sidebar + a FIXED-VIEWPORT content frame whose pane is `position:absolute;
inset:0`). A raw StackLayout dropped straight into that pane would be clipped by the inset overlay,
so the page can't grow. The proven layout-inside-a-layout path is therefore the IFRAME LEAF: a tab
is registered `mode:'iframe'` and its leaf page renders THIS layout at document root, where the
document itself owns the scroll. dashboard-plus-grid does exactly this (a cap-5 tab whose leaf is a
StackLayout) — scroll-page inside the vertical-tabs main, proven end to end.
★ SHADOW-DOM SAFE. Like VerticalTabsLayout, this is typically imported as a CHILD of a customElement
root, so its <style> would be dropped from the shadow root. It injects its critical CSS via
useShadowStyles() at setup(). Keep STL_SHADOW_CSS and the <style> mirror in sync.
Slots: #brand / #meta (an optional top bar — omitted entirely if neither is used) · #section-<key>
(host content for a section without `element`) · #default (a trailing host area at the bottom).
-->
<template>
<div class="stl">
<header class="stl-top" v-if="$slots.brand || $slots.meta || title">
<span class="stl-brand"><slot name="brand" /></span>
<b class="stl-title" v-if="title">{{ title }}</b>
<span class="stl-meta"><slot name="meta" /></span>
</header>
<div class="stl-stack">
<section
v-for="sec in sections" :key="sec.key" class="stl-sec"
:class="{ 'stl-sec-fill': hasDefiniteHeight(sec) }"
:style="sectionStyle(sec)">
<div class="stl-sec-head" v-if="sec.title">
<b class="stl-sec-title">{{ sec.title }}</b>
<span class="stl-sec-sub" v-if="sec.sub">{{ sec.sub }}</span>
</div>
<!-- element-bearing section → a host pane the element is mounted into imperatively. -->
<div v-if="sec.element" class="stl-host" :ref="el => registerHost(sec.key, el)"></div>
<!-- otherwise → host-rendered content via a per-section slot. -->
<div v-else class="stl-slot"><slot :name="'section-' + sec.key" /></div>
</section>
<div class="stl-tail"><slot /></div>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
// Inject a bundle script exactly once per URL (deduped across all layout instances). Same helper the
// tabs layout + the SPA use — resolves after the IIFE's customElements.define has run.
const _stlLoaded = {}
function injectScriptOnce(url) {
if (!url) return Promise.resolve()
if (_stlLoaded[url]) return _stlLoaded[url]
_stlLoaded[url] = new Promise((ok, fail) => {
const s = document.createElement('script')
s.src = url
s.onload = () => ok()
s.onerror = () => { delete _stlLoaded[url]; fail(new Error('failed to load ' + url)) }
document.head.appendChild(s)
})
return _stlLoaded[url]
}
function waitForDefine(tag, timeoutMs = 5000) {
if (typeof customElements === 'undefined') return Promise.resolve()
if (customElements.get(tag)) return Promise.resolve()
if (customElements.whenDefined) {
return Promise.race([
customElements.whenDefined(tag),
new Promise((_, r) => setTimeout(() => r(new Error('timeout waiting for <' + tag + '>')), timeoutMs))
])
}
return Promise.resolve()
}
// Mirror of the <style> block for the shadow root (kept comment/interp-free for the strip plugin).
const STL_SHADOW_CSS = `
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
.stl-brand { display:inline-flex; align-items:center; }
.stl-brand:empty { display:none; }
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.stl-stack { display:flex; flex-direction:column; }
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
.stl-sec:last-child { border-bottom:0; }
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
.stl-host { display:block; width:100%; min-width:0; position:relative; }
.stl-host > * { display:block; width:100%; }
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
.stl-slot { display:block; width:100%; }
.stl-tail:empty { display:none; }
.stl-sec-fill { position:relative; overflow:hidden; }
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
.stl ::-webkit-scrollbar { width:8px; height:8px; }
.stl ::-webkit-scrollbar-track { background:transparent; }
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.stl ::-webkit-scrollbar-corner { background:transparent; }
`
export default defineComponent({
name: 'StackLayout',
props: {
// [{ key, title?, sub?, element?, src?, attrs? }] — rendered top-to-bottom, all eager.
sections: { type: Array, default: () => [] },
// Optional top-bar title (omit for a bare stack with no header).
title: { type: String, default: '' },
// Optional per-section max height (e.g. '70vh') → that section gets an inner scroll instead of an
// unbounded page. Default '' = no cap, pure page scroll (the doxa-life/nuxtinator page-flow model).
maxSectionHeight: { type: String, default: '' },
// Optional default MIN height for every section (e.g. '60vh') → reserves room for a self-sizing
// element that ships its own fixed layout and can't measure its natural height in flow. A section
// can override per-item with its own `minHeight`. Default '' = no floor (pure natural height).
minSectionHeight: { type: String, default: '' }
},
setup() {
useShadowStyles(STL_SHADOW_CSS, 'stack-layout')
},
data() {
return { _hosts: {}, _mounted: {} }
},
mounted() {
// Eager: mount every element-bearing section on first render (scroll-through shows all).
for (const sec of (this.sections || [])) {
if (sec.element) this.mountSection(sec)
}
},
methods: {
registerHost(key, el) { if (el) this._hosts[key] = el },
// Per-section wrapper style: an optional inner-scroll cap (maxHeight) and/or a reserved floor
// (minHeight, per-section override → layout default). Returns null when neither applies so a plain
// section stays pure natural-height page-flow.
sectionStyle(sec) {
const s = {}
const max = sec.maxHeight || this.maxSectionHeight
const min = sec.minHeight || this.minSectionHeight
// A definite-height section (min===max) is a fixed BAND: it must NOT get an inner section scroll —
// the nested element (via .stl-sec-fill) fills it and owns any inner scroll, and the PAGE scrolls
// past it. Only a cap-WITHOUT-a-matching-floor gets the maxHeight inner-scroll (`overflow:auto`).
const definite = !!(max && min && max === min)
if (max) { s.maxHeight = max; if (!definite) s.overflow = 'auto' }
if (min) s.minHeight = min
return Object.keys(s).length ? s : null
},
// A section is "definite-height" when it pins BOTH a floor and a cap to the SAME value (min===max):
// its height is fixed, not natural. Such a section adds `.stl-sec-fill` so its host/slot + the nested
// element stretch to fill it — the escape hatch for a fixed-viewport plugin (e.g. the research
// dashboard) that ships `height:100%` and needs a definite parent height to resolve against, while
// the OUTER page still grows below the section so page-scroll reaches the next section.
hasDefiniteHeight(sec) {
const max = sec.maxHeight || this.maxSectionHeight
const min = sec.minHeight || this.minSectionHeight
return !!(max && min && max === min)
},
mountSection(sec) {
if (this._mounted[sec.key]) return
this._mounted[sec.key] = true
const host = this._hosts[sec.key]
if (!host) return
injectScriptOnce(sec.src)
.then(() => waitForDefine(sec.element))
.then(() => {
if (!customElements.get(sec.element)) return
const el = document.createElement(sec.element)
const attrs = sec.attrs || {}
for (const k in attrs) {
const val = attrs[k]
// props-in-advance: set as a property AND an attribute (custom elements read either).
try { el[k] = val } catch { /* ignore */ }
try { el.setAttribute(k, val == null ? '' : String(val)) } catch { /* ignore invalid attr name */ }
}
host.innerHTML = ''
host.appendChild(el)
})
.catch(() => { /* a failed section leaves its host empty; the rest of the stack still renders */ })
}
}
})
</script>
<style>
/* Mirror of STL_SHADOW_CSS for dev / light-DOM readability. Keep the two in sync.
Brand-neutral default dark theme; consumers override via the --stl-* custom properties. */
.stl { display:flow-root; width:100%; min-height:100%; background:#f3f3f1; color:#3b463d; font-family:'Public Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; -webkit-font-smoothing:antialiased; --stl-accent:#92b195; --stl-border:#d1d4cd; --stl-elevated:#ffffff; --stl-muted:#6d766e; }
.stl-top { display:flex; align-items:center; gap:10px; padding:11px 16px; border-bottom:1px solid var(--stl-border); background:#ffffff; position:sticky; top:0; z-index:5; }
.stl-brand { display:inline-flex; align-items:center; }
.stl-brand:empty { display:none; }
.stl-title { font-size:15px; font-weight:600; letter-spacing:-0.01em; color:#3b463d; }
.stl-meta { color:var(--stl-muted); font-size:12px; margin-left:auto; display:inline-flex; align-items:center; gap:8px; }
.stl-stack { display:flex; flex-direction:column; }
.stl-sec { display:flex; flex-direction:column; min-width:0; border-bottom:1px solid var(--stl-border); }
.stl-sec:last-child { border-bottom:0; }
.stl-sec-head { padding:12px 16px 8px; display:flex; align-items:baseline; gap:10px; }
.stl-sec-title { font-size:13px; font-weight:600; letter-spacing:.02em; color:#3b463d; }
.stl-sec-sub { font-size:12px; color:var(--stl-muted); }
.stl-host { display:block; width:100%; min-width:0; position:relative; }
.stl-host > * { display:block; width:100%; }
.stl-host > *[style*="absolute"], .stl-host > *[style*="fixed"] { position:static !important; inset:auto !important; }
.stl-slot { display:block; width:100%; }
.stl-tail:empty { display:none; }
.stl-sec-fill { position:relative; overflow:hidden; }
.stl-sec-fill > .stl-host, .stl-sec-fill > .stl-slot { position:absolute; inset:0; overflow:hidden; }
.stl-sec-fill > .stl-host > *, .stl-sec-fill > .stl-slot > * { height:100%; }
.stl, .stl * { scrollbar-width:thin; scrollbar-color:#c4c8c0 transparent; }
.stl ::-webkit-scrollbar { width:8px; height:8px; }
.stl ::-webkit-scrollbar-track { background:transparent; }
.stl ::-webkit-scrollbar-thumb { background:#c4c8c0; border-radius:8px; }
.stl ::-webkit-scrollbar-thumb:hover { background:#9aa197; }
.stl ::-webkit-scrollbar-corner { background:transparent; }
</style>