SU-SWS/decanter

View on GitHub
core/src/js/components/main-nav/NavItem.js

Summary

Maintainability
D
2 days
Test Coverage
import {theSubNavs, closeAllSubNavs} from './globals';
import {
  isHome,
  isEnd,
  isTab,
  isSpace,
  isEnter,
  isLeftArrow,
  isRightArrow,
  isUpArrow,
  isDownArrow
} from '../../utilities/keyboard';
import Nav from './Nav';
import {createEvent} from '../../utilities/events';

/**
 * Represent an item in a navigation menu. May be a direct link or a subnav
 * trigger.
 *
 * @prop {HTMLLIElement}   item   - the <li> in the DOM that is the NavItem
 * @prop {HTMLElement|Nav} nav    - the Nav that contains the element.
 *                                  May be a main nav (<nav>) or subnav (Nav).
 * @prop {HTMLLIElement}   link   - the <a> in the DOM that is contained in
 *                                  item (the <li>).
 * @prop {Nav}             subNav - if item is the trigger for a subnav, this
 *                                  is an instonce Nav that models the subnav.
 */
export default class NavItem {

  /**
   * Create a NavItem
   * @param {HTMLLIElement}   item  - The <li> that is the NavItem in the DOM.
   * @param {HTMLElement|Nav} nav   - The Nav that contains the element. May
   *                                  be a main nav (<nav>) or a subnav (Nav).
   */
  constructor(item, nav) {
    this.item = item;
    this.nav = nav;
    this.link = this.item.querySelector('a');
    this.subNav = null;
    this.item.addEventListener('keydown', this);

    if (this.isSubNavTrigger()) {
      this.subNav = new Nav(this);
      // Add custom events to alert others when a subnav opens or closes.
      // this.openEvent is dispatched in this.openSubNav().
      this.openEvent = createEvent('openSubnav');
      // this.closeEvent is dispatched in this.closeSubNav().
      this.closeEvent = createEvent('closeSubnav');

      // Maintain global list of subnavs for closeAllSubNavs().
      theSubNavs.push(this);
      this.item.addEventListener('click', this);
    }
  }

  // -------------------------------------------------------------------------
  // Helper Methods.
  // -------------------------------------------------------------------------

  /**
   * Is this the first item in the containing Nav?
   *
   * @return {Boolean}
   *  Wether or not the item is the first item.
   */
  isFirstItem() {
    return this.nav.items.indexOf(this) === 0;
  }

  /**
   * Is this the last item in the containing Nav?
   *
   * @return {Boolean}
   *  Wether or not the item is the last item.
   */
  isLastItem() {
    return this.nav.items.indexOf(this) === (this.nav.items.length - 1);
  }

  /**
   * Is this a trigger that opens / closes a subnav?
   *
   * @return {Boolean}
   *  Wether or not the item is the sub nav trigger item.
   */
  isSubNavTrigger() {
    return this.item.lastElementChild.tagName.toUpperCase() === 'UL';
  }

  /**
   * Is this a component of a subnav - either the trigger or a nav item?
   *
   * @return {Boolean}
   *  Wether or not the item is a subnav item.
   */
  isSubNavItem() {
    return (this.isSubNavTrigger() || this.nav.isSubNav());
  }

  /**
   * Is this expanded? Can only return TRUE if this is a subnav trigger.
   *
   * @return {Boolean}
   *  Wether or not the item is expanded.
   */
  isExpanded() {
    return this.link.getAttribute('aria-expanded') === 'true';
  }

  /**
   * Set whether or not this is expanded.
   * Only meaningful if this is a subnav trigger.
   *
   * @param {String} value - What to set the aria-expanded attribute of this's
   *                         link to.
   */
  setExpanded(value) {
    this.link.setAttribute('aria-expanded', value);
  }

  // -------------------------------------------------------------------------
  // Functional Methods.
  // -------------------------------------------------------------------------

  /**
   * Handles the opening of a sub-nav.
   *
   * If this is a subnav trigger, open the corresponding subnav.
   * Optionally force focus on the first element in the subnav
   * (for keyboard nav).
   *
   * @param {Boolean} focusOnFirst - whether or not to also focus on the first
   *                                 element in the subnav
   */
  openSubNav(focusOnFirst = true) {
    closeAllSubNavs();

    if (this.isSubNavTrigger()) {
      this.item.classList.add('su-main-nav__item--expanded');
      this.setExpanded('true');
      if (focusOnFirst) {
        this.subNav.focusOn('first');
      }
      this.item.dispatchEvent(this.openEvent);
    }
  }

