bunjomin
10/2/2018 - 1:55 PM

Accessible Dropdown + Utils

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 }