feat: add collapsible resizable navigation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user