From 48721c85ebcfa973ee81719d2467ca80e4253dc9 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 01 May 2026 17:30:03 +0000
Subject: [PATCH] =Edmonton Ink hard test begins! Real testing of the managers and reset routes will commence. So far, just ensuring our classes are all loaded correctly: Site() and its sub-classes Membership, Login, etc. Care should be taken to load conditionally on 'init', as we finish defining most settings by 'plugins_loaded' at priority 5

---
 assets/js/concise/navigation.js |  214 +++++++++++++++++++++++++++++++++++++++++++---------
 1 files changed, 175 insertions(+), 39 deletions(-)

diff --git a/assets/js/concise/navigation.js b/assets/js/concise/navigation.js
index 77c15e9..3a9550f 100644
--- a/assets/js/concise/navigation.js
+++ b/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"]')
+		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();
 });

--
Gitblit v1.10.0