web: start: add sliding menu

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-06-03 23:54:41 +02:00
commit 50a290d808
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
4 changed files with 159 additions and 22 deletions

View file

@ -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
]')

View file

@ -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

View file

@ -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; }

View file

@ -11,6 +11,11 @@ const HAMBURGER_ICON = `<svg width="18" height="18" viewBox="0 0 24 24" fill="no
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>`
const CHEVRON_ICON = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6"/>
</svg>`
const ICONS = {
dashboard: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>`,
vpc: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5"/><path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3"/></svg>`,
@ -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')
}
})
}
#render() {
const current = this.#currentHref()
#toggleGroup(label) {
const isOpen = this.#expanded.has(label)
isOpen ? this.#expanded.delete(label) : this.#expanded.add(label)
const links = this.#items.map(item => {
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 `<div class="separator"></div>`
}
if (item.type === 'section') {
return `<div class="section-header">${item.label ?? ''}</div>`
}
// Skip malformed entries (missing href or 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 `<a href="${child.href}" class="item child ${active ? 'active' : ''}">
<span class="icon">${resolveIcon(child.icon)}</span>
<span class="label">${child.label}</span>
</a>`
}).join('')
return `
<div class="group">
<button class="group-header ${hasActive ? 'has-active' : ''}" data-group="${item.label}">
<span class="icon">${resolveIcon(item.icon)}</span>
<span class="label">${item.label}</span>
<span class="chevron ${isOpen ? 'open' : ''}">${CHEVRON_ICON}</span>
</button>
<div class="group-children ${isOpen ? 'open' : ''}">
<div>${children}</div>
</div>
</div>`
}
// Default: leaf link
if (!item.href || !item.label) return ''
const active = item.href === current
return `<a href="${item.href}" class="item ${active ? 'active' : ''}">
<span class="icon">${resolveIcon(item.icon)}</span>
<span class="label">${item.label}</span>
</a>`
}).join('')
}
#render() {
const current = this.#currentHref()
const links = this.#items.map(item => this.#renderItem(item, current)).join('')
this.shadowRoot.innerHTML = `
<link rel="stylesheet" href="${CSS}">
${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() })