changeState.js

/**
 * @module htz-collapsibles/changeState
 * @license MIT
 */

import elHasLayout from 'htz-collapsibles/elHasLayout';
import isCollapsible from 'htz-collapsibles/isCollapsible';
import { parseBpsState } from 'htz-parse-bps-state';
import dispatchEvent from 'htz-dispatch-event';

/**
 * Toggle an element's state between collapsed or expanded
 *
 * @param {HTMLElement} wrapper - The element wrapping
 * @param {HTMLElement} contentElem - The element containing the collapsible content.
 * @param {HTMLElement} toggleElem - The clickable element used for toggling.
 * @param {String} labelExpand - The label used to describe the toggle button
 *    when the element is collapsed and the button is used for expanding.
 * @param {String} labelCollapse - The label used to describe the toggle button
 *    when the element is expanded and the button is used for collapsing.
 * @param {String} collapsedClass - The label to attach to the wrapper when it is collapsed.
 * @param {String} expandedClass - The label to attach to the wrapper when it is expanded.
 * @param {String} bpsSelector - A selector for the element to which the active
 *    breakpoint data is attached. See
 *    [htz-parse-bps-state](https://github.com/haaretz/htz-parse-bps-state)
 * @param {Boolean} [isExpanded] - Indicates if the collapsible is collapsed (`false`) or
 *   expanded (true`).
 *
 * @fires module:htz-htz-collapsibles/changeState~expand#htz-collapsibles:expand-before
 * @fires module:htz-htz-collapsibles/changeState~expand#htz-collapsibles:expand-after
 * @fires module:htz-htz-collapsibles/changeState~collapse#htz-collapsibles:collapse-before
 * @fires module:htz-htz-collapsibles/changeState~collapse#htz-collapsibles:collapse-after
 *
 * @return {Boolean} The collapsible's state after toggling
 *     (`true` if expanded, `false` if collapsed).
 */
function toggle(
  wrapper,
  contentElem,
  toggleElem,
  labelExpand,
  labelCollapse,
  collapsedClass,
  expandedClass,
  bpsSelector,
  isExpanded
) {
  return ((isExpanded !== undefined && !isExpanded) || isCollapsed(contentElem)) ?
    expand(
      wrapper,
      contentElem,
      toggleElem,
      labelExpand,
      labelCollapse,
      collapsedClass,
      expandedClass,
      bpsSelector,
      isExpanded
    ) :
    collapse(
      wrapper,
      contentElem,
      toggleElem,
      labelExpand,
      labelCollapse,
      collapsedClass,
      expandedClass,
      bpsSelector,
      isExpanded
    );
}


/**
 * Collapse a collapsible element
 *
 * @param {HTMLElement} wrapper - The element wrapper
 * @param {HTMLElement} contentElem - The element containing the collapsible content.
 * @param {HTMLElement} toggleElem - The clickable element used for toggling.
 * @param {String} labelExpand - The label used to describe the toggle button
 *    when the element is collapsed and it is used expanding.
 * @param {String} labelCollapse - The label used to describe the toggle button
 *    when the element is expanded and it is used collapsing.
 * @param {String} collapsedClass - The label to attach to the wrapper when it is collapsed.
 * @param {String} expandedClass - The label to attach to the wrapper when it is expanded.
 * @param {String} bpsSelector - A selector for the element to which the active
 *    breakpoint data is attached. See
 *    [htz-parse-bps-state](https://github.com/haaretz/htz-parse-bps-state)
 * @param {Boolean} [isExpanded] - Indicates if the collapsible is currently collapsed (`false`) or
 *   expanded (true`).
 *
 * @fires module:htz-htz-collapsibles/changeState~collapse#htz-collapsibles:collapse-before
 * @fires module:htz-htz-collapsibles/changeState~collapse#htz-collapsibles:collapse-after
 *
 * @return {Boolean} Returns `false` to indicate the element has been collapsed.
 */
function collapse(
  wrapper,
  contentElem,
  toggleElem,
  labelExpand,
  labelCollapse,
  collapsedClass,
  expandedClass,
  bpsSelector,
  isExpanded
) {
  const expandedClassRegex = new RegExp(`(${expandedClass}[^'"\\s]*)`, 'gi');


  if ((isExpanded !== undefined && isExpanded) || !isCollapsed(contentElem)) {
    const bpsState = parseBpsState(bpsSelector);

    // Only collapse elements if collapsing is allowed at current breakpoint.
    if (isCollapsible(wrapper, bpsState)) {
      /**
      * A custom event fired before a collapsible is collapsed
      * Stops execution if any of its handlers calls `event.preventDefault`.
      *
      * @event module:htz-htz-collapsibles/changeState~collapse#htz-collapsibles:collapse-before
      * @type {Object}
      * @prop {Object} detail
      * @prop {HTMLElement} detail.wrapper - The element wrapping
      * @prop {HTMLElement} detail.contentElem - The element containing the collapsible content.
      * @prop {HTMLElement} detail.toggleElem - The clickable element used for toggling.
      * @prop {Function} detail.isCollapsed - Retuns a `Boolean` indicating if the
      *     element is collapsed.
      */
      const allowed = dispatchEvent(
        wrapper,
        'htz-collapsibles:expand-before',
        { wrapper, contentElem, toggleElem, isCollapsed }
      );

      if (allowed) {
        toggleElem.focus();
        wrapper.classList.add(collapsedClass);
        /* eslint-disable no-param-reassign */
        wrapper.className = wrapper.className.replace(expandedClassRegex, '');
        /* eslint-enable no-param-reassign */
        toggleElem.setAttribute('aria-label', labelExpand);
        toggleElem.setAttribute('aria-expanded', false);

        /**
        * A custom event fired after a collapsible is collapsed
        *
        * @event module:htz-htz-collapsibles/changeState~collapse#htz-collapsibles:collapse-after
        * @type {Object}
        * @prop {Object} detail
        * @prop {HTMLElement} detail.wrapper - The element wrapping
        * @prop {HTMLElement} detail.contentElem - The element containing the collapsible content.
        * @prop {HTMLElement} detail.toggleElem - The clickable element used for toggling.
        * @prop {Function} detail.isCollapsed - Retuns a `Boolean` indicating if the
        *     element is collapsed.
        */
        dispatchEvent(
          wrapper,
          'htz-collapsibles:collapse-after',
          { wrapper, contentElem, toggleElem, isCollapsed }
        );
      }
    }
  }

  return false;
}


