Source: energize.js

/**
 * This breathes life into your DOM and unlocks a set of data attributes to
 * quickly build interfaces. Combined with some smart CSS, you can use these
 * data attributes to create tabs, toggles, popups, caroussels, fancy radio
 * buttons and almost anything you could want. I generally only use this for
 * prototyping things, but you could use it for small projects.
 *
 * # How to use
 *
 * You activate a certain scope in your DOM:
 *
 * ```javascript
 * import Energize from './energize.js';
 * new Energize('#my-app');
 * ```
 *
 * And then you can use data attributes to add and remove a class from different
 * elements in different ways:
 *
 * ```html
 * <section id="example">
 *   <a data-open="#left" class="active">Left</a>
 *   <a data-open="#center">Center</a>
 *   <a data-open="#right">Right</a>
 *
 *   <div class="tab active" id="left" data-group="tabs" data-follower="a[data-open='#left']">
 *     <p>I'm behind the leftmost link!</p>
 *     <a data-close="#left">Close me</a>
 *   </div>
 *
 *   <div class="tab" id="center" data-group="tabs" data-follower="a[data-open='#center']">
 *     <p id="colour">I'm the panel behind the center link</p>
 *     <a data-toggle="#colour">Toggle me!</a>
 *   </div>
 *
 *   <div class="tab" id="right" data-group="tabs" data-follower="a[data-open='#right']" data-timer="2000">
 *     <p>I'm the last panel, behind the rightmost link</p>
 *     <p>I disappear automatically after two seconds</p>
 *   </div>
 * </section>
 * ```
 *
 * The default class that gets put on your elements is `active`, the default data
 * attributes are:
 *
 *  - `data-open` — A selector to put the `active` class on when clicked
 *  - `data-close` — A selector to remove the `active` class from when clicked
 *  - `data-toggle` — A selector to toggle the `active` class on when clicked
 *  - `data-group` — If I get the `active` class, remove it from others in my group
 *  - `data-timer` — If I get the `active` class, remove it again after this many milliseconds
 *  - `data-follower` — A selector for another element that follows my behaviour
 *
 * If you wish, you can override the class name and the names of all the
 * attributes as options to the `Energize` constructor.
 *
 * @module
 */

export default class Energize {
  constructor(scope, options = {}) {
    this._scope = scope;
    this._options = this._normalizeOptions(options);

    Click.instance().register(
      `${scope} [${this._options.open}], ${scope} [${this._options.close}], ${scope} [${this._options.toggle}]`,
      (e) => this._handleClick(e)
    );
  }

  _normalizeOptions(options) {
    return Object.assign(
      {
        class: "active",
        open: "data-open",
        close: "data-close",
        toggle: "data-toggle",
        group: "data-group",
        timer: "data-timer",
        follower: "data-follower",
      },
      options
    );
  }

  _handleClick(evnt) {
    // Which element did we click?
    const target = evnt.target.closest(
      `[${this._options.open}], [${this._options.close}], [${this._options.toggle}]`
    );

    // What does the clicked element wish to open, close or toggle?
    const closeSelector = target.getAttribute(this._options.close);
    const openSelector = target.getAttribute(this._options.open);
    const toggleSelector = target.getAttribute(this._options.toggle);

    let closeElements = closeSelector
      ? document.querySelectorAll(`${this._scope} ${closeSelector}`)
      : [];
    let openElements = openSelector
      ? document.querySelectorAll(`${this._scope} ${openSelector}`)
      : [];

    // Add elements that need to be toggled
    closeElements = [
      ...closeElements,
      ...(toggleSelector
        ? document.querySelectorAll(
            `${this._scope} ${toggleSelector}.${this._options.class}`
          )
        : []),
    ];
    openElements = [
      ...openElements,
      ...(toggleSelector
        ? document.querySelectorAll(
            `${this._scope} ${toggleSelector}:not(.${this._options.class})`
          )
        : []),
    ];

    this._close(closeElements);
    this._open(openElements);

    // We're done with this event, don't try to evaluate it any further
    evnt.preventDefault();
    evnt.stopPropagation();
  }

  _close(elements) {
    elements.forEach((element) => {
      element.classList.remove(this._options.class);
      this._close(this._followers(element));
    });
  }

  _open(elements) {
    elements.forEach((element) => {
      this._close(this._group(element));
      element.classList.add(this._options.class);
      this._open(this._followers(element));

      // Set self-destruct timer if needed
      const delay = element.getAttribute(this._options.timer);
      if (delay) window.setTimeout(() => this._close([element]), delay);
    });
  }

  _group(element) {
    const group = element.getAttribute(this._options.group);
    if (!group) return [];
    return [
      ...document.querySelectorAll(
        `${this._scope} [${this._options.group}=${group}]`
      ),
    ];
  }

  _followers(element) {
    const selector = element.getAttribute(this._options.follower);
    if (!selector) return [];
    return [...document.querySelectorAll(`${this._scope} ${selector}`)];
  }
}

/*
 * This class installs one single click handler on the whole document, and
 * evaluates which callback to call at click time, based on the element that has
 * been clicked. This allows us to swap out and rerender whole sections of the
 * DOM without having to reinstall a bunch of click handlers each time. This
 * nicely decouples the render logic from the click event management logic.
 *
 * To make sure we really only install a single click handler, you can use the
 * singleton pattern and ask for `Click.instance()` instead of creating a new
 * object.
 */

class Click {
  constructor() {
    this._handlers = {};

    document.addEventListener("click", (e) => this._callHandler("click", e));
    document.addEventListener("mousedown", (e) =>
      this._callHandler("mousedown", e)
    );
    document.addEventListener("mouseup", (e) =>
      this._callHandler("mouseup", e)
    );
  }

  register(
    selector,
    handlers = { click: null, mousedown: null, mouseup: null }
  ) {
    if (typeof handlers == "function") handlers = { click: handlers };
    this._handlers[selector] = this._handlers[selector] || [];
    this._handlers[selector].push(handlers);
  }

  _callHandler(type, e) {
    Object.keys(this._handlers).forEach((selector) => {
      if (e.target.closest(selector) !== null) {
        const handlers = this._handlers[selector].map((h) => h[type]);
        handlers.forEach((handler) => {
          if (typeof handler == "function" && !e.defaultPrevented)
            handler(e, selector);
        });
      }
    });
  }
}

Click.instance = function () {
  if (!!Click._instance) return Click._instance;
  return (Click._instance = new Click());
};