UNPKG

10.3 kBJavaScriptView Raw
1/**
2 * @preserve Create and manage a DOM event delegator.
3 *
4 * @version 0.1.3
5 * @codingstandard ftlabs-jsv2
6 * @copyright The Financial Times Limited [All Rights Reserved]
7 * @license MIT License (see LICENSE.txt)
8 */
9
10/*jslint browser:true, node:true*/
11/*global define, Node*/
12
13
14/**
15 * DOM event delegator
16 *
17 * The delegator will listen for events that bubble up to the root node.
18 *
19 * @constructor
20 * @param {Node|string} root The root node or a selector string matching the root node
21 */
22function Delegate(root) {
23 'use strict';
24 var self = this;
25
26 if (root) {
27 this.root(root);
28 }
29
30
31 /**
32 * Maintain a map of listener lists, keyed by event name.
33 *
34 * @type Object
35 */
36 this.listenerMap = {};
37
38
39 /** @type function() */
40 this.handle = function(event) { Delegate.prototype.handle.call(self, event); };
41}
42
43
44/**
45 * @protected
46 * @type ?boolean
47 */
48Delegate.tagsCaseSensitive = null;
49
50
51/**
52 * Start listening for events on the provided DOM element
53 * @param {Node} root The root node or a selector string matching the root node
54 */
55Delegate.prototype.root = function(root) {
56 'use strict';
57 var listenerMap = this.listenerMap;
58 var eventType;
59
60 if (typeof root === 'string') {
61 root = document.querySelector(root);
62 }
63
64 // Remove master event listeners
65 if (this.rootElement) {
66 for (eventType in listenerMap) {
67 if (listenerMap.hasOwnProperty(eventType)) {
68 this.rootElement.removeEventListener(eventType, this.handle, this.captureForType(eventType));
69 }
70 }
71 }
72
73 // If no root or root is not a dom node, then
74 // remove internal root reference and exit here
75 if (!root || !root.addEventListener) {
76 if (this.rootElement) {
77 delete this.rootElement;
78 }
79 return;
80 }
81
82 /**
83 * The root node at which listeners are attached.
84 *
85 * @type Node
86 */
87 this.rootElement = root;
88
89 // Set up master event listeners
90 for (eventType in listenerMap) {
91 if (listenerMap.hasOwnProperty(eventType)) {
92 this.rootElement.addEventListener(eventType, this.handle, this.captureForType(eventType));
93 }
94 }
95};
96
97
98/**
99 * @param {string} eventType
100 * @returns boolean
101 */
102Delegate.prototype.captureForType = function(eventType) {
103 'use strict';
104 return eventType === 'error';
105};
106
107
108/**
109 * Attach a handler to one event for all elements that match the selector, now or in the future
110 *
111 * The handler function receives three arguments: the DOM event object, the node that matched the selector while the event was bubbling
112 * and a reference to itself. Within the handler, 'this' is equal to the second argument.
113 * The node that actually received the event can be accessed via 'event.target'.
114 *
115 * @param {string} eventType Listen for these events (in a space-separated list)
116 * @param {string} selector Only handle events on elements matching this selector
117 * @param {function()} handler Handler function - event data passed here will be in event.data
118 * @param {Object} [eventData] Data to pass in event.data
119 * @returns {Delegate} This method is chainable
120 */
121Delegate.prototype.on = function(eventType, selector, handler, eventData) {
122 'use strict';
123 var root, listenerMap, matcher, matcherParam, self = this, /** @const */ SEPARATOR = ' ';
124
125 if (!eventType) {
126 throw new TypeError('Invalid event type: ' + eventType);
127 }
128
129 if (!selector) {
130 throw new TypeError('Invalid selector: ' + selector);
131 }
132
133 // Support a separated list of event types
134 if (eventType.indexOf(SEPARATOR) !== -1) {
135 eventType.split(SEPARATOR).forEach(function(singleEventType) {
136 self.on(singleEventType, selector, handler, eventData);
137 });
138
139 return this;
140 }
141
142 // Normalise undefined eventData to null
143 if (eventData === undefined) {
144 eventData = null;
145 }
146
147 if (typeof handler !== 'function') {
148 throw new TypeError('Handler must be a type of Function');
149 }
150
151 root = this.rootElement;
152 listenerMap = this.listenerMap;
153
154 // Add master handler for type if not created yet
155 if (!listenerMap[eventType]) {
156 if (root) {
157 root.addEventListener(eventType, this.handle, this.captureForType(eventType));
158 }
159 listenerMap[eventType] = [];
160 }
161
162 // Compile a matcher for the given selector
163 if (/^[a-z]+$/i.test(selector)) {
164
165 // Lazily check whether tag names are case sensitive (as in XML or XHTML documents).
166 if (Delegate.tagsCaseSensitive === null) {
167 Delegate.tagsCaseSensitive = document.createElement('i').tagName === 'i';
168 }
169
170 if (!Delegate.tagsCaseSensitive) {
171 matcherParam = selector.toUpperCase();
172 } else {
173 matcherParam = selector;
174 }
175
176 matcher = this.matchesTag;
177 } else if (/^#[a-z0-9\-_]+$/i.test(selector)) {
178 matcherParam = selector.slice(1);
179 matcher = this.matchesId;
180 } else {
181 matcherParam = selector;
182 matcher = this.matches;
183 }
184
185 // Add to the list of listeners
186 listenerMap[eventType].push({
187 selector: selector,
188 eventData: eventData,
189 handler: handler,
190 matcher: matcher,
191 matcherParam: matcherParam
192 });
193
194 return this;
195};
196
197
198/**
199 * Remove an event handler for elements that match the selector, forever
200 *
201 * @param {string} eventType Remove handlers for events matching this type, considering the other parameters
202 * @param {string} [selector] If this parameter is omitted, only handlers which match the other two will be removed
203 * @param {function()} [handler] If this parameter is omitted, only handlers which match the previous two will be removed
204 * @returns {Delegate} This method is chainable
205 */
206Delegate.prototype.off = function(eventType, selector, handler) {
207 'use strict';
208 var i, listener, listenerMap, listenerList, singleEventType, self = this, /** @const */ SEPARATOR = ' ';
209
210 listenerMap = this.listenerMap;
211 if (!eventType) {
212 for (singleEventType in listenerMap) {
213 if (listenerMap.hasOwnProperty(singleEventType)) {
214 this.off(singleEventType, selector, handler);
215 }
216 }
217
218 return this;
219 }
220
221 listenerList = listenerMap[eventType];
222 if (!listenerList || !listenerList.length) {
223 return this;
224 }
225
226 // Support a separated list of event types
227 if (eventType.indexOf(SEPARATOR) !== -1) {
228 eventType.split(SEPARATOR).forEach(function(singleEventType) {
229 self.off(singleEventType, selector, handler);
230 });
231
232 return this;
233 }
234
235 // Remove only parameter matches if specified
236 for (i = listenerList.length - 1; i >= 0; i--) {
237 listener = listenerList[i];
238
239 if ((!selector || selector === listener.selector) && (!handler || handler === listener.handler)) {
240 listenerList.splice(i, 1);
241 }
242 }
243
244 // All listeners removed
245 if (!listenerList.length) {
246 delete listenerList[eventType];
247
248 // Remove the main handler
249 if (this.rootElement) {
250 this.rootElement.removeEventListener(eventType, this.handle, this.captureForType(eventType));
251 }
252 }
253
254 return this;
255};
256
257
258/**
259 * Handle an arbitrary event.
260 *
261 * @param {Event} event
262 */
263Delegate.prototype.handle = function(event) {
264 'use strict';
265 var i, l, root, listener, returned, listenerList, target, /** @const */ EVENTIGNORE = 'ftLabsDelegateIgnore';
266
267 if (event[EVENTIGNORE] === true) {
268 return;
269 }
270
271 target = event.target;
272 if (target.nodeType === Node.TEXT_NODE) {
273 target = target.parentNode;
274 }
275
276 root = this.rootElement;
277 listenerList = this.listenerMap[event.type];
278
279 // Need to continuously check that the specific list is still populated in case one of the callbacks actually causes the list to be destroyed.
280 l = listenerList.length;
281 while (target && l) {
282 for (i = 0; i < l; i++) {
283 listener = listenerList[i];
284
285 // Bail from this loop if the length changed and no more listeners are defined between i and l.
286 if (!listener) {
287 break;
288 }
289
290 // Check for match and fire the event if there's one
291 // TODO:MCG:20120117: Need a way to check if event#stopImmediateProgagation was called. If so, break both loops.
292 if (listener.matcher.call(target, listener.matcherParam, target)) {
293 returned = this.fire(event, target, listener);
294 }
295
296 // Stop propagation to subsequent callbacks if the callback returned false
297 if (returned === false) {
298 event[EVENTIGNORE] = true;
299 return;
300 }
301 }
302
303 // TODO:MCG:20120117: Need a way to check if event#stopProgagation was called. If so, break looping through the DOM.
304 // Stop if the delegation root has been reached
305 if (target === root) {
306 break;
307 }
308
309 l = listenerList.length;
310 target = target.parentElement;
311 }
312};
313
314
315/**
316 * Fire a listener on a target.
317 *
318 * @param {Event} event
319 * @param {Node} target
320 * @param {Object} listener
321 * @returns {boolean}
322 */
323Delegate.prototype.fire = function(event, target, listener) {
324 'use strict';
325 var returned, oldData;
326
327 if (listener.eventData !== null) {
328 oldData = event.data;
329 event.data = listener.eventData;
330 returned = listener.handler.call(target, event, target);
331 event.data = oldData;
332 } else {
333 returned = listener.handler.call(target, event, target);
334 }
335
336 return returned;
337};
338
339
340/**
341 * Check whether an element matches a generic selector.
342 *
343 * @type function()
344 * @param {string} selector A CSS selector
345 */
346Delegate.prototype.matches = (function(p) {
347 'use strict';
348 return (p.matchesSelector || p.webkitMatchesSelector || p.mozMatchesSelector || p.msMatchesSelector || p.oMatchesSelector);
349}(HTMLElement.prototype));
350
351
352/**
353 * Check whether an element matches a tag selector.
354 *
355 * Tags are NOT case-sensitive, except in XML (and XML-based languages such as XHTML).
356 *
357 * @param {string} tagName The tag name to test against
358 * @param {Element} element The element to test with
359 * @returns boolean
360 */
361Delegate.prototype.matchesTag = function(tagName, element) {
362 'use strict';
363 return tagName === element.tagName;
364};
365
366
367/**
368 * Check whether the ID of the element in 'this' matches the given ID.
369 *
370 * IDs are case-sensitive.
371 *
372 * @param {string} id The ID to test against
373 * @param {Element} element The element to test with
374 * @returns boolean
375 */
376Delegate.prototype.matchesId = function(id, element) {
377 'use strict';
378 return id === element.id;
379};
380
381if (typeof define !== 'undefined' && define.amd) {
382
383 // AMD. Register as an anonymous module.
384 define(function() {
385 'use strict';
386 return Delegate;
387 });
388}
389
390if (typeof module !== 'undefined' && module.exports) {
391 module.exports = function(root) {
392 'use strict';
393 return new Delegate(root);
394 };
395
396 module.exports.Delegate = Delegate;
397}
398
399
400/**
401 * Short hand for off() and root(), ie both with no parameters
402 *
403 * @return void
404 */
405Delegate.prototype.destroy = function() {
406 this.off();
407 this.root();
408};