web: start: add sliding menu
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
38939a0579
commit
d4282353e7
4 changed files with 159 additions and 22 deletions
11
web/build.sh
11
web/build.sh
|
|
@ -253,10 +253,15 @@ if [[ "$CHECK_ONLY" == false ]]; then
|
||||||
NAV_ENTRIES=$(jq -n \
|
NAV_ENTRIES=$(jq -n \
|
||||||
--argjson menu "$menu_json" \
|
--argjson menu "$menu_json" \
|
||||||
--argjson pages "$PAGE_ENTRIES" \
|
--argjson pages "$PAGE_ENTRIES" \
|
||||||
'[ $menu[] |
|
'def resolve($pages):
|
||||||
|
. as $r | $pages[] | select(.route == $r) | { label, href: ("#/" + .route), icon };
|
||||||
|
[ $menu[] |
|
||||||
if type == "string"
|
if type == "string"
|
||||||
then (. as $r | $pages[] | select(.route == $r) |
|
then resolve($pages)
|
||||||
{ label, href: ("#/" + .route), icon })
|
elif .type == "group"
|
||||||
|
then { type: "group", label: .label, icon: (.icon // ""),
|
||||||
|
children: [ .children[]? |
|
||||||
|
if type == "string" then resolve($pages) else . end ] }
|
||||||
else .
|
else .
|
||||||
end
|
end
|
||||||
]')
|
]')
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,27 @@ remote_pages: []
|
||||||
# List routes in desired display order.
|
# List routes in desired display order.
|
||||||
# A page not listed here is accessible via /#/route but hidden from the nav.
|
# A page not listed here is accessible via /#/route but hidden from the nav.
|
||||||
menu:
|
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
|
- dashboard
|
||||||
- type: section
|
- type: section
|
||||||
label: Réseau
|
label: Réseau
|
||||||
- vpcs
|
- vpcs
|
||||||
- subnets
|
- subnets
|
||||||
- type: separator
|
|
||||||
- vms
|
- vms
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,54 @@
|
||||||
background: #313244;
|
background: #313244;
|
||||||
margin: 6px 4px;
|
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; }
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||||
</svg>`
|
</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 = {
|
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>`,
|
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>`,
|
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 {
|
class SideNav extends HTMLElement {
|
||||||
#items = []
|
#items = []
|
||||||
|
#expanded = new Set() // labels of open groups
|
||||||
#hamburger = null
|
#hamburger = null
|
||||||
#backdrop = null
|
#backdrop = null
|
||||||
#mqHandler = null
|
#mqHandler = null
|
||||||
|
|
@ -42,6 +48,14 @@ class SideNav extends HTMLElement {
|
||||||
if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`)
|
if (!response.ok) throw new Error(`side-nav: failed to load ${navPath}`)
|
||||||
this.#items = await response.json()
|
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.#render()
|
||||||
|
|
||||||
this.#mqHandler = () => this.#applyMode()
|
this.#mqHandler = () => this.#applyMode()
|
||||||
|
|
@ -64,38 +78,89 @@ class SideNav extends HTMLElement {
|
||||||
return window.location.hash || `#/${this.#items[0]?.href?.replace(/^#\//, '') ?? 'dashboard'}`
|
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() {
|
#updateActive() {
|
||||||
const current = this.#currentHref()
|
const current = this.#currentHref()
|
||||||
|
|
||||||
this.shadowRoot?.querySelectorAll('.item').forEach(a =>
|
this.shadowRoot?.querySelectorAll('.item').forEach(a =>
|
||||||
a.classList.toggle('active', a.getAttribute('href') === current)
|
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 `<div class="separator"></div>`
|
||||||
|
}
|
||||||
|
if (item.type === 'section') {
|
||||||
|
return `<div class="section-header">${item.label ?? ''}</div>`
|
||||||
|
}
|
||||||
|
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>`
|
||||||
}
|
}
|
||||||
|
|
||||||
#render() {
|
#render() {
|
||||||
const current = this.#currentHref()
|
const current = this.#currentHref()
|
||||||
|
const links = this.#items.map(item => this.#renderItem(item, current)).join('')
|
||||||
const links = this.#items.map(item => {
|
|
||||||
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.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('')
|
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<link rel="stylesheet" href="${CSS}">
|
<link rel="stylesheet" href="${CSS}">
|
||||||
${links}
|
${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)
|
// Fermer l'overlay sur tap d'un lien (mobile)
|
||||||
this.shadowRoot.querySelectorAll('.item').forEach(a =>
|
this.shadowRoot.querySelectorAll('.item').forEach(a =>
|
||||||
a.addEventListener('click', () => { if (this.#isMobile) this.#close() })
|
a.addEventListener('click', () => { if (this.#isMobile) this.#close() })
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue