Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
109 lines
4.2 KiB
JavaScript
109 lines
4.2 KiB
JavaScript
/**
|
||
* AppShell — Layer 1 layout scaffold
|
||
*
|
||
* ┌─────────────────────────────────┐ ← fixed banner (top, optional)
|
||
* ├─────────────────────────────────┤
|
||
* │ message (optional, dismissible)│
|
||
* ├─────────────────────────────────┤
|
||
* │ │
|
||
* │ surface (fills remaining) │
|
||
* │ │
|
||
* ├─────────────────────────────────┤
|
||
* │ footer (optional) │
|
||
* ├─────────────────────────────────┤
|
||
* └─────────────────────────────────┘ ← fixed banner (bottom, same as top)
|
||
*
|
||
* Props:
|
||
* banner — { text, variant } or null (one config → top AND bottom)
|
||
* message — { text, variant } or null (dismissible)
|
||
* footer — VNode children or null
|
||
* children — Surface content
|
||
*/
|
||
const { html } = window;
|
||
const { useState, useEffect, useRef, useReducer } = hooks;
|
||
|
||
function ShellBanner({ position, text, variant = 'info' }) {
|
||
const ref = useRef(null);
|
||
const prop = position === 'top' ? '--banner-top-height' : '--banner-bottom-height';
|
||
|
||
useEffect(() => {
|
||
if (ref.current) {
|
||
const h = ref.current.offsetHeight + 'px';
|
||
document.documentElement.style.setProperty(prop, h);
|
||
}
|
||
return () => document.documentElement.style.setProperty(prop, '0px');
|
||
}, [text, prop]);
|
||
|
||
return html`
|
||
<div class="sw-shell__banner sw-shell__banner--${position} sw-shell__banner--${variant}" ref=${ref}>
|
||
<span class="sw-shell__banner-text">${text}</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Renders all components registered in a named slot.
|
||
* Re-renders when sw.slots changes (via 'slots.changed' event).
|
||
*/
|
||
function SlotRenderer({ name }) {
|
||
const [, tick] = useReducer(n => n + 1, 0);
|
||
|
||
useEffect(() => {
|
||
if (!window.sw) return;
|
||
const off = window.sw.on('slots.changed', (e) => {
|
||
if (e.slot === name) tick();
|
||
});
|
||
return () => { if (typeof off === 'function') off(); else window.sw.off('slots.changed', tick); };
|
||
}, [name]);
|
||
|
||
const items = window.sw?.slots?.get(name) || [];
|
||
if (!items.length) return null;
|
||
return html`<div class="sw-slot sw-slot--${name}">
|
||
${items.map(i => html`<${i.component} key=${i.id} />`)}
|
||
</div>`;
|
||
}
|
||
|
||
export function AppShell({ banner, message, footer, children }) {
|
||
const [msgDismissed, setMsgDismissed] = useState(false);
|
||
|
||
const showMsg = message && !msgDismissed;
|
||
|
||
return html`
|
||
<div class="sw-shell">
|
||
${banner && html`
|
||
<${ShellBanner} position="top" text=${banner.text} variant=${banner.variant} />
|
||
`}
|
||
|
||
<div class="sw-shell__body">
|
||
${showMsg && html`
|
||
<div class="sw-shell__announcement">
|
||
<div class="sw-shell__announcement-inner sw-shell__announcement--${message.variant || 'info'}">
|
||
<span class="sw-shell__announcement-text">${message.text}</span>
|
||
<button class="sw-shell__banner-close" onClick=${() => setMsgDismissed(true)} aria-label="Dismiss">×</button>
|
||
</div>
|
||
</div>
|
||
`}
|
||
|
||
<${SlotRenderer} name="toolbar" />
|
||
<${SlotRenderer} name="surface.header" />
|
||
|
||
<main class="sw-shell__surface">
|
||
${children}
|
||
</main>
|
||
|
||
${footer && html`
|
||
<footer class="sw-shell__footer">
|
||
${footer}
|
||
</footer>
|
||
`}
|
||
|
||
<${SlotRenderer} name="statusbar" />
|
||
</div>
|
||
|
||
${banner && html`
|
||
<${ShellBanner} position="bottom" text=${banner.text} variant=${banner.variant} />
|
||
`}
|
||
</div>
|
||
`;
|
||
}
|