UNPKG

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
/** * 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;