ftdomdelegate
Version:
FT's dom delegate library is a library for creating and binding to events on all target elements matching the given selector.
481 lines (420 loc) • 11.7 kB
JavaScript
/**
* DOM event delegator
*
* The delegator will listen
* for events that bubble up
* to the root node.
*
* @constructor
* @param {Node|string} [root] The root node or a selector string matching the root node
*/
function Delegate(root) {
/**
* Maintain a map of listener
* lists, keyed by event name.
*
* @type Object
*/
this.listenerMap = [{}, {}];
if (root) {
this.root(root);
}
/** @type function() */
this.handle = Delegate.prototype.handle.bind(this);
// Cache of event listeners removed during an event cycle
this._removedListeners = [];
}
/**
* Start listening for events
* on the provided DOM element
*
* @param {Node|string} [root] The root node or a selector string matching the root node
* @returns {Delegate} This method is chainable
*/
Delegate.prototype.root = function (root) {
const listenerMap = this.listenerMap;
let eventType;
// Remove master event listeners
if (this.rootElement) {
for (eventType in listenerMap[1]) {
if (listenerMap[1].hasOwnProperty(eventType)) {
this.rootElement.removeEventListener(eventType, this.handle, true);
}
}
for (eventType in listenerMap[0]) {
if (listenerMap[0].hasOwnProperty(eventType)) {
this.rootElement.removeEventListener(eventType, this.handle, false);
}
}
}
// If no root or root is not
// a dom node, then remove internal
// root reference and exit here
if (!root || !root.addEventListener) {
if (this.rootElement) {
delete this.rootElement;
}
return this;
}
/**
* The root node at which
* listeners are attached.
*
* @type Node
*/
this.rootElement = root;
// Set up master event listeners
for (eventType in listenerMap[1]) {
if (listenerMap[1].hasOwnProperty(eventType)) {
this.rootElement.addEventListener(eventType, this.handle, true);
}
}
for (eventType in listenerMap[0]) {
if (listenerMap[0].hasOwnProperty(eventType)) {
this.rootElement.addEventListener(eventType, this.handle, false);
}
}
return this;
};
/**
* @param {string} eventType
* @returns boolean
*/
Delegate.prototype.captureForType = function (eventType) {
return ['blur', 'error', 'focus', 'load', 'resize', 'scroll'].indexOf(eventType) !== -1;
};
/**
* Attach a handler to one
* event for all elements
* that match the selector,
* now or in the future
*
* The handler function receives
* three arguments: the DOM event
* object, the node that matched
* the selector while the event
* was bubbling and a reference
* to itself. Within the handler,
* 'this' is equal to the second
* argument.
*
* The node that actually received
* the event can be accessed via
* 'event.target'.
*
* @param {string} eventType Listen for these events
* @param {string|undefined} selector Only handle events on elements matching this selector, if undefined match root element
* @param {function()} handler Handler function - event data passed here will be in event.data
* @param {boolean} [useCapture] see 'useCapture' in <https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener>
* @returns {Delegate} This method is chainable
*/
Delegate.prototype.on = function (eventType, selector, handler, useCapture) {
let root;
let listenerMap;
let matcher;
let matcherParam;
if (!eventType) {
throw new TypeError('Invalid event type: ' + eventType);
}
// handler can be passed as
// the second or third argument
if (typeof selector === 'function') {
useCapture = handler;
handler = selector;
selector = null;
}
// Fallback to sensible defaults
// if useCapture not set
if (useCapture === undefined) {
useCapture = this.captureForType(eventType);
}
if (typeof handler !== 'function') {
throw new TypeError('Handler must be a type of Function');
}
root = this.rootElement;
listenerMap = this.listenerMap[useCapture ? 1 : 0];
// Add master handler for type if not created yet
if (!listenerMap[eventType]) {
if (root) {
root.addEventListener(eventType, this.handle, useCapture);
}
listenerMap[eventType] = [];
}
if (!selector) {
matcherParam = null;
// COMPLEX - matchesRoot needs to have access to
// this.rootElement, so bind the function to this.
matcher = matchesRoot.bind(this);
// Compile a matcher for the given selector
} else if (/^[a-z]+$/i.test(selector)) {
matcherParam = selector;
matcher = matchesTag;
} else if (/^#[a-z0-9\-_]+$/i.test(selector)) {
matcherParam = selector.slice(1);
matcher = matchesId;
} else {
matcherParam = selector;
matcher = Element.prototype.matches;
}
// Add to the list of listeners
listenerMap[eventType].push({
selector: selector,
handler: handler,
matcher: matcher,
matcherParam: matcherParam
});
return this;
};
/**
* Remove an event handler
* for elements that match
* the selector, forever
*
* @param {string} [eventType] Remove handlers for events matching this type, considering the other parameters
* @param {string} [selector] If this parameter is omitted, only handlers which match the other two will be removed
* @param {function()} [handler] If this parameter is omitted, only handlers which match the previous two will be removed
* @returns {Delegate} This method is chainable
*/
Delegate.prototype.off = function (eventType, selector, handler, useCapture) {
let i;
let listener;
let listenerMap;
let listenerList;
let singleEventType;
// Handler can be passed as
// the second or third argument
if (typeof selector === 'function') {
useCapture = handler;
handler = selector;
selector = null;
}
// If useCapture not set, remove
// all event listeners
if (useCapture === undefined) {
this.off(eventType, selector, handler, true);
this.off(eventType, selector, handler, false);
return this;
}
listenerMap = this.listenerMap[useCapture ? 1 : 0];
if (!eventType) {
for (singleEventType in listenerMap) {
if (listenerMap.hasOwnProperty(singleEventType)) {
this.off(singleEventType, selector, handler);
}
}
return this;
}
listenerList = listenerMap[eventType];
if (!listenerList || !listenerList.length) {
return this;
}
// Remove only parameter matches
// if specified
for (i = listenerList.length - 1; i >= 0; i--) {
listener = listenerList[i];
if ((!selector || selector === listener.selector) && (!handler || handler === listener.handler)) {
this._removedListeners.push(listener);
listenerList.splice(i, 1);
}
}
// All listeners removed
if (!listenerList.length) {
delete listenerMap[eventType];
// Remove the main handler
if (this.rootElement) {
this.rootElement.removeEventListener(eventType, this.handle, useCapture);
}
}
return this;
};
/**
* Handle an arbitrary event.
*
* @param {Event} event
*/
Delegate.prototype.handle = function (event) {
let i;
let l;
const type = event.type;
let root;
let phase;
let listener;
let returned;
let listenerList = [];
let target;
const eventIgnore = 'ftLabsDelegateIgnore';
if (event[eventIgnore] === true) {
return;
}
target = event.target;
// Hardcode value of Node.TEXT_NODE
// as not defined in IE8
if (target.nodeType === 3) {
target = target.parentNode;
}
// Handle SVG <use> elements in IE
if (target.correspondingUseElement) {
target = target.correspondingUseElement;
}
root = this.rootElement;
phase = event.eventPhase || (event.target !== event.currentTarget ? 3 : 2);
// eslint-disable-next-line default-case
switch (phase) {
case 1: //Event.CAPTURING_PHASE:
listenerList = this.listenerMap[1][type];
break;
case 2: //Event.AT_TARGET:
if (this.listenerMap[0] && this.listenerMap[0][type]) {
listenerList = listenerList.concat(this.listenerMap[0][type]);
}
if (this.listenerMap[1] && this.listenerMap[1][type]) {
listenerList = listenerList.concat(this.listenerMap[1][type]);
}
break;
case 3: //Event.BUBBLING_PHASE:
listenerList = this.listenerMap[0][type];
break;
}
let toFire = [];
// Need to continuously check
// that the specific list is
// still populated in case one
// of the callbacks actually
// causes the list to be destroyed.
l = listenerList.length;
while (target && l) {
for (i = 0; i < l; i++) {
listener = listenerList[i];
// Bail from this loop if
// the length changed and
// no more listeners are
// defined between i and l.
if (!listener) {
break;
}
if (
target.tagName &&
["button", "input", "select", "textarea"].indexOf(target.tagName.toLowerCase()) > -1 &&
target.hasAttribute("disabled")
) {
// Remove things that have previously fired
toFire = [];
}
// Check for match and fire
// the event if there's one
//
// TODO:MCG:20120117: Need a way
// to check if event#stopImmediatePropagation
// was called. If so, break both loops.
else if (listener.matcher.call(target, listener.matcherParam, target)) {
toFire.push([event, target, listener]);
}
}
// TODO:MCG:20120117: Need a way to
// check if event#stopPropagation
// was called. If so, break looping
// through the DOM. Stop if the
// delegation root has been reached
if (target === root) {
break;
}
l = listenerList.length;
// Fall back to parentNode since SVG children have no parentElement in IE
target = target.parentElement || target.parentNode;
// Do not traverse up to document root when using parentNode, though
if (target instanceof HTMLDocument) {
break;
}
}
let ret;
for (i = 0; i < toFire.length; i++) {
// Has it been removed during while the event function was fired
if (this._removedListeners.indexOf(toFire[i][2]) > -1) {
continue;
}
returned = this.fire.apply(this, toFire[i]);
// Stop propagation to subsequent
// callbacks if the callback returned
// false
if (returned === false) {
toFire[i][0][eventIgnore] = true;
toFire[i][0].preventDefault();
ret = false;
break;
}
}
return ret;
};
/**
* Fire a listener on a target.
*
* @param {Event} event
* @param {Node} target
* @param {Object} listener
* @returns {boolean}
*/
Delegate.prototype.fire = function (event, target, listener) {
return listener.handler.call(target, event, target);
};
/**
* Check whether an element
* matches a tag selector.
*
* Tags are NOT case-sensitive,
* except in XML (and XML-based
* languages such as XHTML).
*
* @param {string} tagName The tag name to test against
* @param {Element} element The element to test with
* @returns boolean
*/
function matchesTag(tagName, element) {
return tagName.toLowerCase() === element.tagName.toLowerCase();
}
/**
* Check whether an element
* matches the root.
*
* @param {?String} selector In this case this is always passed through as null and not used
* @param {Element} element The element to test with
* @returns boolean
*/
function matchesRoot(selector, element) {
if (this.rootElement === window) {
return (
// Match the outer document (dispatched from document)
element === document ||
// The <html> element (dispatched from document.body or document.documentElement)
element === document.documentElement ||
// Or the window itself (dispatched from window)
element === window
);
}
return this.rootElement === element;
}
/**
* Check whether the ID of
* the element in 'this'
* matches the given ID.
*
* IDs are case-sensitive.
*
* @param {string} id The ID to test against
* @param {Element} element The element to test with
* @returns boolean
*/
function matchesId(id, element) {
return id === element.id;
}
/**
* Short hand for off()
* and root(), ie both
* with no parameters
*
* @return void
*/
Delegate.prototype.destroy = function () {
this.off();
this.root();
};
export default Delegate;