From c885959a6f3ff14b677fb104b72dc707d0fc1c66 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 6 Jun 2026 22:55:38 +0200 Subject: [PATCH] init work Signed-off-by: GnomeZworc --- manifest.json | 5 + side-nav.css | 107 ++++++++++++++++++ side-nav.js | 297 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 409 insertions(+) create mode 100644 manifest.json create mode 100644 side-nav.css create mode 100644 side-nav.js diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..67cdfbd --- /dev/null +++ b/manifest.json @@ -0,0 +1,5 @@ +{ + "tag": "side-nav", + "version": "0.1.0", + "description": "Left sidebar navigation, driven by navigation.json." +} diff --git a/side-nav.css b/side-nav.css new file mode 100644 index 0000000..5dd311f --- /dev/null +++ b/side-nav.css @@ -0,0 +1,107 @@ +:host { + display: flex; + flex-direction: column; + width: 220px; + min-width: 220px; + background: #1e1e2e; + border-right: 1px solid #313244; + padding: 16px 12px; + gap: 4px; + height: 100%; + overflow: hidden; + box-sizing: border-box; +} + +.item { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 12px; + margin: 2px; + border-radius: 6px; + text-decoration: none; + color: #a6adc8; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + min-height: 25px; + transition: background 0.1s, color 0.1s; +} + +.item:hover { background: #313244; color: #cdd6f4; } +.item.active { background: #313244; color: #89b4fa; } +.item.active .icon { color: #89b4fa; } + +.icon { + display: flex; + align-items: center; + color: #6c7086; + flex-shrink: 0; +} + +.item:hover .icon { color: #cdd6f4; } + +.section-header { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: #45475a; + padding: 14px 12px 4px; +} + +.separator { + height: 1px; + background: #313244; + margin: 6px 4px; +} + +/* ── Accordion group ────────────────────────────────────────────────────── */ + +.group { display: flex; flex-direction: column; } + +.group-header { + display: flex; + align-items: center; + gap: 10px; + padding: 5px 12px; + margin: 2px; + border-radius: 6px; + background: transparent; + border: none; + cursor: pointer; + color: #a6adc8; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + min-height: 25px; + transition: background 0.1s, color 0.1s; + width: 100%; + text-align: left; +} + +.group-header:hover { background: #313244; color: #cdd6f4; } +.group-header:hover .icon { color: #cdd6f4; } +.group-header.has-active { color: #89b4fa; } +.group-header.has-active .icon { color: #89b4fa; } + +.chevron { + margin-left: auto; + display: flex; + align-items: center; + color: #6c7086; + flex-shrink: 0; + transition: transform 0.2s ease; +} + +.chevron.open { transform: rotate(90deg); } + +.group-children { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.2s ease; +} + +.group-children.open { grid-template-rows: 1fr; } + +.group-children > div { overflow: hidden; } + +.item.child { padding-left: 36px; } diff --git a/side-nav.js b/side-nav.js new file mode 100644 index 0000000..ac49d79 --- /dev/null +++ b/side-nav.js @@ -0,0 +1,297 @@ +// Side navigation component. +// Desktop : sidebar fixe à gauche dans le flux flex. +// Mobile (≤768px) : overlay depuis la gauche, déclenché par un bouton hamburger +// inséré dans
. Géométrie gérée en inline style pour +// éviter les conflits de spécificité avec le shadow DOM. + +const HAMBURGER_ICON = ` + + + +` + +const CHEVRON_ICON = ` + +` + +const ICONS = { + dashboard: ``, + vpc: ``, + subnet: ``, + vm: ``, +} + +const CSS = new URL('./side-nav.css', import.meta.url).href +const MQ = window.matchMedia('(max-width: 768px)') +const NAV_W = 220 // px + +function resolveIcon(name) { + return ICONS[name] ?? `` +} + +class SideNav extends HTMLElement { + #items = [] + #expanded = new Set() + #hamburger = null + #backdrop = null + #mqHandler = null + #hashHandler = null + #isMobile = false + #readyResolve = null + + // Resolves once navigation.json is loaded and the nav is rendered. + // Await this before calling setGroupChildren(). + ready = new Promise(r => { this.#readyResolve = r }) + + async connectedCallback() { + this.attachShadow({ mode: 'open' }) + + const navPath = this.dataset.src ?? './navigation.json' + const response = await fetch(navPath) + if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`) + this.#items = await response.json() + + // Auto-expand groups that contain the active route + const current = this.#currentHref() + for (const item of this.#items) { + if (item.type === 'group' && item.children?.some(c => c.href === current)) { + this.#expanded.add(item.label) + } + } + + this.#render() + this.#readyResolve() + + this.#mqHandler = () => this.#applyMode() + this.#hashHandler = () => this.#updateActive() + MQ.addEventListener('change', this.#mqHandler) + window.addEventListener('hashchange', this.#hashHandler) + this.#applyMode() + } + + disconnectedCallback() { + MQ.removeEventListener('change', this.#mqHandler) + window.removeEventListener('hashchange', this.#hashHandler) + this.#removeHamburger() + this.#removeBackdrop() + } + + // ── Public API ────────────────────────────────────────────────────────────── + + // Inject dynamic children into an existing group (identified by label). + // Call after awaiting nav.ready. + setGroupChildren(label, children) { + const group = this.#items.find(i => i.type === 'group' && i.label === label) + if (!group) return + group.children = children + this.#render() + } + + // ── Rendu des items ───────────────────────────────────────────────────────── + + #currentHref() { + return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}` + } + + // Update active state without re-rendering the whole nav. + #updateActive() { + const current = this.#currentHref() + + this.shadowRoot?.querySelectorAll('.item').forEach(a => + a.classList.toggle('active', a.getAttribute('href') === current) + ) + + this.shadowRoot?.querySelectorAll('.group-header').forEach(btn => { + const item = this.#items.find(i => i.label === btn.dataset.group) + const hasActive = item?.children?.some(c => c.href === current) ?? false + btn.classList.toggle('has-active', hasActive) + // Auto-expand if a child becomes active + if (hasActive && !this.#expanded.has(item.label)) { + this.#expanded.add(item.label) + btn.querySelector('.chevron')?.classList.add('open') + btn.nextElementSibling?.classList.add('open') + } + }) + } + + #toggleGroup(label) { + const isOpen = this.#expanded.has(label) + isOpen ? this.#expanded.delete(label) : this.#expanded.add(label) + + const btn = this.shadowRoot.querySelector(`[data-group="${label}"]`) + const children = btn?.nextElementSibling + btn?.querySelector('.chevron')?.classList.toggle('open', !isOpen) + children?.classList.toggle('open', !isOpen) + } + + #renderItem(item, current) { + if (item.type === 'separator') { + return `
` + } + if (item.type === 'section') { + return `
${item.label ?? ''}
` + } + if (item.type === 'group') { + const isOpen = this.#expanded.has(item.label) + const hasActive = item.children?.some(c => c.href === current) ?? false + const children = (item.children ?? []).map(child => { + const active = child.href === current + return ` + ${resolveIcon(child.icon)} + ${child.label} + ` + }).join('') + return ` +
+ +
+
${children}
+
+
` + } + // Default: leaf link + if (!item.href || !item.label) return '' + const active = item.href === current + return ` + ${resolveIcon(item.icon)} + ${item.label} + ` + } + + #render() { + const current = this.#currentHref() + const links = this.#items.map(item => this.#renderItem(item, current)).join('') + + this.shadowRoot.innerHTML = ` + + ${links} + ` + + // Group toggle + this.shadowRoot.querySelectorAll('.group-header').forEach(btn => + btn.addEventListener('click', () => this.#toggleGroup(btn.dataset.group)) + ) + + // Fermer l'overlay sur tap d'un lien (mobile) + this.shadowRoot.querySelectorAll('.item').forEach(a => + a.addEventListener('click', () => { if (this.#isMobile) this.#close() }) + ) + } + + // ── Basculement desktop / mobile ──────────────────────────────────────────── + + #applyMode() { + if (MQ.matches) this.#toMobile() + else this.#toDesktop() + } + + #toMobile() { + this.#isMobile = true + + // inline style = spécificité maximale, pas de conflit avec le shadow DOM + Object.assign(this.style, { + position: 'fixed', + top: '0', + left: '0', + width: `${NAV_W}px`, + minWidth: `${NAV_W}px`, + height: '100vh', + zIndex: '500', + transform: 'translateX(-100%)', + transition: 'transform 0.25s cubic-bezier(0.4,0,0.2,1)', + overflow: 'hidden', + boxShadow: '4px 0 24px rgba(0,0,0,0.45)', + }) + + this.#addHamburger() + } + + #toDesktop() { + this.#isMobile = false + // Réinitialise tous les styles inline → shadow DOM reprend le contrôle + this.style.cssText = '' + this.#removeHamburger() + this.#removeBackdrop() + } + + // ── Hamburger ─────────────────────────────────────────────────────────────── + + #addHamburger() { + if (this.#hamburger) return + + const btn = document.createElement('button') + btn.innerHTML = HAMBURGER_ICON + btn.setAttribute('aria-label', 'Menu') + btn.setAttribute('data-sidenav-toggle', '') + Object.assign(btn.style, { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + border: '1px solid #313244', + borderRadius: '6px', + padding: '7px', + color: '#a6adc8', + cursor: 'pointer', + minWidth: '36px', + minHeight: '36px', + flexShrink: '0', + }) + btn.addEventListener('click', () => this.#toggle()) + this.#hamburger = btn + + // Insère après le h1 dans le header, ou en premier si pas de h1 + const header = document.querySelector('header') + if (!header) { document.body.prepend(btn); return } + const h1 = header.querySelector('h1') + h1 ? h1.after(btn) : header.prepend(btn) + } + + #removeHamburger() { + this.#hamburger?.remove() + this.#hamburger = null + } + + // ── Open / Close (mobile) ─────────────────────────────────────────────────── + + #toggle() { this.#isMobile && (this.style.transform === 'translateX(0px)' + ? this.#close() : this.#open()) } + + #open() { + this.style.transform = 'translateX(0)' + + const bd = document.createElement('div') + Object.assign(bd.style, { + position: 'fixed', + inset: '0', + background: 'rgba(0,0,0,0)', + zIndex: '499', + transition: 'background 0.25s', + }) + bd.addEventListener('click', () => this.#close()) + document.body.appendChild(bd) + this.#backdrop = bd + requestAnimationFrame(() => { bd.style.background = 'rgba(0,0,0,0.45)' }) + } + + #close() { + this.style.transform = 'translateX(-100%)' + this.#removeBackdrop() + } + + #removeBackdrop() { + if (!this.#backdrop) return + const bd = this.#backdrop + this.#backdrop = null + bd.style.background = 'rgba(0,0,0,0)' + bd.addEventListener('transitionend', () => bd.remove(), { once: true }) + } +} + +customElements.define('side-nav', SideNav)