feat: add collapsible resizable navigation

This commit is contained in:
2026-07-31 10:25:47 +02:00
parent 2cbce03206
commit 6b36184f9d
14 changed files with 567 additions and 35 deletions
@@ -1,18 +1,36 @@
(() => {
'use strict';
function applyNavigationPreferences() {
const dataElement = document.getElementById('netbox-utilities-navigation-data');
const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav');
if (!dataElement || !sidebar) return;
const dataElement = document.getElementById('netbox-utilities-navigation-data');
if (!dataElement) return;
let preferences;
try {
preferences = JSON.parse(dataElement.textContent);
} catch (_error) {
return;
}
let preferences;
try {
preferences = JSON.parse(dataElement.textContent);
} catch (_error) {
return;
}
const root = document.documentElement;
function normalizeWidth(value) {
const minimum = Number(preferences.width_min) || 216;
const maximum = Number(preferences.width_max) || 408;
const step = Number(preferences.width_step) || 24;
const requested = Number(value) || 288;
const snapped = minimum + Math.round((requested - minimum) / step) * step;
return Math.min(maximum, Math.max(minimum, snapped));
}
function applyRootLayout() {
preferences.width = normalizeWidth(preferences.width);
preferences.collapsed = Boolean(preferences.collapsed);
root.classList.add('netbox-utilities-navigation-layout');
root.classList.toggle('netbox-utilities-navigation-collapsed', preferences.collapsed);
root.style.setProperty('--netbox-utilities-sidebar-width', `${preferences.width}px`);
}
function applyMenuPreferences(sidebar) {
const descriptorByPath = new Map(
preferences.menus.map((menu) => [new URL(menu.first_url, document.baseURI).pathname, menu])
);
@@ -36,9 +54,157 @@
});
}
function updateMenuTitles(sidebar) {
sidebar.querySelectorAll(':scope > li.nav-item.dropdown > button.nav-link').forEach((button) => {
if (preferences.collapsed) {
button.dataset.netboxUtilitiesTitle = 'true';
button.title = button.getAttribute('aria-label') || '';
const menu = button.closest('.nav-item.dropdown')?.querySelector(':scope > .dropdown-menu.show');
if (menu) {
const top = Math.max(8, button.getBoundingClientRect().top);
menu.style.setProperty('--netbox-utilities-dropdown-top', `${top}px`);
}
} else if (button.dataset.netboxUtilitiesTitle) {
button.removeAttribute('title');
delete button.dataset.netboxUtilitiesTitle;
}
});
}
function configureCollapsedDropdowns(sidebar) {
sidebar.addEventListener('show.bs.dropdown', (event) => {
if (!preferences.collapsed) return;
const item = event.target.closest('.nav-item.dropdown');
if (!item) return;
sidebar.querySelectorAll(':scope > li.nav-item.dropdown').forEach((otherItem) => {
if (otherItem === item) return;
const otherButton = otherItem.querySelector(':scope > button.nav-link');
const otherMenu = otherItem.querySelector(':scope > .dropdown-menu');
otherButton?.classList.remove('show');
otherButton?.setAttribute('aria-expanded', 'false');
otherMenu?.classList.remove('show');
});
const button = item.querySelector(':scope > button.nav-link');
const menu = item.querySelector(':scope > .dropdown-menu');
if (!button || !menu) return;
const top = Math.max(8, button.getBoundingClientRect().top);
menu.style.setProperty('--netbox-utilities-dropdown-top', `${top}px`);
});
}
function createControl(action, icon, label) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-ghost-secondary netbox-utilities-sidebar-control';
button.dataset.action = action;
button.title = label;
button.setAttribute('aria-label', label);
const iconElement = document.createElement('i');
iconElement.className = `mdi ${icon}`;
iconElement.setAttribute('aria-hidden', 'true');
button.appendChild(iconElement);
return button;
}
function updateControls(controls, sidebar, pending = false) {
const minimum = Number(preferences.width_min) || 216;
const maximum = Number(preferences.width_max) || 408;
const smaller = controls.querySelector('[data-action="smaller"]');
const larger = controls.querySelector('[data-action="larger"]');
const toggle = controls.querySelector('[data-action="toggle"]');
const toggleIcon = toggle.querySelector('i');
smaller.disabled = pending || preferences.collapsed || preferences.width <= minimum;
larger.disabled = pending || preferences.collapsed || preferences.width >= maximum;
toggle.disabled = pending;
toggleIcon.className = preferences.collapsed
? 'mdi mdi-arrow-expand-right'
: 'mdi mdi-arrow-collapse-left';
const toggleLabel = preferences.collapsed ? 'Navigation ausklappen' : 'Navigation auf Icons einklappen';
toggle.title = toggleLabel;
toggle.setAttribute('aria-label', toggleLabel);
updateMenuTitles(sidebar);
}
function applyAction(action) {
const step = Number(preferences.width_step) || 24;
if (action === 'toggle') preferences.collapsed = !preferences.collapsed;
if (action === 'smaller') preferences.width = normalizeWidth(preferences.width - step);
if (action === 'larger') preferences.width = normalizeWidth(preferences.width + step);
applyRootLayout();
}
function addLayoutControls(sidebarMenu, sidebar) {
if (!preferences.layout_update_url || !preferences.csrf_token) return;
const controls = document.createElement('div');
controls.className = 'netbox-utilities-sidebar-controls';
controls.setAttribute('role', 'group');
controls.setAttribute('aria-label', 'Navigationsgröße');
controls.append(
createControl('smaller', 'mdi-minus', 'Navigation schmaler'),
createControl('toggle', 'mdi-arrow-collapse-left', 'Navigation auf Icons einklappen'),
createControl('larger', 'mdi-plus', 'Navigation breiter')
);
const releaseInfo = sidebarMenu.querySelector(':scope > .text-muted');
sidebarMenu.insertBefore(controls, releaseInfo || null);
updateControls(controls, sidebar);
controls.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-action]');
if (!button || button.disabled) return;
const previous = {collapsed: preferences.collapsed, width: preferences.width};
applyAction(button.dataset.action);
updateControls(controls, sidebar, true);
try {
const response = await fetch(preferences.layout_update_url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-CSRFToken': preferences.csrf_token,
},
body: new URLSearchParams({action: button.dataset.action}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json();
preferences.collapsed = Boolean(saved.collapsed);
preferences.width = normalizeWidth(saved.width);
controls.classList.remove('text-danger');
controls.removeAttribute('title');
} catch (error) {
preferences.collapsed = previous.collapsed;
preferences.width = previous.width;
controls.classList.add('text-danger');
controls.title = 'Die Navigationseinstellung konnte nicht gespeichert werden.';
console.error('Could not save NetBox navigation layout', error);
}
applyRootLayout();
updateControls(controls, sidebar);
});
}
function initializeNavigation() {
const sidebarMenu = document.getElementById('sidebar-menu');
const sidebar = document.querySelector('#sidebar-menu > ul.navbar-nav');
if (!sidebarMenu || !sidebar) return;
applyMenuPreferences(sidebar);
updateMenuTitles(sidebar);
configureCollapsedDropdowns(sidebar);
addLayoutControls(sidebarMenu, sidebar);
}
applyRootLayout();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', applyNavigationPreferences, {once: true});
document.addEventListener('DOMContentLoaded', initializeNavigation, {once: true});
} else {
applyNavigationPreferences();
initializeNavigation();
}
})();
@@ -9,3 +9,115 @@
vertical-align: bottom;
white-space: nowrap;
}
.netbox-utilities-sidebar-controls {
display: none;
}
@media (min-width: 992px) {
html.netbox-utilities-navigation-layout {
--netbox-utilities-sidebar-effective-width: var(--netbox-utilities-sidebar-width, 18rem);
}
html.netbox-utilities-navigation-collapsed {
--netbox-utilities-sidebar-effective-width: 4.5rem;
}
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg {
width: var(--netbox-utilities-sidebar-effective-width);
transition: width 160ms ease;
}
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar,
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper {
margin-left: var(--netbox-utilities-sidebar-effective-width);
transition: margin-left 160ms ease;
}
html.netbox-utilities-navigation-layout .navbar-vertical.navbar-expand-lg img.motif {
width: var(--netbox-utilities-sidebar-effective-width);
}
.netbox-utilities-sidebar-controls {
position: sticky;
bottom: 0.5rem;
z-index: 2;
display: flex;
justify-content: center;
gap: 0.25rem;
margin: 0.75rem;
padding: 0.35rem;
border: 1px solid var(--tblr-border-color-translucent);
border-radius: var(--tblr-border-radius);
background: var(--tblr-bg-surface);
box-shadow: var(--tblr-box-shadow-sm);
}
.netbox-utilities-sidebar-control {
flex: 0 1 3rem;
padding-inline: 0.5rem;
}
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg > .container-fluid {
padding-inline: 0.35rem;
}
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg .navbar-brand,
html.netbox-utilities-navigation-collapsed .navbar-vertical.navbar-expand-lg img.motif,
html.netbox-utilities-navigation-collapsed #sidebar-menu > .text-muted,
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-link-title,
html.netbox-utilities-navigation-collapsed #sidebar-menu .dropdown-toggle::after {
display: none !important;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu,
html.netbox-utilities-navigation-collapsed #sidebar-menu > .navbar-nav {
width: 100%;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-item.dropdown > button.nav-link {
justify-content: center;
min-height: 2.75rem;
padding-inline: 0;
text-align: center;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-link-icon {
width: auto;
margin: 0;
font-size: 1.5rem;
}
html.netbox-utilities-navigation-collapsed #sidebar-menu .nav-item.dropdown > .dropdown-menu {
position: fixed !important;
top: var(--netbox-utilities-dropdown-top, 0.5rem) !important;
right: auto !important;
bottom: auto !important;
left: var(--netbox-utilities-sidebar-effective-width) !important;
z-index: 1040;
width: min(22rem, calc(100vw - var(--netbox-utilities-sidebar-effective-width) - 1rem));
max-height: calc(100vh - var(--netbox-utilities-dropdown-top, 0.5rem) - 0.5rem);
overflow-y: auto;
transform: none !important;
border: 1px solid var(--tblr-border-color);
border-radius: var(--tblr-border-radius);
background: var(--tblr-bg-surface);
box-shadow: var(--tblr-box-shadow-lg);
}
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-controls {
margin-inline: 0;
}
html.netbox-utilities-navigation-collapsed .netbox-utilities-sidebar-control:not([data-action="toggle"]) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg,
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .navbar,
html.netbox-utilities-navigation-layout .page > aside.navbar-vertical.navbar-expand-lg ~ .page-wrapper {
transition: none;
}
}