1import { Collapse } from 'bootstrap'; 2import { StateManager } from './state'; 3import { getElements, isElement } from './util'; 4 5type NavState = { pinned: boolean }; 6type BodyAttr = 'show' | 'hide' | 'hidden' | 'pinned'; 7type Section = [HTMLAnchorElement, InstanceType<typeof Collapse>]; 8 9class SideNav { 10 /** 11 * Sidenav container element. 12 */ 13 private base: HTMLDivElement; 14 15 /** 16 * SideNav internal state manager. 17 */ 18 private state: StateManager<NavState>; 19 20 /** 21 * The currently active parent nav-link controlling a section. 22 */ 23 private activeLink: Nullable<HTMLAnchorElement> = null; 24 25 /** 26 * All collapsible sections and their controlling nav-links. 27 */ 28 private sections: Section[] = []; 29 30 constructor(base: HTMLDivElement) { 31 this.base = base; 32 this.state = new StateManager<NavState>( 33 { pinned: true }, 34 { persist: true, key: 'netbox-sidenav' }, 35 ); 36 37 this.init(); 38 this.initSectionLinks(); 39 this.initLinks(); 40 } 41 42 /** 43 * Determine if `document.body` has a sidenav attribute. 44 */ 45 private bodyHas(attr: BodyAttr): boolean { 46 return document.body.hasAttribute(`data-sidenav-${attr}`); 47 } 48 49 /** 50 * Remove sidenav attributes from `document.body`. 51 */ 52 private bodyRemove(...attrs: BodyAttr[]): void { 53 for (const attr of attrs) { 54 document.body.removeAttribute(`data-sidenav-${attr}`); 55 } 56 } 57 58 /** 59 * Add sidenav attributes to `document.body`. 60 */ 61 private bodyAdd(...attrs: BodyAttr[]): void { 62 for (const attr of attrs) { 63 document.body.setAttribute(`data-sidenav-${attr}`, ''); 64 } 65 } 66 67 /** 68 * Set initial values & add event listeners. 69 */ 70 private init() { 71 for (const toggler of this.base.querySelectorAll('.sidenav-toggle')) { 72 toggler.addEventListener('click', event => this.onToggle(event)); 73 } 74 75 for (const toggler of getElements<HTMLButtonElement>('.sidenav-toggle-mobile')) { 76 toggler.addEventListener('click', event => this.onMobileToggle(event)); 77 } 78 79 if (window.innerWidth > 1200) { 80 if (this.state.get('pinned')) { 81 this.pin(); 82 } 83 84 if (!this.state.get('pinned')) { 85 this.unpin(); 86 } 87 window.addEventListener('resize', () => this.onResize()); 88 } 89 90 if (window.innerWidth < 1200) { 91 this.bodyRemove('hide'); 92 this.bodyAdd('hidden'); 93 window.addEventListener('resize', () => this.onResize()); 94 } 95 96 this.base.addEventListener('mouseenter', () => this.onEnter()); 97 this.base.addEventListener('mouseleave', () => this.onLeave()); 98 } 99 100 /** 101 * If the sidenav is shown, expand active nav links. Otherwise, collapse them. 102 */ 103 private initLinks(): void { 104 for (const link of this.getActiveLinks()) { 105 if (this.bodyHas('show')) { 106 this.activateLink(link, 'expand'); 107 } else if (this.bodyHas('hidden')) { 108 this.activateLink(link, 'collapse'); 109 } 110 } 111 } 112 113 /** 114 * Show the sidenav. 115 */ 116 private show(): void { 117 this.bodyAdd('show'); 118 this.bodyRemove('hidden', 'hide'); 119 } 120 121 /** 122 * Hide the sidenav and collapse all active nav sections. 123 */ 124 private hide(): void { 125 this.bodyAdd('hidden'); 126 this.bodyRemove('pinned', 'show'); 127 for (const collapse of this.base.querySelectorAll('.collapse')) { 128 collapse.classList.remove('show'); 129 } 130 } 131 132 /** 133 * Pin the sidenav. 134 */ 135 private pin(): void { 136 this.bodyAdd('show', 'pinned'); 137 this.bodyRemove('hidden'); 138 this.state.set('pinned', true); 139 } 140 141 /** 142 * Unpin the sidenav. 143 */ 144 private unpin(): void { 145 this.bodyRemove('pinned', 'show'); 146 this.bodyAdd('hidden'); 147 for (const collapse of this.base.querySelectorAll('.collapse')) { 148 collapse.classList.remove('show'); 149 } 150 this.state.set('pinned', false); 151 } 152 153 /** 154 * When a section's controlling nav-link is clicked, update this instance's `activeLink` 155 * attribute and close all other sections. 156 */ 157 private handleSectionClick(event: Event): void { 158 event.preventDefault(); 159 const element = event.target as HTMLAnchorElement; 160 this.activeLink = element; 161 this.closeInactiveSections(); 162 } 163 164 /** 165 * Close all sections that are not associated with the currently active link (`activeLink`). 166 */ 167 private closeInactiveSections(): void { 168 for (const [link, collapse] of this.sections) { 169 if (link !== this.activeLink) { 170 link.classList.add('collapsed'); 171 link.setAttribute('aria-expanded', 'false'); 172 collapse.hide(); 173 } 174 } 175 } 176 177 /** 178 * Initialize `bootstrap.Collapse` instances on all section collapse elements and add event 179 * listeners to the controlling nav-links. 180 */ 181 private initSectionLinks(): void { 182 for (const section of getElements<HTMLAnchorElement>( 183 '.navbar-nav .nav-item .nav-link[data-bs-toggle]', 184 )) { 185 if (section.parentElement !== null) { 186 const collapse = section.parentElement.querySelector<HTMLDivElement>('.collapse'); 187 if (collapse !== null) { 188 const collapseInstance = new Collapse(collapse, { 189 toggle: false, // Don't automatically open the collapse element on invocation. 190 }); 191 this.sections.push([section, collapseInstance]); 192 section.addEventListener('click', event => this.handleSectionClick(event)); 193 } 194 } 195 } 196 } 197 198 /** 199 * Starting from the bottom-most active link in the element tree, work backwards to determine the 200 * link's containing `.collapse` element and the `.collapse` element's containing `.nav-link` 201 * element. Once found, expand (or collapse) the `.collapse` element and add (or remove) the 202 * `.active` class to the the parent `.nav-link` element. 203 * 204 * @param link Active nav link 205 * @param action Expand or Collapse 206 */ 207 private activateLink(link: HTMLAnchorElement, action: 'expand' | 'collapse'): void { 208 // Find the closest .collapse element, which should contain `link`. 209 const collapse = link.closest('.collapse') as Nullable<HTMLDivElement>; 210 if (isElement(collapse)) { 211 // Find the closest `.nav-link`, which should be adjacent to the `.collapse` element. 212 const groupLink = collapse.parentElement?.querySelector('.nav-link'); 213 if (isElement(groupLink)) { 214 groupLink.classList.add('active'); 215 switch (action) { 216 case 'expand': 217 groupLink.setAttribute('aria-expanded', 'true'); 218 collapse.classList.add('show'); 219 link.classList.add('active'); 220 break; 221 case 'collapse': 222 groupLink.setAttribute('aria-expanded', 'false'); 223 collapse.classList.remove('show'); 224 link.classList.remove('active'); 225 break; 226 } 227 } 228 } 229 } 230 231 /** 232 * Find any nav links with `href` attributes matching the current path, to determine which nav 233 * link should be considered active. 234 */ 235 private *getActiveLinks(): Generator<HTMLAnchorElement> { 236 for (const link of this.base.querySelectorAll<HTMLAnchorElement>( 237 '.navbar-nav .nav .nav-item a.nav-link', 238 )) { 239 const href = new RegExp(link.href, 'gi'); 240 if (window.location.href.match(href)) { 241 yield link; 242 } 243 } 244 } 245 246 /** 247 * Show the sidenav and expand any active sections. 248 */ 249 private onEnter(): void { 250 if (!this.bodyHas('pinned')) { 251 this.bodyRemove('hide', 'hidden'); 252 this.bodyAdd('show'); 253 for (const link of this.getActiveLinks()) { 254 this.activateLink(link, 'expand'); 255 } 256 } 257 } 258 259 /** 260 * Hide the sidenav and collapse any active sections. 261 */ 262 private onLeave(): void { 263 if (!this.bodyHas('pinned')) { 264 this.bodyRemove('show'); 265 this.bodyAdd('hide'); 266 for (const link of this.getActiveLinks()) { 267 this.activateLink(link, 'collapse'); 268 } 269 this.bodyRemove('hide'); 270 this.bodyAdd('hidden'); 271 } 272 } 273 274 /** 275 * Close the (unpinned) sidenav when the window is resized. 276 */ 277 private onResize(): void { 278 if (this.bodyHas('show') && !this.bodyHas('pinned')) { 279 this.bodyRemove('show'); 280 this.bodyAdd('hidden'); 281 } 282 } 283 284 /** 285 * Pin & unpin the sidenav when the pin button is toggled. 286 */ 287 private onToggle(event: Event): void { 288 event.preventDefault(); 289 290 if (this.state.get('pinned')) { 291 this.unpin(); 292 } else { 293 this.pin(); 294 } 295 } 296 297 /** 298 * Handle sidenav visibility state for small screens. On small screens, there is no pinned state, 299 * only open/closed. 300 */ 301 private onMobileToggle(event: Event): void { 302 event.preventDefault(); 303 if (this.bodyHas('hidden')) { 304 this.show(); 305 } else { 306 this.hide(); 307 } 308 } 309} 310 311export function initSideNav(): void { 312 for (const sidenav of getElements<HTMLDivElement>('.sidenav')) { 313 new SideNav(sidenav); 314 } 315} 316