Jake Vanderwerf
2026-05-01 48721c85ebcfa973ee81719d2467ca80e4253dc9
assets/js/concise/navigation.js
@@ -7,6 +7,9 @@
      }
      this.openNav = null;
      this.openSubmenu = null;
      this.releaseFocusTrap = null;
      this.clicked = new Set();
      this.initListeners();
   }
@@ -20,8 +23,8 @@
            this.counter++;
         }
         if (nav.querySelector('.submenu')) {
            nav.addEventListener('mouseenter', this.hoverOnListener);
            nav.addEventListener('mouseleave', this.hoverOffListener);
            nav.addEventListener('mouseenter', this.handleHoverOn.bind(this));
            nav.addEventListener('mouseleave', this.handleHoverOff.bind(this));
         }
         let [
@@ -39,14 +42,20 @@
            submenus: submenus,
            submenuToggles: submenuToggles
         }
         this.navs.set(navID, elements);
         this.counter++;
         submenus.forEach(menu => {
            menu.id = 'submenu-'+this.counter;
            menu.addEventListener('mouseenter', this.handleHoverOn.bind(this));
            menu.addEventListener('mouseleave', this.handleHoverOff.bind(this));
            this.counter++;
         });
         this.navs.set(navID, elements);
      });
   }
   initListeners() {
      this.clickListener = this.handleClick.bind(this);
      this.escapeListener = this.handleEscape.bind(this);
      this.keysListener = this.handleKeys.bind(this);
      this.hoverOnListener = this.handleHoverOn.bind(this);
      this.hoverOffListener = this.handleHoverOff.bind(this);
@@ -56,58 +65,144 @@
      if (this.navs.size === 0) {
         return;
      }
      if (this.openNav && e.target.closest(`#${this.openNav}`) === null) {
         this.toggleNav(false, this.openNav);
      }
      // if (!e.target.closest(this.openNav)) {
      //    console.log('Not closest nav ids');
      //    console.log(this.navIDs());
      //    return;
      // }
      let toggle = e.target.closest('.toggle.main');
      if (toggle) {
         let nav = toggle.closest('nav');
         this.toggleNav(!nav.classList.contains('open'), nav.id);
         let isOpening = !this.clicked.has(nav);
         let shouldToggle = nav.classList.contains('open') !== isOpening;
         if (shouldToggle) {
            this.toggleNav(isOpening, nav.id);
         }
         if (isOpening) {
            this.clicked.add(nav);
         } else {
            this.clicked.delete(nav);
         }
         return;
      }
      let submenuToggle = e.target.closest('[data-action="toggle-submenu"], .has-submenu .a')
      if (submenuToggle) {
         let li = submenuToggle.closest('li');
         this.toggleSubmenu(!li.classList.contains('open'), li);
      }
         let isOpening = !this.clicked.has(li);
         let shouldToggle = li.classList.contains('open') !== isOpening;
         if (isOpening) {
            this.clicked.add(li);
         } else {
            this.clicked.delete(li);
         }
         if (shouldToggle) {
            this.toggleSubmenu(isOpening, li);
         }
         return;
      }
      if (!this.openNav) {
         return;
      }
      let close = true;
      for (let [navID, elements] of this.navs) {
         if (e.target.closest('#'+navID)) {
            close = false;
            break;
         }
      }
      if (close) {
         this.toggleNav(false, this.openNav);
      }
   }
   handleHoverOn(e) {
      let nav =  e.target.closest('nav');
      if (nav) {
         this.toggleNav(true, nav.id);
      let target = e.currentTarget;
      if (this.clicked.has(target)) {
         return;
      }
      let submenu = e.target.closest('.has-submenu');
      if (submenu) {
         this.toggleSubmenu(true, submenu);
      if (target.classList.contains('has-submenu')) {
         this.toggleSubmenu(true, target);
      } else if (target.tagName === 'NAV') {
         if (!target.classList.contains('mobile')) {
            this.toggleNav(true, target.id);
         }
      }
   }
   handleHoverOff(e) {
      let nav =  e.target.closest('nav');
      if (nav) {
         this.toggleNav(false, nav.id);
      let target = e.currentTarget;
      if (this.clicked.has(target)) {
         return;
      }
      // let submenu = e.target.closest('.has-submenu');
      // if (submenu) {
      //    this.toggleSubmenu(false, submenu);
      // }
      if (target.classList.contains('has-submenu')) {
         this.toggleSubmenu(false, target);
      } else if (target.tagName === 'NAV') {
         if (target.classList.contains('mobile')) {
            return;
         }
         let nav = this.navs.get(target.id);
         let shouldToggle = true;
         for (let submenu of nav.submenus) {
            if (this.clicked.has(submenu)) {
               shouldToggle = false;
               break;
            }
         }
         if (shouldToggle) {
            this.toggleNav(false, target.id);
         }
      }
   }
   handleEscape(e) {
      if (this.openNav && e.key === 'Escape') {
         this.toggleNav(false, this.openNav);
   handleKeys(e) {
      if (!this.openNav) return;
      switch (e.key) {
         case 'Escape':
            this.closeAll();
            break;
         case 'ArrowDown':
            this.focusNextItem();
            e.preventDefault();
            break;
         case 'ArrowUp':
            this.focusPrevItem();
            e.preventDefault();
            break;
      }
   }
   closeAll() {
      let nav = this.navs.get(this.openNav);
      if (nav && this.clicked.has(nav.nav)) {
         this.clicked.delete(nav.nav);
      }
      this.toggleNav(false, this.openNav);
   }
   focusNextItem() {
      const items = this.getFocusableItems();
      const i = items.indexOf(document.activeElement);
      const next = items[i + 1] || items[0];
      next.focus();
   }
   focusPrevItem() {
      const items = this.getFocusableItems();
      const i = items.indexOf(document.activeElement);
      const prev = items[i - 1] || items[items.length - 1];
      prev.focus();
   }
   getFocusableItems() {
      return Array.from(document.querySelectorAll(
         'nav.open a, nav.open button'
      )).filter(el => !el.disabled && !el.closest('[hidden]') && !el.closest('[inert]'));
   }
   toggleNav(on, id) {
      let nav = this.navs.get(id);
      if (!nav) {
@@ -119,12 +214,20 @@
      }
      if (on) {
         this.openNav = id;
         document.addEventListener('keydown', this.escapeListener);
         document.addEventListener('keydown', this.keysListener);
         if (nav.nav.classList.contains('mobile')) {
            this.releaseFocusTrap = window.jvbA11y.trapFocus(nav.nav);
         }
      } else {
         if (this.releaseFocusTrap) {
            this.releaseFocusTrap();
            this.releaseFocusTrap = null;
         }
         if (this.openNav === id) {
            this.openNav = null;
         }
         document.removeEventListener('keydown', this.escapeListener);
         document.removeEventListener('keydown', this.keysListener);
         if (!nav.nav.classList.contains('sidebar')) {
            Array.from(nav.submenus).forEach(submenu => {
               if(submenu.classList.contains('open')) {
@@ -132,17 +235,41 @@
               }
            });
         }
         Array.from(nav.submenus).forEach(submenu => {
            if (this.clicked.has(submenu)) {
               this.clicked.delete(submenu);
            }
         });
      }
      nav.nav.ariaExpanded = on;
      nav.nav.classList.toggle('open', on);
      nav.ariaHidden = !on;
      if (nav.nav.classList.contains('mobile')) {
         const content = nav.nav.querySelector(':scope > ul');
         if (content) content.inert = !on;
      }
      nav.nav.setAttribute('aria-expanded', on);
      if (on) {
         nav.nav.querySelector('a:not(.skip-to-content)')?.focus();
      }
   }
   toggleSubmenu(on, submenu) {
      if (on && this.openSubmenu && this.openSubmenu !== submenu) {
         this.toggleSubmenu(false, this.openSubmenu);
      }
      if (on) {
         this.openSubmenu = submenu;
      } else if (this.openSubmenu === submenu) {
         this.openSubmenu = null;
      }
      let [
         toggle,
         firstLink
@@ -151,16 +278,25 @@
         submenu.querySelector('a')
      ];
      if (!on) {
         toggle.focus();
      }
      const content = submenu.querySelector(':scope > ul');
      if (content) content.inert = !on;
      let label = toggle.getAttribute('aria-label');
      window.jvbA11y.announce(on ? `${label} expanded` : `${label} collapsed`);
      submenu.classList.toggle('open', on);
      submenu.ariaHidden = !on;
      toggle.ariaExpanded = on;
      toggle.setAttribute('aria-expanded', on);
      if (on && firstLink) {
         firstLink.focus();
      }
   }
}
document.addEventListener('DOMContentLoaded', function() {
   window.jvbNav = new Navigation();
});