// 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)