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