My dropdowns & accessibility utils for a WCAG AA compliant WP theme
// Dropdown Menus
import { RestoreFocus, TrapFocus } from './utils';
/**
* * Grab all '.menu-item-has-children' nodes and add a11y dropdown functionality
* TODO: Make this more flexible (not strictly for WP nav elements)
*/
const subMenuParents = [...document.querySelectorAll('.menu-item-has-children')];
subMenuParents.map((element) => {
let menuString = element.querySelector('a').innerText;
element.setAttribute('aria-label', `Open ${menuString} sub-menu`);
element.setAttribute('aria-collapsed', 'true');
element.querySelector('a').setAttribute('href', '#');
// Set this sub-menu to its initial invisible state
var subMenu = element.querySelector('.sub-menu');
subMenu.style.display = 'none';
subMenu.setAttribute('aria-hidden', 'true');
// Tack on an instance function for displaying this submenu
subMenu.toggleSubMenu = function (event) {
var targetSubMenu = this.parentNode.querySelector('.sub-menu');
if (typeof targetSubMenu !== 'undefined') {
if (typeof event === 'object' && event !== null) {event.preventDefault()}
if (targetSubMenu.style.display !== 'inline-flex') {
// Enable visibility & trap focus
window.subMenuOpen = targetSubMenu;
targetSubMenu.style.display = 'inline-flex';
targetSubMenu.setAttribute('aria-hidden', 'false');
targetSubMenu.parentNode.setAttribute('aria-expanded', 'true');
targetSubMenu.parentNode.removeAttribute('aria-collapsed');
targetSubMenu.parentNode.setAttribute('aria-label', `Close ${menuString} sub-menu`);
TrapFocus(targetSubMenu.parentNode, targetSubMenu.querySelector('a'));
} else {
// Disable visibility & restore focus
targetSubMenu.style.display = 'none';
RestoreFocus();
targetSubMenu.setAttribute('aria-hidden', 'true');
targetSubMenu.parentNode.removeAttribute('aria-expanded');
targetSubMenu.parentNode.setAttribute('aria-collapsed', 'true');
targetSubMenu.parentNode.setAttribute('aria-label', `Open ${menuString} sub-menu`);
// Clean-up our window variable
delete window.subMenuOpen;
}
}
}
element.querySelector('a').addEventListener('click', subMenu.toggleSubMenu, {capture: true, once: false});
});
document.addEventListener('keyup', function (e) {
if (typeof window.subMenuOpen !== 'object' || typeof window.subMenuOpen === 'undefined') {return false}
if (typeof window.subMenuOpen.nodeType !== 'number') {return false}
var key = e.key || e.keyCode;
if (key === 'Escape' || key === 'Esc' || key === 27) {
window.subMenuOpen.parentNode.querySelector('a').click();
}
});
document.addEventListener('click', function (e) {
// Check for an active subMenu on the window object
if (typeof window.subMenuOpen !== 'object' || typeof window.subMenuOpen === 'undefined') {return false}
if (typeof window.subMenuOpen.nodeType !== 'number') {return false}
// If the clicked element is within the parentNode of the active sub-menu, toggle the menu
if (![...window.subMenuOpen.parentNode.querySelectorAll('*')].includes(event.target)) {
window.subMenuOpen.style.display = 'none';
// Restore the tab indicies of all the page elements, but don't return focus to the last focused element
RestoreFocus(false);
delete window.subMenuOpen;
}
});
// Utility Functions
/**
* MakeAllElementsUnfocusable
* @param {Array/NodeList} except Set of elements that will be exempt from this function
*/
const MakeAllElementsUnfocusable = (except) => {
// First, make sure 'except' is an array
var exceptions = [...except];
var tabElements = [...document.querySelectorAll('*')];
tabElements.map((element) => {
// Check that this element isn't exempt and this element has a tabindex value to remember
if (false === exceptions.includes(element)) {
// Add a variable to the element "oldTabIndex" that we'll restore later
if ( element.hasAttribute('tabindex') ) { element.oldTabIndex = element.getAttribute('tabindex') }
element.setAttribute('tabindex', '-1');
}
});
}
/**
* RestoreFocus
* * Reset every element's tabindex to its saved value, or remove the attribute
* * Set the activeElement to a previously-saved window.oldFocus variable
* @param passBack Boolean for whether or not focus should be returned to window.oldFocus
*/
const RestoreFocus = (passBack=true) => {
// Loop through all elements, if the element has an oldTabIndex, reset it
var allElements = [...document.querySelectorAll('*')];
allElements.map((element) => {
element.removeAttribute('tabindex');
if (typeof element.oldTabIndex !== 'undefined') {
element.setAttribute('tabindex', element.oldTabIndex);
element.oldTabIndex = null;
}
});
if (passBack) {
// Return focus to the last-focused element before focus was trapped
if (typeof window.oldFocus !== 'undefined') {window.oldFocus.focus()}
}
delete window.oldFocus;
}
/**
* TrapFocus
* * Trigger all elements to be unfocusable except for the given
* * element and its children
* @param element The DOM Node to trap focus within
*/
const TrapFocus = (element, focusTo) => {
// First, check that we've been passed a Node
if (typeof element === 'undefined') { return false }
if (typeof element !== 'object' && typeof element.nodeType !== 'number') { return false }
// Call our helper functions for making all elements unfocusable
var except = [...element.querySelectorAll('*'), element];
window.oldFocus = document.activeElement;
MakeAllElementsUnfocusable(except);
if (typeof focusTo !== 'undefined' && typeof focusTo.nodeType === 'number') {
focusTo.focus();
}
}
/**
* AddRecursiveEventHandler
* * This could be useful for listening to events that you want to COMPLETE once
* @param {Node} listenElement Element we're listening on
* @param {String} eventType EventType we're listening for
* @param {Function} handler Callback the event triggers - IMPORTANT THAT THIS RETURNS A VALUE
* @param {any} conditions Variable for the handler's return value to be compared to, determining if we listen again
*/
const AddRecursiveEventHandler = (listenElement, eventType, handler, conditions) => {
listenElement.addEventListener(eventType, (event) => {
if (handler === conditions) {
// Success (handler has run whatever operations necessary)
return true;
} else {
// Failed (listen again)
AddRecursiveEventHandler(listenElement, eventType, handler);
}
}, {once: true});
}
export { RestoreFocus, TrapFocus, AddRecursiveEventHandler }