webpro/DOMtastic

View on GitHub
src/event/index.js

Summary

Maintainability
B
4 hrs
Test Coverage
/**
 * @module Events
 */

import { each } from '../util';
import { closest } from '../selector/closest';

/**
 * Shorthand for `addEventListener`. Supports event delegation if a filter (`selector`) is provided.
 *
 * @param {String} eventNames List of space-separated event types to be added to the element(s)
 * @param {String} [selector] Selector to filter descendants that delegate the event to this element.
 * @param {Function} handler Event handler
 * @param {Boolean} useCapture=false
 * @param {Boolean} once=false
 * @return {Object} The wrapped collection
 * @chainable
 * @example
 *     $('.item').on('click', callback);
 *     $('.container').on('click focus', '.item', handler);
 */

export const on = function(eventNames, selector, handler, useCapture, once) {

  if(typeof selector === 'function') {
    handler = selector;
    selector = null;
  }

  let parts,
    namespace,
    eventListener;

  eventNames.split(' ').forEach(eventName => {

    parts = eventName.split('.');
    eventName = parts[0] || null;
    namespace = parts[1] || null;

    eventListener = proxyHandler(handler);

    each(this, element => {

      if(selector) {
        eventListener = delegateHandler.bind(element, selector, eventListener);
      }

      if(once) {
        const listener = eventListener;
        eventListener = event => {
          off.call(element, eventNames, selector, handler, useCapture);
          listener.call(element, event);
        };
      }

      element.addEventListener(eventName, eventListener, useCapture || false);

      getHandlers(element).push({
        eventName,
        handler,
        eventListener,
        selector,
        namespace
      });
    });

  }, this);

  return this;
};

/**
 * Shorthand for `removeEventListener`.
 *
 * @param {String} eventNames List of space-separated event types to be removed from the element(s)
 * @param {String} [selector] Selector to filter descendants that undelegate the event to this element.
 * @param {Function} handler Event handler
 * @param {Boolean} useCapture=false
 * @return {Object} The wrapped collection
 * @chainable
 * @example
 *     $('.item').off('click', callback);
 *     $('#my-element').off('myEvent myOtherEvent');
 *     $('.item').off();
 */

export const off = function(eventNames = '', selector, handler, useCapture) {

  if(typeof selector === 'function') {
    handler = selector;
    selector = null;
  }

  let parts,
    namespace,
    handlers;

  eventNames.split(' ').forEach(eventName => {

    parts = eventName.split('.');
    eventName = parts[0] || null;
    namespace = parts[1] || null;

    return each(this, element => {

      handlers = getHandlers(element);

      each(handlers.filter(item => {
        return (
          (!eventName || item.eventName === eventName) &&
          (!namespace || item.namespace === namespace) &&
          (!handler || item.handler === handler) &&
          (!selector || item.selector === selector)
        );
      }), item => {
        element.removeEventListener(item.eventName, item.eventListener, useCapture || false);
        handlers.splice(handlers.indexOf(item), 1);
      });

      if(!eventName && !namespace && !selector && !handler) {
        clearHandlers(element);
      } else if(handlers.length === 0) {
        clearHandlers(element);
      }

    });

  }, this);

  return this;
};

/**
 * Add event listener and execute the handler at most once per element.
 *
 * @param eventNames
 * @param selector
 * @param handler
 * @param useCapture
 * @return {Object} The wrapped collection
 * @chainable
 * @example
 *     $('.item').one('click', callback);
 */

export const one = function(eventNames, selector, handler, useCapture) {
  return on.call(this, eventNames, selector, handler, useCapture, 1);
};

/**
 * Get event handlers from an element
 *
 * @private
 * @param {Node} element
 * @return {Array}
 */

const eventKeyProp = '__domtastic_event__';
let id = 1;
let handlers = {};
let unusedKeys = [];

export const getHandlers = element => {
  if(!element[eventKeyProp]) {
    element[eventKeyProp] = unusedKeys.length === 0 ? ++id : unusedKeys.pop();
  }
  const key = element[eventKeyProp];
  return handlers[key] || (handlers[key] = []);
};

/**
 * Clear event handlers for an element
 *
 * @private
 * @param {Node} element
 */

export const clearHandlers = element => {
  const key = element[eventKeyProp];
  if(handlers[key]) {
    handlers[key] = null;
    element[eventKeyProp] = null;
    unusedKeys.push(key);
  }
};

/**
 * Function to create a handler that augments the event object with some extra methods,
 * and executes the callback with the event and the event data (i.e. `event.detail`).
 *
 * @private
 * @param handler Callback to execute as `handler(event, data)`
 * @return {Function}
 */

export const proxyHandler = handler => function(event) {
  return handler.call(this, augmentEvent(event));
};

const eventMethods = {
  preventDefault: 'isDefaultPrevented',
  stopImmediatePropagation: 'isImmediatePropagationStopped',
  stopPropagation: 'isPropagationStopped'
};
const returnTrue = () => true;
const returnFalse = () => false;

/**
 * Attempt to augment events and implement something closer to DOM Level 3 Events.
 *
 * @private
 * @param {Object} event
 * @return {Function}
 */

const augmentEvent = event => {
  if(!event.isDefaultPrevented || event.stopImmediatePropagation || event.stopPropagation) {
    for(const methodName in eventMethods) {
      (function(methodName, testMethodName, originalMethod) {
        event[methodName] = function() {
          this[testMethodName] = returnTrue;
          return originalMethod && originalMethod.apply(this, arguments);
        };
        event[testMethodName] = returnFalse;
      }(methodName, eventMethods[methodName], event[methodName]));
    }
    if(event._preventDefault) {
      event.preventDefault();
    }
  }
  return event;
};

/**
 * Function to test whether delegated events match the provided `selector` (filter),
 * if the event propagation was stopped, and then actually call the provided event handler.
 * Use `this` instead of `event.currentTarget` on the event object.
 *
 * @private
 * @param {String} selector Selector to filter descendants that undelegate the event to this element.
 * @param {Function} handler Event handler
 * @param {Event} event
 */

export const delegateHandler = function(selector, handler, event) {
  const eventTarget = event._target || event.target;
  const currentTarget = closest.call([eventTarget], selector, this)[0];
  if(currentTarget && currentTarget !== this) {
    if(currentTarget === eventTarget || !(event.isPropagationStopped && event.isPropagationStopped())) {
      handler.call(currentTarget, event);
    }
  }
};

export const bind = on;
export const unbind = off;