/**
 * Expand a collapsible element
 *
 * @param {HTMLElement} wrapper - The element wrapper
 * @param {HTMLElement} contentElem - The element containing the collapsible content.
 * @param {HTMLElement} toggleElem - The clickable element used for toggling.
 * @param {String} labelExpand - The label used to describe the toggle button
 *    when the element is collapsed and it is used expanding.
 * @param {String} labelCollapse - The label used to describe the toggle button
 *    when the element is expanded and it is used collapsing.
 * @param {String} collapsedClass - The label to attach to the wrapper when it is collapsed.
 * @param {String} expandedClass - The label to attach to the wrapper when it is expanded.
 * @param {String} bpsSelector - A selector for the element to which the active
 *    breakpoint data is attached. See
 *    [htz-parse-bps-state](https://github.com/haaretz/htz-parse-bps-state)
 * @param {Boolean} [isExpanded] - Indicates if the collapsible is currently collapsed (`false`) or
 *   expanded (true`).
 *
 * @fires module:htz-htz-collapsibles/changeState~expand#htz-collapsibles:expand-before
 * @fires module:htz-htz-collapsibles/changeState~expand#htz-collapsibles:expand-after
 *
 * @return {Boolean} Returns `true` to indicate the element has been expanded.
 */
function expand(
  wrapper,
  contentElem,
  toggleElem,
  labelExpand,
  labelCollapse,
  collapsedClass,
  expandedClass,
  bpsSelector,
  isExpanded
) {
  const collapsedClassRegex = new RegExp(`(${collapsedClass}[^'"\\s]*)`, 'gi');

  if ((isExpanded !== undefined && !isExpanded) || isCollapsed(contentElem)) {
    /**
     * A custom event fired before a collapsible is expanded
     * Stops execution if any of its handlers calls `event.preventDefault`.
     *
     * @event module:htz-htz-collapsibles/changeState~expand#htz-collapsibles:expand-before
     * @type {Object}
     * @prop {Object} detail
     * @prop {HTMLElement} detail.wrapper - The element wrapping
     * @prop {HTMLElement} detail.contentElem - The element containing the collapsible content.
     * @prop {HTMLElement} detail.toggleElem - The clickable element used for toggling.
     * @prop {Function} detail.isCollapsed - Retuns a `Boolean` indicating if the
     *     element is collapsed.
     */
    const allowed = dispatchEvent(
      wrapper,
      'htz-collapsibles:expand-before',
      { wrapper, contentElem, toggleElem, isCollapsed }
    );

    if (allowed) {
      wrapper.classList.add(expandedClass);
      /* eslint-disable no-param-reassign */
      wrapper.className = wrapper.className.replace(collapsedClassRegex, '');
      /* eslint-enable no-param-reassign */
      toggleElem.setAttribute('aria-label', labelCollapse);
      toggleElem.setAttribute('aria-expanded', true);

      // move focus to expanded content elem
      // Ugly timeout to ensure element is already visible and focusable.
      window.setTimeout(() => contentElem.focus(), 200);

      /**
       * A custom event fired after a collapsible is expanded
       *
       * @event module:htz-htz-collapsibles/changeState~expand#htz-collapsibles:expand-after
       * @type {Object}
       * @prop {Object} detail
       * @prop {HTMLElement} detail.wrapper - The element wrapping
       * @prop {HTMLElement} detail.contentElem - The element containing the collapsible content.
       * @prop {HTMLElement} detail.toggleElem - The clickable element used for toggling.
       * @prop {Function} detail.isCollapsed - Retuns a `Boolean` indicating if the
       *     element is collapsed.
       */
      dispatchEvent(
        wrapper,
        'htz-collapsibles:expand-after',
        { wrapper, contentElem, toggleElem, isCollapsed }
      );
    }
  }

  return true;
}


/**
 * Check if an element is collapsed
 *
 * @param {HtmlElement} elem - The elemnt to test
 *
 * @return {Boolean}
 */
function isCollapsed(elem) {
  const isInvisible = window.getComputedStyle(elem).visibility === 'hidden';

  return !elHasLayout(elem) || isInvisible || !elem;
}


export { collapse, expand, toggle, isCollapsed };