250 lines
15 KiB
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>
|