lib/utils.js

/**
 * HTZ A11Y TABS UTILS
 *
 * Utility function for the htz-a11y-tabs module
 * @module htz-a11y-tabs/utils
 */

import dispatchEvent from 'htz-dispatch-event';

/**
 * Initialize an instance
 *
 * @param {HTMLElement} container - The wrapper element around the tabs and tab panels
 * @param {String} tablistSelector - The tablist's selector.
 * @param {String} tabpanelSelector - The tabpanel's selector.
 * @param {clickHandler} clickHandler - A callback to handle click events.
 * @param {keyHandler} keyHandler - A callback to handle keydown events.
 * @param {Integer} activeTab - The tab number to activate. Zero based.
 *
 * @fires module:htz-a11y-tabs~a11y-tabs:init
 *
 * @return {Array} - An array whose items are:
 *    0: the `tablist` HTMLElement
 *    1: An Array of the clickable tab HTMLElements
 */
export function initialize(
  container,
  tablistSelector,
  tabpanelSelector,
  clickHandler,
  keyHandler,
  activeTab
) {
  const tablist = container.querySelector(tablistSelector);
  const tabpanels = Array.from(container.querySelectorAll(tabpanelSelector));
  const tabs = Array.from(tablist.children);
  const clickables = Array.from(tablist.querySelectorAll('a, button'));

  tablist.setAttribute('role', 'tablist');

  tabs.forEach((tab, index) => {
    const clickable = tab.querySelector('a, button');
    const tabpanel = tabpanels[index];
    const isActive = index === activeTab;
    const href = clickable.href;
    const controls = href ? href.match(/#([^?&]*)/)[1] :
      tabpanel ?
        tabpanel.id || `tab${Math.random()}` :
        `tab${Math.random()}`;

    tab.setAttribute('role', 'presentation');
    clickable.setAttribute('role', 'tab');
    clickable.setAttribute('tabindex', isActive ? '0' : '-1');
    clickable.setAttribute('aria-controls', controls);

    isActive ?
      clickable.setAttribute('aria-selected', 'true') :
      clickable.removeAttribute('aria-selected');


    tabpanel.setAttribute('role', 'tabpanel');
    isActive ?
      tabpanel.removeAttribute('aria-hidden') :
      tabpanel.setAttribute('aria-hidden', 'true');

    if (!tabpanel.id) tabpanel.id = controls;
    if (isActive) makeFocusable(tabpanel);
  });

  // Attach event listeners
  tablist.addEventListener('keydown', keyHandler);
  tablist.addEventListener('click', clickHandler);

  /**
   * Fired from `container` when a tab interface is initialized
   * @event module:htz-a11y-tabs~a11y-tabs:init
   *
   * @type {Object}
   *
   * @prop {Object} detail
   * @prop {HTMLElement} detail.activeTab - The active tab element.
   * @prop {HTMLElement} detail.activeTabpanel - The active tabpanel element.
   */
  dispatchEvent(
    container,
    'a11y-tabs:init',
    {
      activeTab: clickables[activeTab],
      activeTabpanel: tabpanels[activeTab],
    }
  );

  return [clickables, tabpanels];
}


/**
 * Destroy an instance. Removes event listeners and, optionally,
 * accessibility attributes from the DOM.
 *
 * @param {HTMLElement} container - The container wrapping the tab interface
 * @param {String} tablistSelector - The tablist's selector.
 * @param {String} tabpanelSelector - The tabpanel's selector.
 * @param {clickHandler} clickHandler - A callback to handle click events.
 * @param {keyHandler} keyHandler - A callback to handle keydown events.
 * @param {Boolean} [removeAttrs] - Determine if attributes should be remove
 */
export function destroyInstance(
  container,
  tablistSelector,
  tabpanelSelector,
  clickHandler,
  keyHandler,
  removeAttrs
) {
  const tablist = container.querySelector(tablistSelector);

  // Remove event listeners
  tablist.removeEventListener('click', clickHandler);
  tablist.removeEventListener('keydown', keyHandler);

  if (removeAttrs) {
    const tabpanels = Array.from(container.querySelectorAll(tabpanelSelector));
    const tabs = Array.from(tablist.children);

    tablist.removeAttribute('role');

    tabs.forEach((tab, index) => {
      const clickable = tab.querySelector('a, button');
      const tabpanel = tabpanels[index];

      tab.removeAttribute('role');
      clickable.removeAttribute('role');
      clickable.removeAttribute('tabindex');
      clickable.removeAttribute('aria-controls');
      clickable.removeAttribute('aria-selected');
      tabpanel.removeAttribute('role');
      tabpanel.removeAttribute('aria-hidden');
    });
  }

  /**
   * Fired from `container` after a tab interface has been destroyed.
   *
   * @event module:htz-a11y-tabs~a11y-tabs:destroy
   *
   * @type {Object}
   */
  dispatchEvent(container, 'a11y-tabs:destroy');
}


/**
 * Go to the next tab
 *
 * @param {HTMLElement} container - The container wrapping the tab interface
 * @param {HTMLElement[]} tabs - An array containing the tab elements
 * @param {HTMLElement[]} tabpanels - An array containing tabpanel element
 * @param {Integer} activeTabIndex - The index of the currently active tab
 * @param {Boolean} focus - Determine if the newly activated tab should be focused.
 *
 * @return {Integer} The index of the newly active tab.
 */
export function nextTab(container, tabs, tabpanels, activeTabIndex, focus) {
  const targetIndex = activeTabIndex + 1;

  return gotoTab(targetIndex, container, tabs, tabpanels, activeTabIndex, focus);
}

/**
 * Go to the previous tab
 *
 * @param {HTMLElement} container - The container wrapping the tab interface
 * @param {HTMLElement[]} tabs - An array containing the tab elements
 * @param {HTMLElement[]} tabpanels - An array containing tabpanel element
 * @param {Integer} activeTabIndex - The index of the currently active tab
 * @param {Boolean} focus - Determine if the newly activated tab should be focused.
 *
 * @return {Integer} The index of the newly active tab.
 */
export function prevTab(container, tabs, tabpanels, activeTabIndex, focus) {
  const targetIndex = activeTabIndex - 1;

  return gotoTab(targetIndex, container, tabs, tabpanels, activeTabIndex, focus);
}


/**
 * Go to a tab
 *
 * @param {Integer} targetIndex - The index of the tab to be activated
 * @param {HTMLElement} container - The container wrapping the tab interface
 * @param {HTMLElement[]} tabs - An array containing the tab elements
 * @param {HTMLElement[]} tabpanels - An array containing tabpanel element
 * @param {Integer} activeTabIndex - The index of the currently active tab
 * @param {Boolean} focus - Determine if the newly activated tab should be focused.
 *
 * @fires module:htz-a11y-tabs~a11y-tabs:before-select
 * @fires module:htz-a11y-tabs~a11y-tabs:after-select
 *
 * @return {Integer} The index of the newly active tab.
 */
export function gotoTab(targetIndex, container, tabs, tabpanels, activeTabIndex, focus) {
  const currentTab = tabs[activeTabIndex];
  const targetTab = tabs[targetIndex];
  const currentTabpanel = tabpanels[activeTabIndex];
  const targetTabpanel = tabpanels[targetIndex];


  if (targetTab && targetTabpanel) {
    /**
     * Fired from `container` before a tab selection is applied. If the event
     * handler executes `event.preventDefault()`, the selection will not be applied.
     *
     * @event module:htz-a11y-tabs~a11y-tabs:before-select
     *
     * @type {Object}
     * @prop {Object} detail
     * @prop {HTMLElement} detail.currentTab - The currently active tab
     * @prop {HTMLElement} detail.targetTab - The tab that will be activated
     *    after the selection is applied.
     * @prop {HTMLElement} detail.currentTabpanel - The currently active tabpanel
     * @prop {HTMLElement} detail.targetTabpanel - The tabpanel that will be
     *    activated after the selection is applied.
     */
    const allowed = dispatchEvent(
      container,
      'a11y-tabs:before-select',
      { currentTab, targetTab, currentTabpanel, targetTabpanel }
    );

    if (allowed) {
      handleTabSwitch(currentTab, targetTab, currentTabpanel, targetTabpanel, focus);

      /**
       * Fired from `container` after a tab selection is applied.
       *
       * @event module:htz-a11y-tabs~a11y-tabs:after-select
       *
       * @type {Object}
       * @prop {Object} detail
       * @prop {HTMLElement} detail.prevTab - The previously active tab
       * @prop {HTMLElement} detail.targetTab - The tab that has been activated
       *    by the selection.
       * @prop {HTMLElement} detail.prevTabpanel - The previously active tabpanel
       * @prop {HTMLElement} detail.targetTabpanel - The tabpanel that will be activated
       *    after the selection is applied.
       */
      dispatchEvent(
        container,
        'a11y-tabs:after-select',
        {
          prevTab: currentTab,
          targetTab,
          prevTabpanel: currentTabpanel,
          targetTabpanel,
        }
      );

      return targetIndex;
    }
  }

  return undefined;
}

/**
 * Make the first child of an element focusable. If the element has
 * no children, make the element itself focusable.
 *
 * @param {HTMLElement} elem - The Element to target
 *
 * @return {HTMLElement} - The focusable element.
 *
 * @private
 */
export function makeFocusable(elem) {
  const firstChild = elem.firstElementChild;

  (firstChild || elem).setAttribute('tabindex', '0');

  return firstChild || elem;
}

/**
 * Handle DOM changes related to switching tabs
 *
 * @param {HTMLElement} currentTab - The currently selected tab
 * @param {HTMLElement} targetTab - The tab to be selected
 * @param {HTMLElement} currentTabpanel - The currently selected tabpanel
 * @param {HTMLElement} targetTabpanel - The tabpanel to be selected
 * @param {Boolean} moveFocus - Determine if the newly activated tab should be focused.
 *
 * @private
 */
function handleTabSwitch(currentTab, targetTab, currentTabpanel, targetTabpanel, moveFocus) {
  currentTab.setAttribute('tabindex', '-1');
  currentTab.removeAttribute('aria-selected');

  targetTab.setAttribute('tabindex', '0');
  targetTab.setAttribute('aria-selected', 'true');
  targetTab.focus();

  currentTabpanel.setAttribute('aria-hidden', 'true');
  targetTabpanel.removeAttribute('aria-hidden');

  const focusable = makeFocusable(targetTabpanel);

  if (moveFocus) focusable.focus();
}