  /**
   * Handles the closing of a subnav.
   *
   * If this is a subnav trigger or an item in a subnav, close the
   * corresponding subnav. Optionally force focus on the trigger.
   *
   * @param {Boolean} focusOnTrigger - Whether or not to also focus on the
   *                                 subnav's trigger.
   */
  closeSubNav(focusOnTrigger = false) {
    if (this.isSubNavTrigger()) {
      if (this.isExpanded()) {
        this.item.classList.remove('su-main-nav__item--expanded');
        this.setExpanded('false');
        if (focusOnTrigger) {
          this.link.focus();
        }
        this.item.dispatchEvent(this.closeEvent);
      }
    }
    else if (this.isSubNavItem()) {
      // This.nav.elem should be a subNavTrigger.
      this.nav.elem.closeSubNav(focusOnTrigger);
    }
  }

  // -------------------------------------------------------------------------
  // Event Handlers.
  // -------------------------------------------------------------------------

  /**
   * Handler for all events attached to an instance of this class. This method
   * must exist when events are bound to an instance of a class
   * (vs a function). This method is called for all events bound to an
   * instance. It inspects the instance (this) for an appropriate handler
   * based on the event type. If found, it dispatches the event to the
   * appropriate handler.
   *
   * @param {KeyboardEvent} event - The keyboard event.
   *
   * @return {*}
   *   Whatever the dispatched handler returns (in our case nothing)
   */
  handleEvent(event) {
    event = event || window.event;

    // If this class has an onEvent method (onClick, onKeydown) invoke it.
    const handler = 'on'
      + event.type.charAt(0).toUpperCase()
      + event.type.slice(1);

    if (typeof this[handler] === 'function') {
      // The element that was clicked.
      const target = event.target || event.srcElement;
      return this[handler](event, target);
    }
  }

  /**
   * Handler for keydown events. keydown is bound to all NavItem's.
   * Dispatched from this.handleEvent().
   *
   * @param {KeyboardEvent} event - The keyboard event object.
   * @param {HTMLElement} target  - The HTML element target.
   */
  onKeydown(event, target) {
    const theKey = event.key || event.keyCode;

    // Handler for the space and enter key.
    if (isSpace(theKey) || isEnter(theKey)) {
      event.preventDefault();
      event.stopPropagation();
      if (this.isSubNavTrigger()) {
        this.openSubNav();
      }
      else {
        window.location = this.link;
      }
    }
    // Handler for the down arrow key.
    else if (isDownArrow(theKey)) {
      event.preventDefault();
      event.stopPropagation();
      if (this.nav.isDesktopNav()) {
        if (this.isSubNavTrigger()) {
          this.openSubNav();
        }
        else {
          this.nav.focusOn('next', this);
        }
      }
      else {
        this.nav.focusOn('next', this);
      }
    }
    // Handler for the up arrow key.
    else if (isUpArrow(theKey)) {
      event.preventDefault();
      event.stopPropagation();
      this.nav.focusOn('prev', this);
    }
    // Handler for the left arrow key.
    else if (isLeftArrow(theKey)) {
      event.preventDefault();
      event.stopPropagation();
      if (this.nav.isDesktopNav()) {
        if (this.nav.isSubNav()) {
          this.closeSubNav();
          let parent = this.nav.getParentNav();
          // Focus on the previous item in the parent nav.
          parent.focusOn('prev', this.nav.elem);
        }
        else {
          this.nav.focusOn('prev', this);
        }
      }
      else {
        if (this.isSubNavItem()) {
          // Close the subnav and put the focus on the trigger.
          this.closeSubNav(true);
        }
      }
    }
    // Handler for the right arrow key.
    else if (isRightArrow(theKey)) {
      event.preventDefault();
      event.stopPropagation();
      if (this.nav.isDesktopNav()) {
        if (this.nav.isSubNav()) {
          this.closeSubNav();
          let parent = this.nav.getParentNav();
          // Focus on the next item in the parent nav.
          parent.focusOn('next', this.nav.elem);
        }
        else {
          this.nav.focusOn('next', this);
        }
      }
      else {
        if (this.isSubNavTrigger()) {
          this.openSubNav();
        }
      }
    }
    // Handler for the home key.
    else if (isHome(theKey)) {
      this.nav.focusOn('first');
    }
    // Handler for the end key.
    else if (isEnd(theKey)) {
      this.nav.focusOn('last');
    }
    // Handler for the tab key.
    else if (isTab(theKey)) {
      event.stopPropagation();
      const shifted = event.shiftKey;
      if (this.isSubNavItem()
        && ((!shifted && this.isLastItem())
          || (shifted && this.isFirstItem()))
      ) {
        this.closeSubNav(true);
      }
    }
  }

  /**
   * Handler for click events.
   *
   * Dispatched from this.handleEvent().
   * Click is only bound to subnav triggers. However, click bubbles up from
   * subnav items to the trigger.
   *
   * @param {KeyboardEvent} event - The keyboard event object.
   * @param {HTMLElement} target  - The HTML element target.
   */
  onClick(event, target) {
    if (this.isExpanded()) {
      this.closeSubNav();
    }
    else {
      this.openSubNav(false);
    }
    // If the click is directly on the trigger, then we're done.
    if (target === this.link) {
      event.preventDefault();
      event.stopPropagation();
    }
  }

}