diff --git a/web/components.json b/web/components.json index 4d6c2ee..36d2ab3 100644 --- a/web/components.json +++ b/web/components.json @@ -2,6 +2,7 @@ "components/login-gate/login-gate.js", "components/api-client/api-client.js", "components/side-nav/side-nav.js", + "components/side-panel/side-panel.js", "components/logout-button/logout-button.js", "components/date-display/date-display.js" ] diff --git a/web/components.yml b/web/components.yml index 646d294..f2878e3 100644 --- a/web/components.yml +++ b/web/components.yml @@ -15,6 +15,7 @@ local: - side-nav - logout-button - date-display + - side-panel # Remote components fetched from git at build time. # Each is its own repo; its root must contain .js and manifest.json. diff --git a/web/components/side-panel/manifest.json b/web/components/side-panel/manifest.json new file mode 100644 index 0000000..460f54b --- /dev/null +++ b/web/components/side-panel/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "side-panel", + "version": "0.1.0", + "description": "Configurable right-side drawer. Multiple instances stack independently. Opens declaratively or via SidePanel.open()." +} diff --git a/web/components/side-panel/side-panel.js b/web/components/side-panel/side-panel.js new file mode 100644 index 0000000..9feaa4d --- /dev/null +++ b/web/components/side-panel/side-panel.js @@ -0,0 +1,236 @@ +// Configurable right-side drawer panel. +// +// ── Declarative ────────────────────────────────────────────────────────────── +// +// +// +// +// panel.open() +// panel.close() +// +// ── Programmatic (from any component) ──────────────────────────────────────── +// +// import { SidePanel } from '../side-panel/side-panel.js' +// +// const panel = SidePanel.open({ +// title: 'VPC — vp-admin', +// width: '520px', +// content: '', +// }) +// panel.close() +// +// ── Multiple panels ─────────────────────────────────────────────────────────── +// +// Each open panel stacks with a slight depth offset. +// Clicking the backdrop closes only the topmost panel. +// ESC closes the topmost panel. + +const CLOSE_ICON = ` + +` + +const STACK_OFFSET = 12 // px shift per stacked panel +const ANIM_DURATION = 220 // ms + +function openCount() { + return document.querySelectorAll('side-panel[data-open]').length +} + +function topPanel() { + const panels = [...document.querySelectorAll('side-panel[data-open]')] + return panels[panels.length - 1] ?? null +} + +// Global ESC handler — closes topmost panel only. +document.addEventListener('keydown', e => { + if (e.key === 'Escape') topPanel()?.close() +}) + +export class SidePanel extends HTMLElement { + // ── Static factory ────────────────────────────────────────────────────────── + + static open({ title = '', content = '', width = '480px' } = {}) { + const panel = document.createElement('side-panel') + if (title) panel.setAttribute('title', title) + if (width) panel.setAttribute('width', width) + if (content) panel.innerHTML = content + document.body.appendChild(panel) + panel.open() + return panel + } + + // ── Lifecycle ─────────────────────────────────────────────────────────────── + + connectedCallback() { + this.attachShadow({ mode: 'open' }) + this.#render() + } + + // ── Public API ────────────────────────────────────────────────────────────── + + open() { + const depth = openCount() + const offset = depth * STACK_OFFSET + + // Shift panel left based on stack depth + this.style.setProperty('--offset', `${offset}px`) + this.setAttribute('data-open', '') + + // Backdrop: only show/darken the shared one + this.#ensureBackdrop() + + this.dispatchEvent(new CustomEvent('panel-open', { bubbles: true })) + } + + close() { + if (!this.hasAttribute('data-open')) return + + this.removeAttribute('data-open') + this.#updateBackdrop() + + this.addEventListener('transitionend', () => { + // If created programmatically (appended to body by factory), remove from DOM + if (this.dataset.programmatic) this.remove() + }, { once: true }) + + this.dispatchEvent(new CustomEvent('panel-close', { bubbles: true })) + } + + // ── Rendering ─────────────────────────────────────────────────────────────── + + #render() { + const title = this.getAttribute('title') ?? '' + const width = this.getAttribute('width') ?? '480px' + + this.shadowRoot.innerHTML = ` + + +
+ ${title} + +
+
+ +
+ ` + + this.shadowRoot.querySelector('.close').addEventListener('click', () => this.close()) + + // Mark programmatic panels so they self-remove on close + if (!this.parentElement || this.parentElement === document.body) { + this.dataset.programmatic = 'true' + } + } + + // ── Backdrop ───────────────────────────────────────────────────────────────── + + #ensureBackdrop() { + let bd = document.getElementById('__side-panel-backdrop__') + if (!bd) { + bd = document.createElement('div') + bd.id = '__side-panel-backdrop__' + Object.assign(bd.style, { + position: 'fixed', inset: '0', + background: 'rgba(0,0,0,0)', + transition: `background ${ANIM_DURATION}ms`, + zIndex: '999', + }) + bd.addEventListener('click', () => topPanel()?.close()) + document.body.appendChild(bd) + } + // Opacity scales with stack depth (max 0.5) + const opacity = Math.min(0.5, openCount() * 0.15) + requestAnimationFrame(() => { bd.style.background = `rgba(0,0,0,${opacity})` }) + } + + #updateBackdrop() { + const bd = document.getElementById('__side-panel-backdrop__') + if (!bd) return + const remaining = openCount() + if (remaining === 0) { + bd.style.background = 'rgba(0,0,0,0)' + bd.addEventListener('transitionend', () => bd.remove(), { once: true }) + } else { + const opacity = Math.min(0.5, remaining * 0.15) + bd.style.background = `rgba(0,0,0,${opacity})` + } + } +} + +customElements.define('side-panel', SidePanel) diff --git a/web/index.html b/web/index.html index 4c75ef4..19127ed 100644 --- a/web/index.html +++ b/web/index.html @@ -90,12 +90,39 @@
- + + +
+ + +