18F/web-design-standards

View on GitHub
packages/usa-accordion/src/index.js

Summary

Maintainability
A
2 hrs
Test Coverage
const select = require("../../uswds-core/src/js/utils/select");
const behavior = require("../../uswds-core/src/js/utils/behavior");
const toggle = require("../../uswds-core/src/js/utils/toggle");
const isElementInViewport = require("../../uswds-core/src/js/utils/is-in-viewport");
const { CLICK } = require("../../uswds-core/src/js/events");
const { prefix: PREFIX } = require("../../uswds-core/src/js/config");

const ACCORDION = `.${PREFIX}-accordion, .${PREFIX}-accordion--bordered`;
const BANNER_BUTTON = `.${PREFIX}-banner__button`;
const BUTTON = `.${PREFIX}-accordion__button[aria-controls]:not(${BANNER_BUTTON})`;
const EXPANDED = "aria-expanded";
const MULTISELECTABLE = "data-allow-multiple";

/**
 * Get an Array of button elements belonging directly to the given
 * accordion element.
 * @param {HTMLElement} accordion
 * @return {array<HTMLButtonElement>}
 */
const getAccordionButtons = (accordion) => {
  const buttons = select(BUTTON, accordion);

  return buttons.filter((button) => button.closest(ACCORDION) === accordion);
};

/**
 * Toggle a button's "pressed" state, optionally providing a target
 * state.
 *
 * @param {HTMLButtonElement} button
 * @param {boolean?} expanded If no state is provided, the current
 * state will be toggled (from false to true, and vice-versa).
 * @return {boolean} the resulting state
 */
const toggleButton = (button, expanded) => {
  const accordion = button.closest(ACCORDION);
  let safeExpanded = expanded;

  if (!accordion) {
    throw new Error(`${BUTTON} is missing outer ${ACCORDION}`);
  }

  safeExpanded = toggle(button, expanded);

  // XXX multiselectable is opt-in, to preserve legacy behavior
  const multiselectable = accordion.hasAttribute(MULTISELECTABLE);

  if (safeExpanded && !multiselectable) {
    getAccordionButtons(accordion).forEach((other) => {
      if (other !== button) {
        toggle(other, false);
      }
    });
  }
};

/**
 * @param {HTMLButtonElement} button
 * @return {boolean} true
 */
const showButton = (button) => toggleButton(button, true);

/**
 * @param {HTMLButtonElement} button
 * @return {boolean} false
 */
const hideButton = (button) => toggleButton(button, false);

const accordion = behavior(
  {
    [CLICK]: {
      [BUTTON]() {
        toggleButton(this);

        if (this.getAttribute(EXPANDED) === "true") {
          // We were just expanded, but if another accordion was also just
          // collapsed, we may no longer be in the viewport. This ensures
          // that we are still visible, so the user isn't confused.
          if (!isElementInViewport(this)) this.scrollIntoView();
        }
      },
    },
  },
  {
    init(root) {
      select(BUTTON, root).forEach((button) => {
        const expanded = button.getAttribute(EXPANDED) === "true";
        toggleButton(button, expanded);
      });
    },
    ACCORDION,
    BUTTON,
    show: showButton,
    hide: hideButton,
    toggle: toggleButton,
    getButtons: getAccordionButtons,
  }
);

module.exports = accordion;