web: start: add side panel
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
d1ef83e949
commit
8674f5d91d
5 changed files with 271 additions and 1 deletions
|
|
@ -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"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 <name>.js and manifest.json.
|
||||
|
|
|
|||
5
web/components/side-panel/manifest.json
Normal file
5
web/components/side-panel/manifest.json
Normal file
|
|
@ -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()."
|
||||
}
|
||||
236
web/components/side-panel/side-panel.js
Normal file
236
web/components/side-panel/side-panel.js
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
// Configurable right-side drawer panel.
|
||||
//
|
||||
// ── Declarative ──────────────────────────────────────────────────────────────
|
||||
//
|
||||
// <side-panel title="VPC details" width="520px">
|
||||
// <vpc-detail vpc="vp-admin"></vpc-detail>
|
||||
// </side-panel>
|
||||
// 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: '<vpc-detail vpc="vp-admin"></vpc-detail>',
|
||||
// })
|
||||
// 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 = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>`
|
||||
|
||||
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 = `
|
||||
<style>
|
||||
:host {
|
||||
--offset: 0px;
|
||||
--width: ${width};
|
||||
--dur: ${ANIM_DURATION}ms;
|
||||
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: calc(-1 * var(--width));
|
||||
width: var(--width);
|
||||
height: 100vh;
|
||||
background: #1e1e2e;
|
||||
border-left: 1px solid #313244;
|
||||
box-shadow: -8px 0 32px rgba(0,0,0,0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: calc(1000 + var(--stack, 0));
|
||||
transition: right var(--dur) cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform var(--dur) cubic-bezier(0.4, 0, 0.2, 1);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
:host([data-open]) {
|
||||
right: var(--offset);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #313244;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #cdd6f4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 5px;
|
||||
color: #6c7086;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
background: #313244;
|
||||
border-color: #45475a;
|
||||
color: #f38ba8;
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.body::-webkit-scrollbar { width: 6px; }
|
||||
.body::-webkit-scrollbar-track { background: transparent; }
|
||||
.body::-webkit-scrollbar-thumb { background: #45475a; border-radius: 3px; }
|
||||
</style>
|
||||
|
||||
<div class="header">
|
||||
<span class="title">${title}</span>
|
||||
<button class="close" aria-label="Close">${CLOSE_ICON}</button>
|
||||
</div>
|
||||
<div class="body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`
|
||||
|
||||
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)
|
||||
|
|
@ -90,12 +90,39 @@
|
|||
<main>
|
||||
<div id="error"></div>
|
||||
<date-display></date-display>
|
||||
<biscuit-debug></biscuit-debug>
|
||||
|
||||
<!-- Dev test: open stacked panels -->
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<button onclick="openTestPanel('Panel A', '400px')"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#89b4fa;font-size:13px;cursor:pointer;">
|
||||
Open panel A
|
||||
</button>
|
||||
<button onclick="openTestPanel('Panel B', '520px')"
|
||||
style="background:#313244;border:1px solid #45475a;border-radius:6px;
|
||||
padding:7px 14px;color:#a6e3a1;font-size:13px;cursor:pointer;">
|
||||
Open panel B
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import './core/registry.js'
|
||||
import { SidePanel } from './components/side-panel/side-panel.js'
|
||||
|
||||
window.openTestPanel = (title, width) => {
|
||||
SidePanel.open({
|
||||
title,
|
||||
width,
|
||||
content: `<p style="color:#a6adc8;font-size:14px;line-height:1.6">
|
||||
Contenu du panneau <strong style="color:#cdd6f4">${title}</strong>.<br>
|
||||
Largeur : ${width}.<br><br>
|
||||
Tu peux ouvrir plusieurs panneaux — ils s'empilent vers la gauche.
|
||||
Ferme avec ✕, Échap, ou en cliquant le fond.
|
||||
</p>`,
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
const el = document.getElementById('error')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue