Initial commit to github
This commit is contained in:
225
static/js/modules/Navigation.js
Normal file
225
static/js/modules/Navigation.js
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* VDI Main Nav - All Main Navigation Functionality
|
||||
*
|
||||
* Please review documentation upon first use, and, as-needed:
|
||||
* https://docs.vincentdevelopment.ca/docs/starter-v3-enhancements/navigation/
|
||||
*/
|
||||
|
||||
/**
|
||||
* Navigation
|
||||
* Handles navigation logic
|
||||
*
|
||||
* @param {string} mobileMenuButtonId
|
||||
* @param {string} dropDownClass
|
||||
* @param {string} subMenuLinkClass
|
||||
*/
|
||||
class Navigation {
|
||||
/**
|
||||
* The main toggle element
|
||||
*
|
||||
* @type {HTMLElement}
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties}
|
||||
*/
|
||||
#mobileMenuButton;
|
||||
|
||||
/**
|
||||
* List of sub dropdown buttons
|
||||
*
|
||||
* @type {NodeList}
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties}
|
||||
*/
|
||||
#dropdownButtons;
|
||||
|
||||
/**
|
||||
* Class name to identify sub menu items (ul>li>a)
|
||||
* @type {string}
|
||||
*/
|
||||
#subMenuElementIdentifier;
|
||||
|
||||
|
||||
|
||||
constructor(mobileMenuButtonId, dropDownClass, subMenuLinkClass = "sub-menu-item") {
|
||||
this.#mobileMenuButton = document.getElementById(mobileMenuButtonId);
|
||||
this.#dropdownButtons = document.querySelectorAll(dropDownClass); // Do not change this to getElementsByClassName, logic is iterating over non-live node list
|
||||
this.#subMenuElementIdentifier = subMenuLinkClass;
|
||||
|
||||
this.handleEscapeKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles main mobile toggling
|
||||
* Adds an event listener to mobile menu dropdown
|
||||
* toggle button
|
||||
*/
|
||||
mobileMenuToggle() {
|
||||
this.#mobileMenuButton.addEventListener("click", (event) => {
|
||||
this.toggleMobileMenu(event, this.#mobileMenuButton); // toggle submenu on mobile
|
||||
});
|
||||
|
||||
this.closeOnBlur(this.#mobileMenuButton, { "navType": "desktop" }); // close submenu when user clicks outside
|
||||
this.closeOnBlur(this.#mobileMenuButton, { "navType": "mobile" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdowns on desktop navigation
|
||||
* Loops over list of navigation dropdown buttons
|
||||
* and adds eventlisteners to toggle view
|
||||
*/
|
||||
desktopMenuDropdowns() {
|
||||
this.#dropdownButtons.forEach((button) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
this.toggleDropdown(event, button);
|
||||
});
|
||||
|
||||
this.closeOnBlur(button); // close menu when user clicks outside
|
||||
this.handleFocus(button); // close dropdown when user finishes tabbing submenu elements
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles desktop dropdowns
|
||||
*
|
||||
* @param {EventTarget} event
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
toggleDropdown(event, button) {
|
||||
this.toggleAriaExpanded(event, button);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles mobile menu
|
||||
*
|
||||
* @param {EventTarget} event
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
toggleMobileMenu(event, button) {
|
||||
this.toggleAriaExpanded(event, button)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles aria-expanded attribute
|
||||
*
|
||||
* @param {EventTarget} event
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
toggleAriaExpanded(event, button) {
|
||||
let isExpanded = event.currentTarget.getAttribute("aria-expanded") === "true"; // true is returned as string
|
||||
|
||||
// ... but open the targeted secondary nav
|
||||
if (!isExpanded) {
|
||||
// close all dropdowns...
|
||||
this.closeAllDropDowns();
|
||||
// then toggle targeted dropdown...
|
||||
button.setAttribute("aria-expanded", true);
|
||||
} else {
|
||||
button.setAttribute("aria-expanded", false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dropdown when user clicks anywhere else on the screen
|
||||
*
|
||||
* @param {HTMLButtonElement} button
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event}
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent/relatedTarget}
|
||||
*/
|
||||
closeOnBlur(button, args) {
|
||||
if (args === undefined || args?.navType === "desktop") {
|
||||
button.addEventListener("blur", (event) => {
|
||||
if (event.relatedTarget == null) {
|
||||
event.currentTarget.setAttribute("aria-expanded", false);
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
//FIXME: Remove hardcoded literal
|
||||
if (args?.navType === "mobile") {
|
||||
document.getElementById("menu-container").addEventListener("blur", (event) => {
|
||||
// Note: cannot group negation -1*-1*-1 != -(1*1*1)
|
||||
const isNull = event.relatedTarget == null;
|
||||
const isNotMenuItem = !isNull && !event.relatedTarget.classList.contains("menu-vdi__toggle")
|
||||
&& !event.relatedTarget.classList.contains("sub-menu-item") && !event.relatedTarget.classList.contains("menu-vdi__link");
|
||||
|
||||
if (isNull || isNotMenuItem)
|
||||
button.setAttribute("aria-expanded", false);
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles escape behaviour
|
||||
* Closes all dropdown when user hits
|
||||
* escape key
|
||||
*
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/keyup_event}
|
||||
*/
|
||||
handleEscapeKey() {
|
||||
window.addEventListener("keyup", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
this.#mobileMenuButton.setAttribute("aria-expanded", false);
|
||||
this.closeAllDropDowns();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all dropdown menus
|
||||
* Sets aria expanded property by looping
|
||||
* over the list of dropdown button elements
|
||||
*
|
||||
* @link {https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded}
|
||||
*/
|
||||
closeAllDropDowns() {
|
||||
this.#dropdownButtons.forEach((button) => {
|
||||
button.setAttribute("aria-expanded", false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle focus
|
||||
* Watches blur event on submenu list container, if the next
|
||||
* focus element is not a submenu list item, closes the
|
||||
* dropdown.
|
||||
*
|
||||
* Implemented for WCAG 2.2 compliance
|
||||
*
|
||||
* @param {htmlButtonElement} button
|
||||
*/
|
||||
handleFocus(button) {
|
||||
const subMenuListElement = button.closest("ul");
|
||||
|
||||
/**
|
||||
* Ducking JavaScript, I am not sure why the blur event on submenu(ul)
|
||||
* would bubble up to the button since the button and sub menu list element
|
||||
* are siblings and not nested. This is a mystery to me.
|
||||
*
|
||||
* Here we are stopping any bubbling event that may be propagated to the
|
||||
* top level buttons. This includes the bubbling event from sub menu list element.
|
||||
*
|
||||
* If anyone finds a better solution to this or can explain bubbling of focus event
|
||||
* to next sibling, you time and effort would be much appreciated.
|
||||
*/
|
||||
button.addEventListener("focusout", (event) => {
|
||||
event.stopImmediatePropagation();
|
||||
})
|
||||
|
||||
subMenuListElement.addEventListener("focusout", (event) => {
|
||||
// blur event triggers when user clicks outside
|
||||
const nextFocusElement = event.relatedTarget;
|
||||
|
||||
let isSubMenuElement;
|
||||
|
||||
if (nextFocusElement !== null) {
|
||||
isSubMenuElement = nextFocusElement.classList.contains(this.#subMenuElementIdentifier);
|
||||
} else {
|
||||
isSubMenuElement = false; // close when user clicks outside
|
||||
}
|
||||
|
||||
if (!isSubMenuElement) {
|
||||
this.closeAllDropDowns();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
Reference in New Issue
Block a user