mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge branch 'feature-sidebar' into feature
# Conflicts: # netbox/project-static/dist/netbox.js # netbox/project-static/dist/netbox.js.map
This commit is contained in:
@@ -1,22 +1,246 @@
|
||||
import { getElement, getElements } from './util';
|
||||
import { StateManager } from './state';
|
||||
import { getElements, isElement } from './util';
|
||||
|
||||
const breakpoints = {
|
||||
sm: 540,
|
||||
md: 720,
|
||||
lg: 960,
|
||||
xl: 1140,
|
||||
};
|
||||
type NavState = { pinned: boolean };
|
||||
type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned';
|
||||
|
||||
function toggleBodyPosition(position: HTMLBodyElement['style']['position']): void {
|
||||
for (const element of getElements('body')) {
|
||||
element.style.position = position;
|
||||
class SideNav {
|
||||
/**
|
||||
* Sidenav container element.
|
||||
*/
|
||||
private base: HTMLDivElement;
|
||||
|
||||
/**
|
||||
* SideNav internal state manager.
|
||||
*/
|
||||
private state: StateManager<NavState>;
|
||||
|
||||
constructor(base: HTMLDivElement) {
|
||||
this.base = base;
|
||||
this.state = new StateManager<NavState>({ pinned: true }, { persist: true });
|
||||
|
||||
this.init();
|
||||
this.initLinks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if `document.body` has a sidenav attribute.
|
||||
*/
|
||||
private bodyHas(attr: BodyAttr): boolean {
|
||||
return document.body.hasAttribute(`data-sidenav-${attr}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sidenav attributes from `document.body`.
|
||||
*/
|
||||
private bodyRemove(...attrs: BodyAttr[]): void {
|
||||
for (const attr of attrs) {
|
||||
document.body.removeAttribute(`data-sidenav-${attr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sidenav attributes to `document.body`.
|
||||
*/
|
||||
private bodyAdd(...attrs: BodyAttr[]): void {
|
||||
for (const attr of attrs) {
|
||||
document.body.setAttribute(`data-sidenav-${attr}`, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set initial values & add event listeners.
|
||||
*/
|
||||
private init() {
|
||||
for (const toggler of this.base.querySelectorAll('.sidenav-toggle')) {
|
||||
toggler.addEventListener('click', event => this.onToggle(event));
|
||||
}
|
||||
|
||||
for (const toggler of getElements<HTMLButtonElement>('.sidenav-toggle-mobile')) {
|
||||
toggler.addEventListener('click', event => this.onMobileToggle(event));
|
||||
}
|
||||
|
||||
if (window.innerWidth > 1200) {
|
||||
if (this.state.get('pinned')) {
|
||||
this.pin();
|
||||
}
|
||||
|
||||
if (!this.state.get('pinned')) {
|
||||
this.unpin();
|
||||
}
|
||||
window.addEventListener('resize', () => this.onResize());
|
||||
}
|
||||
|
||||
if (window.innerWidth < 1200) {
|
||||
this.bodyRemove('hide');
|
||||
this.bodyAdd('hidden');
|
||||
window.addEventListener('resize', () => this.onResize());
|
||||
}
|
||||
|
||||
this.base.addEventListener('mouseenter', () => this.onEnter());
|
||||
this.base.addEventListener('mouseleave', () => this.onLeave());
|
||||
}
|
||||
|
||||
/**
|
||||
* If the sidenav is shown, expand active nav links. Otherwise, collapse them.
|
||||
*/
|
||||
private initLinks(): void {
|
||||
for (const link of this.getActiveLinks()) {
|
||||
if (this.bodyHas('show')) {
|
||||
this.activateLink(link, 'expand');
|
||||
} else if (this.bodyHas('hidden')) {
|
||||
this.activateLink(link, 'collapse');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private show(): void {
|
||||
this.bodyAdd('show');
|
||||
this.bodyRemove('hidden', 'hide');
|
||||
}
|
||||
|
||||
private hide(): void {
|
||||
this.bodyAdd('hidden');
|
||||
this.bodyRemove('pinned', 'show');
|
||||
for (const collapse of this.base.querySelectorAll('.collapse')) {
|
||||
collapse.classList.remove('show');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin the sidenav.
|
||||
*/
|
||||
private pin(): void {
|
||||
this.bodyAdd('show', 'pinned');
|
||||
this.bodyRemove('hidden');
|
||||
this.state.set('pinned', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpin the sidenav.
|
||||
*/
|
||||
private unpin(): void {
|
||||
this.bodyRemove('pinned', 'show');
|
||||
this.bodyAdd('hidden');
|
||||
for (const collapse of this.base.querySelectorAll('.collapse')) {
|
||||
collapse.classList.remove('show');
|
||||
}
|
||||
this.state.set('pinned', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starting from the bottom-most active link in the element tree, work backwards to determine the
|
||||
* link's containing `.collapse` element and the `.collapse` element's containing `.nav-link`
|
||||
* element. Once found, expand (or collapse) the `.collapse` element and add (or remove) the
|
||||
* `.active` class to the the parent `.nav-link` element.
|
||||
*
|
||||
* @param link Active nav link
|
||||
* @param action Expand or Collapse
|
||||
*/
|
||||
private activateLink(link: HTMLAnchorElement, action: 'expand' | 'collapse'): void {
|
||||
// Find the closest .collapse element, which should contain `link`.
|
||||
const collapse = link.closest('.collapse') as Nullable<HTMLDivElement>;
|
||||
if (isElement(collapse)) {
|
||||
// Find the closest `.nav-link`, which should be adjacent to the `.collapse` element.
|
||||
const groupLink = collapse.parentElement?.querySelector('.nav-link');
|
||||
if (isElement(groupLink)) {
|
||||
groupLink.classList.add('active');
|
||||
switch (action) {
|
||||
case 'expand':
|
||||
groupLink.setAttribute('aria-expanded', 'true');
|
||||
collapse.classList.add('show');
|
||||
link.classList.add('active');
|
||||
break;
|
||||
case 'collapse':
|
||||
groupLink.setAttribute('aria-expanded', 'false');
|
||||
collapse.classList.remove('show');
|
||||
link.classList.remove('active');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any nav links with `href` attributes matching the current path, to determine which nav
|
||||
* link should be considered active.
|
||||
*/
|
||||
private *getActiveLinks(): Generator<HTMLAnchorElement> {
|
||||
for (const link of this.base.querySelectorAll<HTMLAnchorElement>(
|
||||
'.navbar-nav .nav .nav-item a.nav-link',
|
||||
)) {
|
||||
const href = new RegExp(link.href, 'gi');
|
||||
if (Boolean(window.location.href.match(href))) {
|
||||
yield link;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the sidenav and expand any active sections.
|
||||
*/
|
||||
private onEnter(): void {
|
||||
if (!this.bodyHas('pinned')) {
|
||||
this.bodyRemove('hide', 'hidden');
|
||||
this.bodyAdd('show');
|
||||
for (const link of this.getActiveLinks()) {
|
||||
this.activateLink(link, 'expand');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the sidenav and collapse any active sections.
|
||||
*/
|
||||
private onLeave(): void {
|
||||
if (!this.bodyHas('pinned')) {
|
||||
this.bodyRemove('show');
|
||||
this.bodyAdd('hide');
|
||||
for (const link of this.getActiveLinks()) {
|
||||
this.activateLink(link, 'collapse');
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.bodyRemove('hide');
|
||||
this.bodyAdd('hidden');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the (unpinned) sidenav when the window is resized.
|
||||
*/
|
||||
private onResize(): void {
|
||||
if (this.bodyHas('show') && !this.bodyHas('pinned')) {
|
||||
this.bodyRemove('show');
|
||||
this.bodyAdd('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin & unpin the sidenav when the pin button is toggled.
|
||||
*/
|
||||
private onToggle(event: Event): void {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.state.get('pinned')) {
|
||||
this.unpin();
|
||||
} else {
|
||||
this.pin();
|
||||
}
|
||||
}
|
||||
|
||||
private onMobileToggle(event: Event): void {
|
||||
event.preventDefault();
|
||||
if (this.bodyHas('hidden')) {
|
||||
this.show();
|
||||
} else {
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initSideNav() {
|
||||
const element = getElement<HTMLAnchorElement>('sidebarMenu');
|
||||
if (element !== null && document.body.clientWidth < breakpoints.lg) {
|
||||
element.addEventListener('shown.bs.collapse', () => toggleBodyPosition('fixed'));
|
||||
element.addEventListener('hidden.bs.collapse', () => toggleBodyPosition('relative'));
|
||||
for (const sidenav of getElements<HTMLDivElement>('.sidenav')) {
|
||||
new SideNav(sidenav);
|
||||
}
|
||||
}
|
||||
|
@@ -56,6 +56,13 @@ export function isTruthy<V extends string | number | boolean | null | undefined>
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to determine if a value is an `Element`.
|
||||
*/
|
||||
export function isElement(obj: Element | null | undefined): obj is Element {
|
||||
return typeof obj !== null && typeof obj !== 'undefined';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the CSRF token from cookie storage.
|
||||
*/
|
||||
@@ -152,6 +159,22 @@ export function getElement<E extends HTMLElement>(id: string): Nullable<E> {
|
||||
return document.getElementById(id) as Nullable<E>;
|
||||
}
|
||||
|
||||
export function removeElements(...selectors: string[]): void {
|
||||
for (const element of getElements(...selectors)) {
|
||||
element.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function elementWidth<E extends HTMLElement>(element: Nullable<E>): number {
|
||||
let width = 0;
|
||||
if (element !== null) {
|
||||
const style = getComputedStyle(element);
|
||||
const pre = style.width.replace('px', '');
|
||||
width = parseFloat(pre);
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* scrollTo() wrapper that calculates a Y offset relative to `element`, but also factors in an
|
||||
* offset relative to div#content-title. This ensures we scroll to the element, but leave enough
|
||||
|
Reference in New Issue
Block a user