1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00
Files
netbox-community-netbox/netbox/project-static/src/sidenav.ts
2021-10-15 15:02:50 -07:00

316 lines
8.6 KiB
TypeScript

import { Collapse } from 'bootstrap';
import { StateManager } from './state';
import { getElements, isElement } from './util';
type NavState = { pinned: boolean };
type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned';
type Section = [HTMLAnchorElement, InstanceType<typeof Collapse>];
class SideNav {
/**
* Sidenav container element.
*/
private base: HTMLDivElement;
/**
* SideNav internal state manager.
*/
private state: StateManager<NavState>;
/**
* The currently active parent nav-link controlling a section.
*/
private activeLink: Nullable<HTMLAnchorElement> = null;
/**
* All collapsible sections and their controlling nav-links.
*/
private sections: Section[] = [];
constructor(base: HTMLDivElement) {
this.base = base;
this.state = new StateManager<NavState>(
{ pinned: true },
{ persist: true, key: 'netbox-sidenav' },
);
this.init();
this.initSectionLinks();
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');
}
}
}
/**
* Show the sidenav.
*/
private show(): void {
this.bodyAdd('show');
this.bodyRemove('hidden', 'hide');
}
/**
* Hide the sidenav and collapse all active nav sections.
*/
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);
}
/**
* When a section's controlling nav-link is clicked, update this instance's `activeLink`
* attribute and close all other sections.
*/
private handleSectionClick(event: Event): void {
event.preventDefault();
const element = event.target as HTMLAnchorElement;
this.activeLink = element;
this.closeInactiveSections();
}
/**
* Close all sections that are not associated with the currently active link (`activeLink`).
*/
private closeInactiveSections(): void {
for (const [link, collapse] of this.sections) {
if (link !== this.activeLink) {
link.classList.add('collapsed');
link.setAttribute('aria-expanded', 'false');
collapse.hide();
}
}
}
/**
* Initialize `bootstrap.Collapse` instances on all section collapse elements and add event
* listeners to the controlling nav-links.
*/
private initSectionLinks(): void {
for (const section of getElements<HTMLAnchorElement>(
'.navbar-nav .nav-item .nav-link[data-bs-toggle]',
)) {
if (section.parentElement !== null) {
const collapse = section.parentElement.querySelector<HTMLDivElement>('.collapse');
if (collapse !== null) {
const collapseInstance = new Collapse(collapse, {
toggle: false, // Don't automatically open the collapse element on invocation.
});
this.sections.push([section, collapseInstance]);
section.addEventListener('click', event => this.handleSectionClick(event));
}
}
}
}
/**
* 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 (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');
}
this.bodyRemove('hide');
this.bodyAdd('hidden');
}
}
/**
* 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();
}
}
/**
* Handle sidenav visibility state for small screens. On small screens, there is no pinned state,
* only open/closed.
*/
private onMobileToggle(event: Event): void {
event.preventDefault();
if (this.bodyHas('hidden')) {
this.show();
} else {
this.hide();
}
}
}
export function initSideNav(): void {
for (const sidenav of getElements<HTMLDivElement>('.sidenav')) {
new SideNav(sidenav);
}
}