From 50a290d8083cef478d539df4f89764e0c010ade7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 3 Jun 2026 23:54:41 +0200 Subject: [PATCH] web: start: add sliding menu Signed-off-by: GnomeZworc --- web/build.sh | 11 +++- web/components.yml | 20 +++++- web/components/side-nav/side-nav.css | 51 ++++++++++++++ web/components/side-nav/side-nav.js | 99 +++++++++++++++++++++++----- 4 files changed, 159 insertions(+), 22 deletions(-) diff --git a/web/build.sh b/web/build.sh index 0806f63..78bc79f 100755 --- a/web/build.sh +++ b/web/build.sh @@ -253,10 +253,15 @@ if [[ "$CHECK_ONLY" == false ]]; then NAV_ENTRIES=$(jq -n \ --argjson menu "$menu_json" \ --argjson pages "$PAGE_ENTRIES" \ - '[ $menu[] | + 'def resolve($pages): + . as $r | $pages[] | select(.route == $r) | { label, href: ("#/" + .route), icon }; + [ $menu[] | if type == "string" - then (. as $r | $pages[] | select(.route == $r) | - { label, href: ("#/" + .route), icon }) + then resolve($pages) + elif .type == "group" + then { type: "group", label: .label, icon: (.icon // ""), + children: [ .children[]? | + if type == "string" then resolve($pages) else . end ] } else . end ]') diff --git a/web/components.yml b/web/components.yml index b39eb90..caa8912 100644 --- a/web/components.yml +++ b/web/components.yml @@ -51,11 +51,27 @@ remote_pages: [] # List routes in desired display order. # A page not listed here is accessible via /#/route but hidden from the nav. menu: + - dashboard + - type: separator + - type: group + label: Réseau + icon: vpc + children: + - vpcs + - subnets + - type: group + label: Empty + icon: vpc + children: [] + - type: group + label: Compute + icon: vm + children: + - vms + - type: separator - dashboard - type: section label: Réseau - vpcs - subnets - - type: separator - vms - diff --git a/web/components/side-nav/side-nav.css b/web/components/side-nav/side-nav.css index 2cae64a..5dd311f 100644 --- a/web/components/side-nav/side-nav.css +++ b/web/components/side-nav/side-nav.css @@ -54,3 +54,54 @@ 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/web/components/side-nav/side-nav.js b/web/components/side-nav/side-nav.js index 9e85870..dc105e7 100644 --- a/web/components/side-nav/side-nav.js +++ b/web/components/side-nav/side-nav.js @@ -11,6 +11,11 @@ const HAMBURGER_ICON = ` ` +const CHEVRON_ICON = ` + +` + const ICONS = { dashboard: ``, vpc: ``, @@ -28,6 +33,7 @@ function resolveIcon(name) { class SideNav extends HTMLElement { #items = [] + #expanded = new Set() // labels of open groups #hamburger = null #backdrop = null #mqHandler = null @@ -42,6 +48,14 @@ class SideNav extends HTMLElement { 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.#mqHandler = () => this.#applyMode() @@ -64,38 +78,89 @@ class SideNav extends HTMLElement { return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}` } - // Update active class without re-rendering the whole nav. + // 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 => { - if (item.type === 'separator') { - return `
` - } - if (item.type === 'section') { - return `
${item.label ?? ''}
` - } - // Skip malformed entries (missing href or label) - if (!item.href || !item.label) return '' - const active = item.href === current - return ` - ${resolveIcon(item.icon)} - ${item.label} - ` - }).join('') + 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() })