From a7d05bd18408910356897130015a7a0a28ec750a Mon Sep 17 00:00:00 2001 From: notlee Date: Tue, 17 Sep 2019 17:06:05 +0100 Subject: [PATCH 01/10] Add MIGRATION.md --- MIGRATION.md | 8 ++++++++ README.md | 10 ++++++++++ 2 files changed, 18 insertions(+) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..9e29610 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,8 @@ +# Migration + +## Migrating from v3 to v4 + + +## Migrating from v2 to v3 + +V3 is a name change and does not make any API changes. Replace `dom-delegate` in your `bower.json` with `ftdomdelegate`. diff --git a/README.md b/README.md index 451ba80..ee9400f 100644 --- a/README.md +++ b/README.md @@ -190,3 +190,13 @@ Short hand for off() and root(), ie both with no parameters. Used to reset the d ## Credits and collaboration ## The developers of ftdomdelegate are [Matthew Andrews](https://twitter.com/andrewsmatt) and [Matthew Caruana Galizia](http://twitter.com/mcaruanagalizia). Test engineering by [Sam Giles](https://twitter.com/SamuelGiles_). The API is influenced by [jQuery Live](http://api.jquery.com/live/). All open source code released by FT Labs is licenced under the MIT licence. We welcome comments, feedback and suggestions. Please feel free to raise an issue or pull request. + +## Migration guide + +State | Major Version | Last Minor Release | Migration guide | +:---: | :---: | :---: | :---: +✨ active | 4 | N/A | [migrate to v4](MIGRATION.md#migrating-from-v3-to-v4) | +⚠ maintained | 3 | 3.0.2 | [migrate to v3](MIGRATION.md#migrating-from-v2-to-v3) | +╳ deprecated | 2 | 2.2.1 | N/A | +╳ deprecated | 1 | 1.0.6 | N/A | + From 5750b9e0e5773d10ad5995cf7ed1d514263b862f Mon Sep 17 00:00:00 2001 From: notlee Date: Tue, 17 Sep 2019 18:48:47 +0100 Subject: [PATCH 02/10] Improve conformance with the Origami specification. - Update status from active to maintained. - Update code to pass `obt verify`. - Update tests to run with `obt test`. - Remove dev dependencies. - Remove grunt task runner. - Update the folder structure. - Remove travis ci config. - Add circle config. ftdomdelegate can now deploy to npm on tag. --- .circleci/config.yml | 36 ++ .gitignore | 14 +- .npmignore | 5 - .travis.yml | 16 - GruntFile.js | 47 --- README.md | 42 +-- bower.json | 2 +- lib/delegate.js | 429 ---------------------- lib/index.js => main.js | 8 +- origami.json | 2 +- package.json | 27 +- src/js/delegate.js | 451 ++++++++++++++++++++++++ test/buster.js | 21 -- test/delegate.test.js | 703 +++++++++++++++++++++++++++++++++++++ test/scroll.test.js | 64 ++++ test/tests/delegateTest.js | 637 --------------------------------- test/tests/test-scroll.js | 76 ---- 17 files changed, 1269 insertions(+), 1311 deletions(-) create mode 100644 .circleci/config.yml delete mode 100644 .npmignore delete mode 100644 .travis.yml delete mode 100644 GruntFile.js delete mode 100644 lib/delegate.js rename lib/index.js => main.js (70%) create mode 100644 src/js/delegate.js delete mode 100644 test/buster.js create mode 100644 test/delegate.test.js create mode 100644 test/scroll.test.js delete mode 100644 test/tests/delegateTest.js delete mode 100644 test/tests/test-scroll.js diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..5fcea1e --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,36 @@ +version: 2 +jobs: + test: + docker: + - image: circleci/node:10-browsers + steps: + - checkout + - run: npm config set prefix "$HOME/.local" + - run: npm i -g origami-build-tools@^7 + - run: $HOME/.local/bin/obt install + - run: $HOME/.local/bin/obt demo --demo-filter pa11y --suppress-errors + - run: $HOME/.local/bin/obt verify + - run: $HOME/.local/bin/obt test + - run: git clean -fxd + - run: npx occ 0.0.0 + - run: $HOME/.local/bin/obt install --ignore-bower + - run: $HOME/.local/bin/obt test --ignore-bower + publish_to_npm: + docker: + - image: circleci/node:10 + steps: + - checkout + - run: npx occ ${CIRCLE_TAG##v} + - run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > $HOME/.npmrc + - run: npm publish --access public +workflows: + version: 2 + test: + jobs: + - test + - publish_to_npm: + filters: + tags: + only: /^v.*/ + branches: + ignore: /.*/ diff --git a/.gitignore b/.gitignore index e334ff9..1ac3e14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ -node_modules -lcov-report -lcov.info -npm-debug.log -/build +.DS_Store +.env +/.sass-cache/ +/bower_components/ +/node_modules/ +/build/ +.idea/ +/demos/local +/coverage diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 0d105d6..0000000 --- a/.npmignore +++ /dev/null @@ -1,5 +0,0 @@ -.github/ -test/ -.travis.yml -bower.json -GruntFile.js diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2c203fc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,16 +0,0 @@ -before_script: - - export DISPLAY=:99.0 - - sh -e /etc/init.d/xvfb start - - sleep 5 - - node_modules/.bin/buster-server & - - sleep 5 - - firefox http://localhost:1111/capture & - - sleep 5 - -script: - - "npm test" - -language: node_js - -node_js: - - "8.9" diff --git a/GruntFile.js b/GruntFile.js deleted file mode 100644 index 67d465d..0000000 --- a/GruntFile.js +++ /dev/null @@ -1,47 +0,0 @@ -module.exports = function(grunt) { - - // Project configuration. - grunt.initConfig({ - pkg: grunt.file.readJSON('package.json'), - - buster: {}, - - browserify: { - build: { - src: 'lib/delegate.js', - dest: 'build/<%= pkg.name %>.js' - }, - options: { - browserifyOptions: { - standalone: 'Delegate' - } - } - }, - - uglify: { - build: { - src: 'build/<%= pkg.name %>.js', - dest: 'build/<%= pkg.name %>.min.js' - } - }, - - jshint: { - all: [ - '*.js', - 'lib/**/*.js', - 'test/*.js', - 'test/tests/*.js', - '*.json' - ] - } - - }); - - grunt.loadNpmTasks('grunt-buster'); - grunt.loadNpmTasks('grunt-browserify'); - grunt.loadNpmTasks('grunt-contrib-uglify'); - grunt.loadNpmTasks('grunt-contrib-jshint'); - - // Default task. - grunt.registerTask('default', ['browserify', 'uglify', 'jshint']); -}; diff --git a/README.md b/README.md index ee9400f..52e06f2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# ftdomdelegate [![Build Status](https://travis-ci.org/ftlabs/ftdomdelegate.svg?branch=master)](https://travis-ci.org/ftlabs/ftdomdelegate) +# ftdomdelegate [![CircleCI](https://circleci.com/gh/Financial-Times/ftdomdelegate.svg?style=svg)](https://circleci.com/gh/Financial-Times/ftdomdelegate) FT's dom delegate library is a component for binding to events on all target elements matching the given selector, irrespective of whether anything exists in the DOM at registration time or not. This allows developers to implement the [event delegation pattern](http://www.sitepoint.com/javascript-event-delegation-is-easier-than-you-think/). @@ -34,21 +34,6 @@ The easiest way is to include the following script tag and let [Polyfill.io](htt ``` - -## Installation ## - -Get the [browserify](http://browserify.org/)-able source from a package manager: - -``` -npm install ftdomdelegate -``` - -or - -``` -bower install ftdomdelegate -``` - ## Usage ## The library is written in CommonJS and so can be `require` in. @@ -108,31 +93,6 @@ Also note: as of 0.2.0 you cannot specify more than one `eventType` in a single Delegate supports compilation with `ADVANCED_OPTIMIZATIONS` ('advanced mode'), which should reduce its size by about 70% (60% gzipped). Note that exposure of the `Delegate` variable isn't forced therefore you must compile it along with all of your code. -## Tests ## - -Tests are run using [buster](http://docs.busterjs.org/en/latest/) and sit in `test/`. To run the tests statically: - -``` -$ cd ftdomdelegate/ -$ ./node_modules/.bin/buster-static -c test/buster.js -Starting server on http://localhost:8282/ -``` - -...then point your browser to http://localhost:8282/. - -``` -$ ./node_modules/.bin/buster-server -buster-server running on http://localhost:1111 -``` - -Point your browser to http://localhost:1111 and capture it, then in another terminal tab: - -``` -$ ./node_modules/.bin/buster-test -c test/buster.js -``` - -Code coverage is generated automatically with [istanbul](https://github.com/gotwarlost/istanbul). The report outputs to `lcov-report/index.html`. - ## API ## ### .on(eventType[, selector], handler[, useCapture]) ### diff --git a/bower.json b/bower.json index 633a212..2d15743 100644 --- a/bower.json +++ b/bower.json @@ -1,7 +1,7 @@ { "name": "ftdomdelegate", "description": "Create and manage a DOM event delegator.", - "main": "lib/delegate.js", + "main": "main.js", "ignore": [".github", "test", ".npmignore", ".gitignore", "GruntFile.js"], "license": "MIT" } diff --git a/lib/delegate.js b/lib/delegate.js deleted file mode 100644 index 048968d..0000000 --- a/lib/delegate.js +++ /dev/null @@ -1,429 +0,0 @@ -/*jshint browser:true, node:true*/ - -'use strict'; - -module.exports = Delegate; - -/** - * 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); -} - -/** - * 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) { - var listenerMap = this.listenerMap; - var 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 - * @returns {Delegate} This method is chainable - */ -Delegate.prototype.on = function(eventType, selector, handler, useCapture) { - var root, listenerMap, matcher, 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 = 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) { - var i, listener, listenerMap, listenerList, 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)) { - 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) { - var i, l, type = event.type, root, phase, listener, returned, listenerList = [], 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; - } - - root = this.rootElement; - - phase = event.eventPhase || ( event.target !== event.currentTarget ? 3 : 2 ); - - 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; - } - - // 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; - } - - // 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. - if (listener.matcher.call(target, listener.matcherParam, target)) { - returned = this.fire(event, target, listener); - } - - // Stop propagation to subsequent - // callbacks if the callback returned - // false - if (returned === false) { - event[EVENTIGNORE] = true; - event.preventDefault(); - return; - } - } - - // 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; - target = target.parentElement; - } -}; - -/** - * 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 generic selector. - * - * @type function() - * @param {string} selector A CSS selector - */ -var matches = (function(el) { - if (!el) return; - var p = el.prototype; - return (p.matches || p.matchesSelector || p.webkitMatchesSelector || p.mozMatchesSelector || p.msMatchesSelector || p.oMatchesSelector); -}(Element)); - -/** - * 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) { - /*jshint validthis:true*/ - if (this.rootElement === window) return element === document; - 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(); -}; diff --git a/lib/index.js b/main.js similarity index 70% rename from lib/index.js rename to main.js index 588a697..46e25c9 100644 --- a/lib/index.js +++ b/main.js @@ -1,7 +1,3 @@ -/*jshint browser:true, node:true*/ - -'use strict'; - /** * @preserve Create and manage a DOM event delegator. * @@ -9,10 +5,10 @@ * @copyright The Financial Times Limited [All Rights Reserved] * @license MIT License (see LICENSE.txt) */ -var Delegate = require('./delegate'); +const Delegate = require('./src/js/delegate'); module.exports = function(root) { - return new Delegate(root); + return new Delegate(root); }; module.exports.Delegate = Delegate; diff --git a/origami.json b/origami.json index a0e43ab..e90175e 100644 --- a/origami.json +++ b/origami.json @@ -7,7 +7,7 @@ "support": "https://github.com/ftlabs/ftdomdelegate/issues", "supportStatus": "active", "ci": { - "travis": "https://api.travis-ci.org/repos/ftlabs/ftdomdelegate/builds.json" + "circle": "https://circleci.com/api/v1/project/Financial-Times/o-message" }, "browserFeatues": { "required": [ diff --git a/package.json b/package.json index 1da5a84..e25787e 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,15 @@ { "name": "ftdomdelegate", - "version": "3.0.0", "author": "FT Labs (http://labs.ft.com/)", - "description": "Create and manage a DOM event delegator.", "contributors": [ "Matthew Caruana Galizia", "Sam Giles", "Matt Andrews" ], - "main": "lib/index.js", - "repository": { - "type": "git", - "url": "git://github.com/ftlabs/ftdomdelegate.git" - }, - "engines": { - "node": "*" - }, - "scripts": { - "test": "./node_modules/.bin/grunt && ./node_modules/.bin/buster-test" - }, "keywords": [ "delegate", "dom", "events" ], - "devDependencies": { - "buster": "~0.7.18", - "grunt": "~1.0.1", - "grunt-cli": "~1.2.0", - "grunt-buster": "~0.4.2", - "grunt-browserify": "5.0.0", - "grunt-contrib-uglify": "~2.0.0", - "istanbul": "*", - "buster-istanbul": "*", - "grunt-contrib-jshint": "~1.0.0" - }, - "license": "MIT", - "homepage": "https://github.com/ftlabs/ftdomdelegate" + "license": "MIT" } diff --git a/src/js/delegate.js b/src/js/delegate.js new file mode 100644 index 0000000..73bd7f8 --- /dev/null +++ b/src/js/delegate.js @@ -0,0 +1,451 @@ +/*jshint browser:true, node:true*/ +module.exports = Delegate; + +/** + * 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); +} + +/** + * 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) { + let 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 + * @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 = 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)) { + 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; + let 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; + } + + 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; + } + + // 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; + } + + // 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. + if (listener.matcher.call(target, listener.matcherParam, target)) { + returned = this.fire(event, target, listener); + } + + // Stop propagation to subsequent + // callbacks if the callback returned + // false + if (returned === false) { + event[eventIgnore] = true; + event.preventDefault(); + return; + } + } + + // 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; + target = target.parentElement; + } +}; + +/** + * 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 generic selector. + * + * @type function() + * @param {string} selector A CSS selector + */ +let matches = (function (el) { + if (!el) { + return; + } + let p = el.prototype; + return (p.matches || p.matchesSelector || p.webkitMatchesSelector || p.mozMatchesSelector || p.msMatchesSelector || p.oMatchesSelector); +}(Element)); + +/** + * 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) { + /*jshint validthis:true*/ + if (this.rootElement === window) { + return element === document; + } + 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(); +}; diff --git a/test/buster.js b/test/buster.js deleted file mode 100644 index 8946089..0000000 --- a/test/buster.js +++ /dev/null @@ -1,21 +0,0 @@ -var config = module.exports; - -config.DelegateTests = { - rootPath: '../', - environment: "browser", - sources: [ - - // The 3 polyfills below are needed for testing in ie8 - // "https://gist.githubusercontent.com/jonathantneal/3062955/raw/ad9d969c4e55581edbbb293c74135a751f586664/matchesSelector.polyfill.js", - // "https://raw.githubusercontent.com/jonathantneal/EventListener/master/EventListener.js", - // "http://cdnjs.cloudflare.com/ajax/libs/es5-shim/2.3.0/es5-shim.min.js", - - "build/ftdomdelegate.js" - ], - tests: [ - "test/tests/delegateTest.js" - ], - extensions: [ - require('buster-istanbul') - ] -}; diff --git a/test/delegate.test.js b/test/delegate.test.js new file mode 100644 index 0000000..a46ef3a --- /dev/null +++ b/test/delegate.test.js @@ -0,0 +1,703 @@ +/* eslint-env mocha, sinon, proclaim */ +import Delegate from '../main'; +import proclaim from 'proclaim'; +import sinon from 'sinon/pkg/sinon'; + +let setupHelper = {}; + +setupHelper.setUp = function() { + document.body.insertAdjacentHTML('beforeend', + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + ); +}; + +setupHelper.tearDown = function() { + let toRemove; + toRemove = document.getElementById('container1'); + if (toRemove) { + toRemove.parentNode.removeChild(toRemove); + } + toRemove = document.getElementById('container2'); + if (toRemove) { + toRemove.parentNode.removeChild(toRemove); + } +}; + +setupHelper.fireMouseEvent = function(target, eventName, relatedTarget) { + // TODO: Extend this to be slightly more configurable when initialising the event. + let ev; + if (document.createEvent) { + ev = document.createEvent("MouseEvents"); + ev.initMouseEvent(eventName, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, relatedTarget || null); + target.dispatchEvent(ev); + } else if ( document.createEventObject ) { + ev = document.createEventObject(); + target.fireEvent( 'on' + eventName, ev); + } +}; + +setupHelper.fireFormEvent = function (target, eventName) { + let ev; + if (document.createEvent) { + ev = document.createEvent('Event'); + ev.initEvent(eventName, true, true); + target.dispatchEvent(ev); + } else if ( document.createEventObject ) { + ev = document.createEventObject(); + target.fireEvent( 'on' + eventName, ev); + } +}; + +setupHelper.fireCustomEvent = function(target, eventName) { + let ev = new Event(eventName, { + bubbles: true + }); + target.dispatchEvent(ev); +}; + +describe("Delegate", () => { + beforeEach(() => { + setupHelper.setUp(); + }); + + afterEach(() => { + setupHelper.tearDown(); + }); + + it('Delegate#off should remove the event handlers for a selector', () => { + let delegate = new Delegate(document); + let spyA = sinon.spy(); + let spyB = sinon.spy(); + + delegate.on('click', '#delegate-test-clickable', spyA); + delegate.on('click', '#delegate-test-clickable', spyB); + + let element = document.getElementById("delegate-test-clickable"); + + setupHelper.fireMouseEvent(element, "click"); + + proclaim.isTrue(spyA.calledOnce); + proclaim.isTrue(spyB.calledOnce); + + delegate.off("click", '#delegate-test-clickable'); + + setupHelper.fireMouseEvent(element, "click"); + + proclaim.isTrue(spyA.calledOnce); + proclaim.isTrue(spyB.calledOnce); + }); + + it('ID selectors are supported', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document); + spy = sinon.spy(); + delegate.on('click', '#delegate-test-clickable', spy); + + element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.calledOnce); + + delegate.off(); + }); + + it('Destroy destroys', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document); + spy = sinon.spy(); + delegate.on('click', '#delegate-test-clickable', spy); + + delegate.destroy(); + + element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isFalse(spy.called); + }); + + it('Tag selectors are supported', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document); + spy = sinon.spy(); + delegate.on('click', 'div', function () { + spy(); + return false; + }); + + element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.calledOnce); + + delegate.off(); + }); + + it('Tag selectors are supported for svg', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document); + spy = sinon.spy(); + delegate.on('click', 'circle', function () { + spy(); + return false; + }); + + element = document.getElementById('svg-delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.calledOnce); + + delegate.off(); + }); + + it('Class name selectors are supported', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document); + spy = sinon.spy(); + delegate.on('click', '.delegate-test-clickable', spy); + + element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.calledOnce); + + delegate.off(); + }); + + it('Complex selectors are supported', () => { + let delegate; + let spyA; + let spyB; + let element; + + delegate = new Delegate(document); + spyA = sinon.spy(); + spyB = sinon.spy(); + delegate.on('click', 'div.delegate-test-clickable, div[id=another-delegate-test-clickable]', spyA); + delegate.on('click', 'div.delegate-test-clickable + #another-delegate-test-clickable', spyB); + + element = document.getElementById('another-delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spyA.calledOnce); + proclaim.isTrue(spyB.calledOnce); + + delegate.off(); + }); + + it('If two click handlers are registered then all handlers should be called on click', () => { + let delegate = new Delegate(document); + let spyA = sinon.spy(); + let spyB = sinon.spy(); + + delegate.on("click", '#delegate-test-clickable', spyA); + delegate.on("click", '#delegate-test-clickable', spyB); + + let element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, "click"); + + proclaim.isTrue(spyA.calledOnce); + proclaim.isTrue(spyB.calledOnce); + + delegate.off(); + }); + + it('Returning false from a callback should stop propagation immediately', () => { + let delegate = new Delegate(document); + + let spyA = sinon.spy(); + let spyB = sinon.spy(); + + delegate.on("click", '#delegate-test-clickable', function () { + spyA(); + + // Return false to stop propagation + return false; + }); + delegate.on("click", '#delegate-test-clickable', spyB); + + let element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, "click"); + + proclaim.isTrue(spyA.calledOnce); + proclaim.isFalse(spyB.calledOnce); + + delegate.off(); + }); + + it('Returning false from a callback should preventDefault', (done) => { + let delegate = new Delegate(document.body); + + let spyA = sinon.spy(); + + delegate.on("click", '#delegate-test-clickable', function (event) { + spyA(); + + // event.defaultPrevented appears to have issues in IE so just mock + // preventDefault instead. + let defaultPrevented; + event.preventDefault = function () { + defaultPrevented = true; + }; + + setTimeout(function () { + proclaim.isTrue(defaultPrevented); + done(); + }, 0); + + return false; + }); + + let element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, "click"); + + proclaim.isTrue(spyA.calledOnce); + delegate.off(); + }); + + it('Returning false from a callback should stop propagation globally', () => { + let delegateA = new Delegate(document); + let delegateB = new Delegate(document); + + let spyA = sinon.spy(); + let spyB = sinon.spy(); + + delegateA.on("click", '#delegate-test-clickable', function() { + spyA(); + + // Return false to stop propagation to other delegates + return false; + }); + delegateB.on("click", '#delegate-test-clickable', spyB); + + let element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, "click"); + + proclaim.isTrue(spyA.calledOnce); + proclaim.isFalse(spyB.calledOnce); + + delegateA.off(); + delegateB.off(); + }); + + + it('Clicking on parent node should not trigger event', () => { + let delegate = new Delegate(document); + let spy = sinon.spy(); + + delegate.on("click", "#delegate-test-clickable", spy); + + setupHelper.fireMouseEvent(document, "click"); + + proclaim.isFalse(spy.called); + + let spyA = sinon.spy(); + + delegate.on("click", "#another-delegate-test-clickable", spyA); + + let element = document.getElementById("another-delegate-test-clickable"); + setupHelper.fireMouseEvent(element, "click"); + + proclaim.isTrue(spyA.calledOnce); + proclaim.isFalse(spy.calledOnce); + + delegate.off(); + }); + + it('Exception should be thrown when no handler is specified in Delegate#on', (done) => { + try { + let delegate = new Delegate(document); + delegate.on("click", '#delegate-test-clickable'); + } catch (e) { + proclaim.equal(e.name, 'TypeError'); + proclaim.equal(e.message, 'Handler must be a type of Function'); + done(); + } + done(new Error('Did not error.')); + }); + + it('Delegate#off with zero arguments should remove all handlers', () => { + let delegate = new Delegate(document); + let spyA = sinon.spy(); + let spyB = sinon.spy(); + + delegate.on('click', '#delegate-test-clickable', spyA); + delegate.on('click', '#another-delegate-test-clickable', spyB); + + delegate.off(); + + let element = document.getElementById('delegate-test-clickable'); + let element2 = document.getElementById('another-delegate-test-clickable'); + + setupHelper.fireMouseEvent(element, "click"); + setupHelper.fireMouseEvent(element2, "click"); + + proclaim.isFalse(spyA.called); + proclaim.isFalse(spyB.called); + + spyA.resetHistory(); + spyB.resetHistory(); + + setupHelper.fireMouseEvent(element, "mouseover", document); + setupHelper.fireMouseEvent(element2, "mouseover", document); + + proclaim.isFalse(spyA.called); + proclaim.isFalse(spyB.called); + }); + + it('Regression test: Delegate#off called from a callback should succeed without exception', () => { + let delegate = new Delegate(document); + let spyA = sinon.spy(); + + delegate.on('click', '#delegate-test-clickable', function () { + spyA(); + delegate.off(); + }); + + let element = document.getElementById('delegate-test-clickable'); + + proclaim.doesNotThrow(function () { + setupHelper.fireMouseEvent(element, 'click'); + }); + + proclaim.isTrue(spyA.called); + }); + + it('Delegate#off called from a callback should prevent execution of subsequent callbacks', () => { + let delegate = new Delegate(document); + let spyA = sinon.spy(); + let spyB = sinon.spy(); + + delegate.on('click', '#delegate-test-clickable', function () { + spyA(); + delegate.off(); + }); + delegate.on('click', '#delegate-test-clickable', spyB); + + let element = document.getElementById('delegate-test-clickable'); + + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spyA.called); + proclaim.isFalse(spyB.called); + }); + + it('Can be instantiated without a root node', () => { + let delegate = new Delegate(); + let spyA = sinon.spy(); + let element = document.getElementById('delegate-test-clickable'); + + delegate.on('click', '#delegate-test-clickable', function () { + spyA(); + }); + + setupHelper.fireMouseEvent(element, 'click'); + proclaim.isFalse(spyA.called); + delegate.off(); + }); + + it('Can be bound to an element after its event listeners have been set up', () => { + let delegate = new Delegate(); + let spyA = sinon.spy(); + let element = document.getElementById('delegate-test-clickable'); + + delegate.on('click', '#delegate-test-clickable', function () { + spyA(); + }); + + setupHelper.fireMouseEvent(element, 'click'); + delegate.root(document); + setupHelper.fireMouseEvent(element, 'click'); + proclaim.isTrue(spyA.calledOnce); + delegate.off(); + }); + + it('Can be unbound from an element', () => { + let delegate = new Delegate(document); + let spyA = sinon.spy(); + let element = document.getElementById('delegate-test-clickable'); + + delegate.on('click', '#delegate-test-clickable', function () { + spyA(); + }); + + delegate.root(); + setupHelper.fireMouseEvent(element, 'click'); + proclaim.isFalse(spyA.called); + delegate.off(); + }); + + it('Can be to bound to a different DOM element', () => { + let spyA = sinon.spy(); + let element = document.getElementById('element-in-container2-test-clickable'); + + // Attach to the first container + let delegate = new Delegate(document.getElementById('container1')); + + // Listen to elements with class delegate-test-clickable + delegate.on('click', '.delegate-test-clickable', function () { + spyA(); + }); + + // Click the element in the second container + setupHelper.fireMouseEvent(element, 'click'); + + // Ensure no click was caught + proclaim.isFalse(spyA.called); + + // Move the listeners to the second container + delegate.root(document.getElementById('container2')); + + // Click the element in the second container again + setupHelper.fireMouseEvent(element, 'click'); + + // Ensure the click was caught + proclaim.isTrue(spyA.calledOnce); + + delegate.off(); + }); + + it('Regression test: event fired on a text node should bubble normally', () => { + let delegate; + let spy; + let element; + let textNode; + + spy = sinon.spy(); + + delegate = new Delegate(document); + delegate.on('click', '#delegate-test-clickable', spy); + + element = document.getElementById('delegate-test-clickable'); + textNode = document.createTextNode('Test text'); + element.appendChild(textNode); + + setupHelper.fireMouseEvent(textNode, 'click'); + + proclaim.isTrue(spy.called); + + delegate.off(); + }); + + + // Regression test for - https://github.com/ftlabs/dom-delegate/pull/10 + it('Regression test: event listener should be rebound after last event is removed and new events are added.', () => { + let delegate; + let spy; + let element; + + spy = sinon.spy(); + + delegate = new Delegate(document); + delegate.on('click', '#delegate-test-clickable', spy); + + // Unbind event listeners + delegate.off(); + + delegate.on('click', '#delegate-test-clickable', spy); + + element = document.getElementById('delegate-test-clickable'); + + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.called); + + delegate.off(); + }); + + + // Test for issue #5 + it('The root element, via a null selector, is supported', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document.body); + spy = sinon.spy(); + delegate.on('click', null, spy); + + element = document.body; + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.calledOnce); + + delegate.off(); + }); + + + // Test for issues #16 + it('The root element, when passing a callback into the second parameter, is supported', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document.body); + spy = sinon.spy(); + delegate.on('click', spy); + + element = document.body; + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.calledOnce); + + delegate.off(); + }); + + + // Test for issue #16 + it('Can unset a listener on the root element when passing the callback into the second parameter', () => { + let element = document.getElementById('element-in-container2-test-clickable'); + let delegate = new Delegate(document.body); + let spy = sinon.spy(); + let spy2 = sinon.spy(); + + delegate.on('click', spy); + delegate.on('click', '#element-in-container2-test-clickable', spy2); + + setupHelper.fireMouseEvent(element, 'click'); + delegate.off('click', spy); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(spy.calledOnce); + proclaim.isTrue(spy2.called); + + delegate.off(); + }); + + + it('Regression test: #root is chainable during setting of root', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(); + spy = sinon.spy(); + delegate.root(document.body).on('click', null, spy); + + element = document.body; + setupHelper.fireMouseEvent(element, 'click'); + proclaim.isTrue(spy.calledOnce); + delegate.off(); + }); + + + it('Regression test: #root is chainable during unsetting of root', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document.body); + spy = sinon.spy(); + delegate.root().on('click', null, spy); + delegate.root(document.body); + + element = document.body; + setupHelper.fireMouseEvent(element, 'click'); + proclaim.isTrue(spy.calledOnce); + delegate.off(); + }); + + + it('Focus events can be caught', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document.body); + spy = sinon.spy(); + delegate.on('focus', 'input', spy); + element = document.getElementById('js-input'); + setupHelper.fireFormEvent(element, 'focus'); + proclaim.isTrue(spy.calledOnce); + }); + + it('Blur events can be caught', () => { + let delegate; + let spy; + let element; + + delegate = new Delegate(document.body); + spy = sinon.spy(); + delegate.on('blur', 'input', spy); + element = document.getElementById('js-input'); + setupHelper.fireFormEvent(element, 'blur'); + proclaim.isTrue(spy.calledOnce); + }); + + it('Test setting useCapture true false works get attached to capturing and bubbling event handlers, respectively', () => { + let delegate = new Delegate(document); + let bubbleSpy = sinon.spy(); + let captureSpy = sinon.spy(); + let bubblePhase; + let capturePhase; + + delegate.on('click', '.delegate-test-clickable', function (event) { + bubblePhase = event.eventPhase; + bubbleSpy(); + }, false); + delegate.on('click', '.delegate-test-clickable', function (event) { + capturePhase = event.eventPhase; + captureSpy(); + }, true); + + let element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.equal(capturePhase, 1); + proclaim.equal(bubblePhase, 3); + proclaim.isTrue(captureSpy.called, bubbleSpy); + + // Ensure unbind works properly + delegate.off(); + + element = document.getElementById('delegate-test-clickable'); + setupHelper.fireMouseEvent(element, 'click'); + + proclaim.isTrue(captureSpy.calledOnce); + proclaim.isTrue(bubbleSpy.calledOnce); + }); + + it('Custom events are supported', () => { + let delegate = new Delegate(document.body); + let spyOnContainer = sinon.spy(); + let spyOnElement = sinon.spy(); + + delegate.on('foobar', '#container1', function () { + spyOnContainer(); + }); + + delegate.on('foobar', '#custom-event', function () { + spyOnElement(); + }); + + setupHelper.fireCustomEvent(document.getElementById("custom-event"), 'foobar'); + + proclaim.isTrue(spyOnContainer.calledOnce); + proclaim.isTrue(spyOnElement.calledOnce); + }); + +}); diff --git a/test/scroll.test.js b/test/scroll.test.js new file mode 100644 index 0000000..b975d28 --- /dev/null +++ b/test/scroll.test.js @@ -0,0 +1,64 @@ +/* eslint-env mocha, sinon, proclaim */ +import Delegate from '../main'; +import proclaim from 'proclaim'; +import sinon from 'sinon/pkg/sinon'; + +describe("Delegate", () => { + + beforeEach(() => { + let snip = '

text

'; + let out = ''; + for (let i = 0, l = 10000; i < l; i++) { + out += snip; + } + document.body.insertAdjacentHTML('beforeend', '
' + out + '
'); + window.scrollTo(0, 0); + }); + + afterEach(() => { + let el = document.getElementById('el'); + el.parentNode.removeChild(el); + }); + + it('Test scroll event', done => { + + let delegate = new Delegate(document); + let windowDelegate = new Delegate(window); + let spyA = sinon.spy(); + let spyB = sinon.spy(); + delegate.on('scroll', spyA); + windowDelegate.on('scroll', spyB); + + // Scroll events on some browsers are asynchronous + window.setTimeout(function () { + proclaim.isTrue(spyA.calledOnce); + proclaim.isTrue(spyB.calledOnce); + delegate.destroy(); + windowDelegate.destroy(); + + done(); + }, 100); + window.scrollTo(0, 100); + }); + + it('Test sub-div scrolling', done => { + let delegate = new Delegate(document); + let el = document.getElementById('el'); + el.style.height = '100px'; + el.style.overflow = 'scroll'; + + let spyA = sinon.spy(); + delegate.on('scroll', '#el', spyA); + + // Scroll events on some browsers are asynchronous + window.setTimeout(function () { + proclaim.isTrue(spyA.calledOnce); + delegate.destroy(); + done(); + }, 100); + + let event = document.createEvent("MouseEvents"); + event.initMouseEvent('scroll', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); + el.dispatchEvent(event); + }); +}); diff --git a/test/tests/delegateTest.js b/test/tests/delegateTest.js deleted file mode 100644 index 775b69c..0000000 --- a/test/tests/delegateTest.js +++ /dev/null @@ -1,637 +0,0 @@ -/*jshint laxbreak:true*/ - -/*global buster, Delegate*/ - -var setupHelper = {}; - -var assert = buster.assert; -var refute = buster.refute; - -setupHelper.setUp = function() { - document.body.insertAdjacentHTML('beforeend', - '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '
' - + '' - + '' - + '' - ); -}; - -setupHelper.tearDown = function() { - var toRemove; - toRemove = document.getElementById('container1'); - if (toRemove) { - toRemove.parentNode.removeChild(toRemove); - } - toRemove = document.getElementById('container2'); - if (toRemove) { - toRemove.parentNode.removeChild(toRemove); - } -}; - -setupHelper.fireMouseEvent = function(target, eventName, relatedTarget) { - // TODO: Extend this to be slightly more configurable when initialising the event. - var ev; - if (document.createEvent) { - ev = document.createEvent("MouseEvents"); - ev.initMouseEvent(eventName, true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, relatedTarget || null); - target.dispatchEvent(ev); - } else if ( document.createEventObject ) { - ev = document.createEventObject(); - target.fireEvent( 'on' + eventName, ev); - } -}; - -setupHelper.fireFormEvent = function (target, eventName) { - var ev; - if (document.createEvent) { - ev = document.createEvent('Event'); - ev.initEvent(eventName, true, true); - target.dispatchEvent(ev); - } else if ( document.createEventObject ) { - ev = document.createEventObject(); - target.fireEvent( 'on' + eventName, ev); - } -}; - -setupHelper.fireCustomEvent = function(target, eventName) { - var ev = new Event(eventName, { - bubbles: true - }); - target.dispatchEvent(ev); -}; - -buster.testCase('Delegate', { - 'setUp': function() { - setupHelper.setUp(); - }, - 'Delegate#off should remove the event handlers for a selector' : function() { - var delegate = new Delegate(document); - var spyA = this.spy(), spyB = this.spy(); - - delegate.on('click', '#delegate-test-clickable', spyA); - delegate.on('click', '#delegate-test-clickable', spyB); - - var element = document.getElementById("delegate-test-clickable"); - - setupHelper.fireMouseEvent(element, "click"); - - assert.calledOnce(spyA); - assert.calledOnce(spyB); - - delegate.off("click", '#delegate-test-clickable'); - - setupHelper.fireMouseEvent(element, "click"); - - assert.calledOnce(spyA); - assert.calledOnce(spyB); - }, - 'ID selectors are supported' : function() { - var delegate, spy, element; - - delegate = new Delegate(document); - spy = this.spy(); - delegate.on('click', '#delegate-test-clickable', spy); - - element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spy); - - delegate.off(); - }, - 'Destroy destroys' : function() { - var delegate, spy, element; - - delegate = new Delegate(document); - spy = this.spy(); - delegate.on('click', '#delegate-test-clickable', spy); - - delegate.destroy(); - - element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - refute.called(spy); - }, - 'Tag selectors are supported' : function() { - var delegate, spy, element; - - delegate = new Delegate(document); - spy = this.spy(); - delegate.on('click', 'div', function (event) { - spy(); - return false; - }); - - element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spy); - - delegate.off(); - }, - 'Tag selectors are supported for svg' : function() { - var delegate, spy, element; - - delegate = new Delegate(document); - spy = this.spy(); - delegate.on('click', 'circle', function (event) { - spy(); - return false; - }); - - element = document.getElementById('svg-delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spy); - - delegate.off(); - }, - 'Class name selectors are supported' : function() { - var delegate, spy, element; - - delegate = new Delegate(document); - spy = this.spy(); - delegate.on('click', '.delegate-test-clickable', spy); - - element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spy); - - delegate.off(); - }, - 'Complex selectors are supported' : function() { - var delegate, spyA, spyB, element; - - delegate = new Delegate(document); - spyA = this.spy(); - spyB = this.spy(); - delegate.on('click', 'div.delegate-test-clickable, div[id=another-delegate-test-clickable]', spyA); - delegate.on('click', 'div.delegate-test-clickable + #another-delegate-test-clickable', spyB); - - element = document.getElementById('another-delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spyA); - assert.calledOnce(spyB); - - delegate.off(); - }, - 'If two click handlers are registered then all handlers should be called on click' : function() { - var delegate = new Delegate(document); - var spyA = this.spy(), spyB = this.spy(); - - delegate.on("click", '#delegate-test-clickable', spyA); - delegate.on("click", '#delegate-test-clickable', spyB); - - var element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, "click"); - - assert.calledOnce(spyA); - assert.calledOnce(spyB); - - delegate.off(); - }, - 'Returning false from a callback should stop propagation immediately': function() { - var delegate = new Delegate(document); - - var spyA = this.spy(), spyB = this.spy(); - - delegate.on("click", '#delegate-test-clickable', function() { - spyA(); - - // Return false to stop propagation - return false; - }); - delegate.on("click", '#delegate-test-clickable', spyB); - - var element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, "click"); - - assert.calledOnce(spyA); - refute.calledOnce(spyB); - - delegate.off(); - }, - 'Returning false from a callback should preventDefault': function(done) { - var delegate = new Delegate(document.body); - - var spyA = this.spy(); - - delegate.on("click", '#delegate-test-clickable', function(event) { - spyA(); - - // event.defaultPrevented appears to have issues in IE so just mock - // preventDefault instead. - var defaultPrevented; - event.preventDefault = function() { - defaultPrevented = true; - }; - - setTimeout(function() { - assert.equals(defaultPrevented, true); - done(); - }, 0); - - return false; - }); - - var element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, "click"); - - assert.calledOnce(spyA); - delegate.off(); - }, - 'Returning false from a callback should stop propagation globally': function() { - var delegateA = new Delegate(document), delegateB = new Delegate(document); - - var spyA = this.spy(), spyB = this.spy(); - - delegateA.on("click", '#delegate-test-clickable', function() { - spyA(); - - // Return false to stop propagation to other delegates - return false; - }); - delegateB.on("click", '#delegate-test-clickable', spyB); - - var element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, "click"); - - assert.calledOnce(spyA); - refute.calledOnce(spyB); - - delegateA.off(); - delegateB.off(); - }, - 'Clicking on parent node should not trigger event' : function() { - var delegate = new Delegate(document); - var spy = this.spy(); - - delegate.on("click", "#delegate-test-clickable", spy); - - setupHelper.fireMouseEvent(document, "click"); - - refute.called(spy); - - var spyA = this.spy(); - - delegate.on("click", "#another-delegate-test-clickable", spyA); - - var element = document.getElementById("another-delegate-test-clickable"); - setupHelper.fireMouseEvent(element, "click"); - - assert.calledOnce(spyA); - refute.calledOnce(spy); - - delegate.off(); - }, - 'Exception should be thrown when no handler is specified in Delegate#on' : function() { - - try { - var delegate = new Delegate(document); - delegate.on("click", '#delegate-test-clickable'); - } catch (e) { - assert.match(e, { name: 'TypeError', message: 'Handler must be a type of Function' }); - } - }, - 'Delegate#off with zero arguments should remove all handlers' : function() { - var delegate = new Delegate(document); - var spyA = this.spy(), spyB = this.spy(); - - delegate.on('click', '#delegate-test-clickable', spyA); - delegate.on('click', '#another-delegate-test-clickable', spyB); - - delegate.off(); - - var element = document.getElementById('delegate-test-clickable'), - element2 = document.getElementById('another-delegate-test-clickable'); - - setupHelper.fireMouseEvent(element, "click"); - setupHelper.fireMouseEvent(element2, "click"); - - refute.called(spyA); - refute.called(spyB); - - spyA.reset(); - spyB.reset(); - - setupHelper.fireMouseEvent(element, "mouseover", document); - setupHelper.fireMouseEvent(element2, "mouseover", document); - - refute.called(spyA); - refute.called(spyB); - }, - 'Regression test: Delegate#off called from a callback should succeed without exception' : function() { - var delegate = new Delegate(document); - var spyA = this.spy(); - - delegate.on('click', '#delegate-test-clickable', function() { - spyA(); - delegate.off(); - }); - - var element = document.getElementById('delegate-test-clickable'); - - refute.exception(function() { - setupHelper.fireMouseEvent(element, 'click'); - }); - - assert.called(spyA); - }, - 'Delegate#off called from a callback should prevent execution of subsequent callbacks' : function() { - var delegate = new Delegate(document); - var spyA = this.spy(), spyB = this.spy(); - - delegate.on('click', '#delegate-test-clickable', function() { - spyA(); - delegate.off(); - }); - delegate.on('click', '#delegate-test-clickable', spyB); - - var element = document.getElementById('delegate-test-clickable'); - - setupHelper.fireMouseEvent(element, 'click'); - - assert.called(spyA); - refute.called(spyB); - }, - 'Can be instantiated without a root node' : function() { - var delegate = new Delegate(); - var spyA = this.spy(); - var element = document.getElementById('delegate-test-clickable'); - - delegate.on('click', '#delegate-test-clickable', function(event) { - spyA(); - }); - - setupHelper.fireMouseEvent(element, 'click'); - refute.called(spyA); - delegate.off(); - }, - 'Can be bound to an element after its event listeners have been set up' : function() { - var delegate = new Delegate(); - var spyA = this.spy(); - var element = document.getElementById('delegate-test-clickable'); - - delegate.on('click', '#delegate-test-clickable', function(event) { - spyA(); - }); - - setupHelper.fireMouseEvent(element, 'click'); - delegate.root(document); - setupHelper.fireMouseEvent(element, 'click'); - assert.calledOnce(spyA); - delegate.off(); - }, - 'Can be unbound from an element' : function() { - var delegate = new Delegate(document); - var spyA = this.spy(); - var element = document.getElementById('delegate-test-clickable'); - - delegate.on('click', '#delegate-test-clickable', function(event) { - spyA(); - }); - - delegate.root(); - setupHelper.fireMouseEvent(element, 'click'); - refute.called(spyA); - delegate.off(); - }, - 'Can be to bound to a different DOM element': function () { - var spyA = this.spy(); - var element = document.getElementById('element-in-container2-test-clickable'); - - // Attach to the first container - var delegate = new Delegate(document.getElementById('container1')); - - // Listen to elements with class delegate-test-clickable - delegate.on('click', '.delegate-test-clickable', function(event) { - spyA(); - }); - - // Click the element in the second container - setupHelper.fireMouseEvent(element, 'click'); - - // Ensure no click was caught - refute.called(spyA); - - // Move the listeners to the second container - delegate.root(document.getElementById('container2')); - - // Click the element in the second container again - setupHelper.fireMouseEvent(element, 'click'); - - // Ensure the click was caught - assert.calledOnce(spyA); - - delegate.off(); - }, - 'Regression test: event fired on a text node should bubble normally' : function() { - var delegate, spy, element, textNode; - - spy = this.spy(); - - delegate = new Delegate(document); - delegate.on('click', '#delegate-test-clickable', spy); - - element = document.getElementById('delegate-test-clickable'); - textNode = document.createTextNode('Test text'); - element.appendChild(textNode); - - setupHelper.fireMouseEvent(textNode, 'click'); - - assert.called(spy); - - delegate.off(); - }, - - // Regression test for - https://github.com/ftlabs/dom-delegate/pull/10 - 'Regression test: event listener should be rebound after last event is removed and new events are added.' : function() { - var delegate, spy, element, textNode; - - spy = this.spy(); - - delegate = new Delegate(document); - delegate.on('click', '#delegate-test-clickable', spy); - - // Unbind event listeners - delegate.off(); - - delegate.on('click', '#delegate-test-clickable', spy); - - element = document.getElementById('delegate-test-clickable'); - - setupHelper.fireMouseEvent(element, 'click'); - - assert.called(spy); - - delegate.off(); - }, - - // Test for issue #5 - 'The root element, via a null selector, is supported': function() { - var delegate, spy, element; - - delegate = new Delegate(document.body); - spy = this.spy(); - delegate.on('click', null, spy); - - element = document.body; - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spy); - - delegate.off(); - }, - - // Test for issues #16 - 'The root element, when passing a callback into the second parameter, is supported': function() { - var delegate, spy, element; - - delegate = new Delegate(document.body); - spy = this.spy(); - delegate.on('click', spy); - - element = document.body; - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spy); - - delegate.off(); - }, - - // Test for issue #16 - 'Can unset a listener on the root element when passing the callback into the second parameter': function() { - var element = document.getElementById('element-in-container2-test-clickable'); - var delegate = new Delegate(document.body); - var spy = this.spy(); - var spy2 = this.spy(); - - delegate.on('click', spy); - delegate.on('click', '#element-in-container2-test-clickable', spy2); - - setupHelper.fireMouseEvent(element, 'click'); - delegate.off('click', spy); - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(spy); - assert.calledTwice(spy2); - - delegate.off(); - }, - - 'Regression test: #root is chainable during setting of root': function() { - var delegate, spy, element; - - delegate = new Delegate(); - spy = this.spy(); - delegate.root(document.body).on('click', null, spy); - - element = document.body; - setupHelper.fireMouseEvent(element, 'click'); - assert.calledOnce(spy); - delegate.off(); - }, - - 'Regression test: #root is chainable during unsetting of root': function() { - var delegate, spy, element; - - delegate = new Delegate(document.body); - spy = this.spy(); - delegate.root().on('click', null, spy); - delegate.root(document.body); - - element = document.body; - setupHelper.fireMouseEvent(element, 'click'); - assert.calledOnce(spy); - delegate.off(); - }, - - 'Focus events can be caught': function() { - var delegate, spy, element, ev; - - delegate = new Delegate(document.body); - spy = this.spy(); - spy2 = this.spy(); - delegate.on('focus', 'input', spy); - element = document.getElementById('js-input'); - setupHelper.fireFormEvent(element, 'focus'); - assert.calledOnce(spy); - }, - - 'Blur events can be caught': function() { - var delegate, spy, element, ev; - - delegate = new Delegate(document.body); - spy = this.spy(); - spy2 = this.spy(); - delegate.on('blur', 'input', spy); - element = document.getElementById('js-input'); - setupHelper.fireFormEvent(element, 'blur'); - assert.calledOnce(spy); - }, - 'Test setting useCapture true false works get attached to capturing and bubbling event handlers, respectively' : function() { - var delegate = new Delegate(document); - var bubbleSpy = this.spy(); - var captureSpy = this.spy(); - var bubblePhase; - var capturePhase; - - delegate.on('click', '.delegate-test-clickable', function(event) { - bubblePhase = event.eventPhase; - bubbleSpy(); - }, false); - delegate.on('click', '.delegate-test-clickable', function(event) { - capturePhase = event.eventPhase; - captureSpy(); - }, true); - - var element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - assert.equals(1, capturePhase); - assert.equals(3, bubblePhase); - assert.callOrder(captureSpy, bubbleSpy); - - // Ensure unbind works properly - delegate.off(); - - element = document.getElementById('delegate-test-clickable'); - setupHelper.fireMouseEvent(element, 'click'); - - assert.calledOnce(captureSpy); - assert.calledOnce(bubbleSpy); - }, - - 'Custom events are supported': function() { - var delegate = new Delegate(document.body); - var spyOnContainer = this.spy(); - var spyOnElement = this.spy(); - - delegate.on('foobar', '#container1', function(event) { - spyOnContainer(); - }); - - delegate.on('foobar', '#custom-event', function(event) { - spyOnElement(); - }); - - setupHelper.fireCustomEvent(document.getElementById("custom-event"), 'foobar'); - - assert.calledOnce(spyOnContainer); - assert.calledOnce(spyOnElement); - }, - - 'tearDown': function() { - setupHelper.tearDown(); - } -}); diff --git a/test/tests/test-scroll.js b/test/tests/test-scroll.js deleted file mode 100644 index 9bbdef3..0000000 --- a/test/tests/test-scroll.js +++ /dev/null @@ -1,76 +0,0 @@ -buster.testCase('Delegate', { - 'setUp': function() { - var snip = '

text

'; - var out = ''; - for (var i = 0, l = 10000; i < l; i++) { - out += snip; - } - document.body.insertAdjacentHTML('beforeend', '
'+out+'
'); - window.scrollTo(0, 0); - }, - 'Test scroll event' : function() { - var promise = { - then: function (callback) { - this.callbacks = this.callbacks || []; - this.callbacks.push(callback); - } - }; - - var delegate = new Delegate(document); - var windowDelegate = new Delegate(window); - var spyA = this.spy(); - var spyB = this.spy(); - delegate.on('scroll', spyA); - windowDelegate.on('scroll', spyB); - - // Scroll events on some browsers are asynchronous - window.setTimeout(function() { - assert.calledOnce(spyA); - assert.calledOnce(spyB); - delegate.destroy(); - windowDelegate.destroy(); - - callbacks = promise.callbacks || []; - for (var i = 0, l = callbacks.length; i < l; ++i) { - callbacks[i](); - } - }, 100); - window.scrollTo(0, 100); - return promise; - }, - 'Test sub-div scrolling': function() { - var promise = { - then: function (callback) { - this.callbacks = this.callbacks || []; - this.callbacks.push(callback); - } - }; - - var delegate = new Delegate(document); - var el = document.getElementById('el'); - el.style.height = '100px'; - el.style.overflow = 'scroll'; - - var spyA = this.spy(); - delegate.on('scroll', '#el', spyA); - - // Scroll events on some browsers are asynchronous - window.setTimeout(function() { - assert.calledOnce(spyA); - delegate.destroy(); - - callbacks = promise.callbacks || []; - for (var i = 0, l = callbacks.length; i < l; ++i) { - callbacks[i](); - } - }, 100); - var event = document.createEvent("MouseEvents"); - event.initMouseEvent('scroll', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); - el.dispatchEvent(event); - return promise; - }, - 'tearDown': function() { - var el = document.getElementById('el'); - el.parentNode.removeChild(el); - } -}); From 3697bb41d7d02ee7aa8ec5ec2059551e60f67f74 Mon Sep 17 00:00:00 2001 From: notlee Date: Wed, 18 Sep 2019 10:20:49 +0100 Subject: [PATCH 03/10] Correct references in origami.json --- origami.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/origami.json b/origami.json index e90175e..cfab329 100644 --- a/origami.json +++ b/origami.json @@ -4,14 +4,14 @@ "origamiType": "module", "origamiCategory": "utilities", "origamiVersion": 1, - "support": "https://github.com/ftlabs/ftdomdelegate/issues", - "supportStatus": "active", + "support": "https://github.com/Financial-Times/ftdomdelegate/issues", + "supportStatus": "maintained", "ci": { - "circle": "https://circleci.com/api/v1/project/Financial-Times/o-message" + "circle": "https://circleci.com/api/v1/project/Financial-Times/ftdomdelegate" }, "browserFeatues": { "required": [ - "matches" + "Element.prototype.matches" ] } } From 23899eac25debf1da6dcf4902d20fe7ed58ad38c Mon Sep 17 00:00:00 2001 From: notlee Date: Wed, 18 Sep 2019 11:46:57 +0100 Subject: [PATCH 04/10] Remove matches iife, so it doesn't run upon require. Related to: https://github.com/Financial-Times/ftdomdelegate/pull/87 Similar to: https://github.com/Financial-Times/o-grid/pull/171 --- README.md | 4 +++- src/js/delegate.js | 17 +---------------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 52e06f2..c1c6398 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,15 @@ The library has been deployed as part of the [FT Web App](http://app.ft.com/) an * Android Browser on Android 2 + * PlayBook OS 1 + +You'll need the following polyfill to support IE11: + - [Element.prototype.matches](https://polyfill.io/v2/docs/features/#Element_prototype_matches) + For older browsers (IE8) you'll need the following polyfills - [Event](https://polyfill.io/v2/docs/features/#Event) - [Array.prototype.map](https://polyfill.io/v2/docs/features/#Array_prototype_map) - [Function.prototype.bind](https://polyfill.io/v2/docs/features/#Function_prototype_bind) - [document.querySelector](https://polyfill.io/v2/docs/features/#document_querySelector) - - [Element.prototype.matches](https://polyfill.io/v2/docs/features/#Element_prototype_matches) The easiest way is to include the following script tag and let [Polyfill.io](https://Polyfill.io) work its magic diff --git a/src/js/delegate.js b/src/js/delegate.js index 73bd7f8..5f4988e 100644 --- a/src/js/delegate.js +++ b/src/js/delegate.js @@ -174,7 +174,7 @@ Delegate.prototype.on = function (eventType, selector, handler, useCapture) { matcher = matchesId; } else { matcherParam = selector; - matcher = matches; + matcher = Element.prototype.matches; } // Add to the list of listeners @@ -376,21 +376,6 @@ Delegate.prototype.fire = function (event, target, listener) { return listener.handler.call(target, event, target); }; -/** - * Check whether an element - * matches a generic selector. - * - * @type function() - * @param {string} selector A CSS selector - */ -let matches = (function (el) { - if (!el) { - return; - } - let p = el.prototype; - return (p.matches || p.matchesSelector || p.webkitMatchesSelector || p.mozMatchesSelector || p.msMatchesSelector || p.oMatchesSelector); -}(Element)); - /** * Check whether an element * matches a tag selector. From c4abae89842a42dbdfe4ba27cc596169f77c4dde Mon Sep 17 00:00:00 2001 From: notlee Date: Wed, 18 Sep 2019 14:20:14 +0100 Subject: [PATCH 05/10] Remove version from package.json\nhttps://origami.ft.com/spec/v1/components/\#package-management --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index b86a9c2..e25787e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "name": "ftdomdelegate", - "version": "3.1.0", "author": "FT Labs (http://labs.ft.com/)", "contributors": [ "Matthew Caruana Galizia", From 1392c68c4d62624da0db2d5075f1a54da0366ebf Mon Sep 17 00:00:00 2001 From: notlee Date: Wed, 18 Sep 2019 15:08:06 +0100 Subject: [PATCH 06/10] Use ES Modules over CommonJS. --- MIGRATION.md | 25 +++ README.md | 18 +- main.js | 482 ++++++++++++++++++++++++++++++++++++++++++++- src/js/delegate.js | 481 -------------------------------------------- 4 files changed, 504 insertions(+), 502 deletions(-) delete mode 100644 src/js/delegate.js diff --git a/MIGRATION.md b/MIGRATION.md index 9e29610..a952405 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2,6 +2,31 @@ ## Migrating from v3 to v4 +v4 changes the default export to the constructor. It also uses [ES Modules over CommonJS](https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/) syntax. We recommend to include `ftdomdelegate` using the es modules syntax. + +If you used the `.Delegate` constructure update your import: + +```diff +-const Delegate = require('ftdomdelegate').Delegate; ++import Delegate from 'ftdomdelegate'; +let myDel = new Delegate(document.body); +``` + +If you used the previous default export, also update to use the constructor: +```diff +-const delegate = require('ftdomdelegate'); +-let myDel = delegate(document.body); ++import Delegate from 'ftdomdelegate'; ++let myDel = new Delegate(document.body); +``` + +However to use the CommonJS syntax, without a plugin like [babel-plugin-transform-es2015-modules-commonjs](https://babeljs.io/docs/en/babel-plugin-transform-es2015-modules-commonjs), add `.default`. + +```diff +-const Delegate = require('ftdomdelegate').Delegate; ++const Delegate = require('ftdomdelegate').default; +let myDel = new Delegate(document.body); +``` ## Migrating from v2 to v3 diff --git a/README.md b/README.md index c1c6398..04ada7c 100644 --- a/README.md +++ b/README.md @@ -36,23 +36,16 @@ The easiest way is to include the following script tag and let [Polyfill.io](htt ``` -## Usage ## +## Usage -The library is written in CommonJS and so can be `require` in. +To import ftdomdelegate: ```js -// If requiring the module via CommonJS, either:- -Delegate = require('ftdomdelegate').Delegate; -myDel = new Delegate(document.body); - -// Or:- -delegate = require('ftdomdelegate'); -myDel = delegate(document.body); +import Delegate from 'ftdomdelegate'; +let myDel = new Delegate(document.body); ``` -The script must be loaded prior to instantiating a Delegate object. - -To instantiate Delegate on the `body` and listen to some events: +To instantiate `Delegate` on the `body` and listen to some events: ```js function handleButtonClicks(event) { @@ -70,7 +63,6 @@ document.addEventListener('DOMContentLoaded', function() { // Listen to all touch move // events that reach the body delegate.on('touchmove', handleTouchMove); - }); ``` diff --git a/main.js b/main.js index 46e25c9..abd6215 100644 --- a/main.js +++ b/main.js @@ -1,14 +1,480 @@ /** - * @preserve Create and manage a DOM event delegator. + * DOM event delegator * - * @codingstandard ftlabs-jsv2 - * @copyright The Financial Times Limited [All Rights Reserved] - * @license MIT License (see LICENSE.txt) + * 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) { + let 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 + * @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 */ -const Delegate = require('./src/js/delegate'); +Delegate.prototype.handle = function (event) { + let i; + let l; + let 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 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; + } + } -module.exports = function(root) { - return new Delegate(root); + 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 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(); }; -module.exports.Delegate = Delegate; +export default Delegate; diff --git a/src/js/delegate.js b/src/js/delegate.js deleted file mode 100644 index c5f30d5..0000000 --- a/src/js/delegate.js +++ /dev/null @@ -1,481 +0,0 @@ -/*jshint browser:true, node:true*/ -module.exports = Delegate; - -/** - * 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) { - let 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 - * @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; - let 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 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 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(); -}; From b76f6d983aecb96e6b73195be77d97f1983ebf93 Mon Sep 17 00:00:00 2001 From: notlee Date: Wed, 18 Sep 2019 15:41:16 +0100 Subject: [PATCH 07/10] Update the README to align with Origami-maintained components. --- MIGRATION.md | 4 +++- README.md | 55 +++++++++------------------------------------------- 2 files changed, 12 insertions(+), 47 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index a952405..5f22dbe 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -2,7 +2,9 @@ ## Migrating from v3 to v4 -v4 changes the default export to the constructor. It also uses [ES Modules over CommonJS](https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/) syntax. We recommend to include `ftdomdelegate` using the es modules syntax. +To support IE11 and other older browsers v4 requires the [Element.prototype.matches](https://polyfill.io/v3/url-builder/#Element.prototype.matches-polyfill) polyfill. + +It also uses [ES Modules over CommonJS](https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/) syntax, and updates the default export to the constructor. We recommend to include `ftdomdelegate` using the es modules syntax. If you used the `.Delegate` constructure update your import: diff --git a/README.md b/README.md index 04ada7c..a64e2f4 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,15 @@ -# ftdomdelegate [![CircleCI](https://circleci.com/gh/Financial-Times/ftdomdelegate.svg?style=svg)](https://circleci.com/gh/Financial-Times/ftdomdelegate) -FT's dom delegate library is a component for binding to events on all target elements matching the given selector, irrespective of whether anything exists in the DOM at registration time or not. This allows developers to implement the [event delegation pattern](http://www.sitepoint.com/javascript-event-delegation-is-easier-than-you-think/). - -FT DOM Delegate is developed by [FT Labs](http://labs.ft.com/), part of the Financial Times. - -## Compatibility ## - -The library has been deployed as part of the [FT Web App](http://app.ft.com/) and is tried and tested on the following browsers: - -* Safari 5 + -* Mobile Safari on iOS 3 + -* Chrome 1 + -* Chrome on iOS 5 + -* Chrome on Android 4.0 + -* Opera 11.5 + -* Opera Mobile 11.5 + -* Firefox 4 + -* Internet Explorer 9 + -* Android Browser on Android 2 + -* PlayBook OS 1 + - -You'll need the following polyfill to support IE11: - - [Element.prototype.matches](https://polyfill.io/v2/docs/features/#Element_prototype_matches) - -For older browsers (IE8) you'll need the following polyfills - - - [Event](https://polyfill.io/v2/docs/features/#Event) - - [Array.prototype.map](https://polyfill.io/v2/docs/features/#Array_prototype_map) - - [Function.prototype.bind](https://polyfill.io/v2/docs/features/#Function_prototype_bind) - - [document.querySelector](https://polyfill.io/v2/docs/features/#document_querySelector) +ftdomdelegate [![CircleCI](https://circleci.com/gh/Financial-Times/ftdomdelegate.svg?style=svg)](https://circleci.com/gh/Financial-Times/ftdomdelegate) [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](#licence) +================= -The easiest way is to include the following script tag and let [Polyfill.io](https://Polyfill.io) work its magic +FT's dom delegate library is a component for binding to events on all target elements matching the given selector, irrespective of whether anything exists in the DOM at registration time or not. This allows developers to implement the [event delegation pattern](http://www.sitepoint.com/javascript-event-delegation-is-easier-than-you-think/). -```js - -``` +- [JavaScript](#javascript) +- [Migration](#migration) +- [Contact](#contact) +- [Licence](#licence) -## Usage +## JavaScript To import ftdomdelegate: @@ -79,16 +52,6 @@ document.addEventListener('DOMContentLoaded', function() { }); ``` -Note: as of 0.1.2 you do not need to provide a DOM element at the point of instantiation, it can be set later via the `root` method. - -Also note: as of 0.2.0 you cannot specify more than one `eventType` in a single call to `off` or `on`. - -### Google Closure Compiler ### - -Delegate supports compilation with `ADVANCED_OPTIMIZATIONS` ('advanced mode'), which should reduce its size by about 70% (60% gzipped). Note that exposure of the `Delegate` variable isn't forced therefore you must compile it along with all of your code. - -## API ## - ### .on(eventType[, selector], handler[, useCapture]) ### #### `eventType (string)` #### @@ -143,7 +106,7 @@ Short hand for off() and root(), ie both with no parameters. Used to reset the d ## Credits and collaboration ## -The developers of ftdomdelegate are [Matthew Andrews](https://twitter.com/andrewsmatt) and [Matthew Caruana Galizia](http://twitter.com/mcaruanagalizia). Test engineering by [Sam Giles](https://twitter.com/SamuelGiles_). The API is influenced by [jQuery Live](http://api.jquery.com/live/). All open source code released by FT Labs is licenced under the MIT licence. We welcome comments, feedback and suggestions. Please feel free to raise an issue or pull request. +FT DOM Delegate was developed by [FT Labs](http://labs.ft.com/), part of the Financial Times. It's now maintained by the [Origami Team](https://origami.ft.com/). The developers of ftdomdelegate were [Matthew Andrews](https://twitter.com/andrewsmatt) and [Matthew Caruana Galizia](http://twitter.com/mcaruanagalizia). Test engineering by [Sam Giles](https://twitter.com/SamuelGiles_). The API is influenced by [jQuery Live](http://api.jquery.com/live/). ## Migration guide From 32843365008fec903c4075f31cfa8a75f68f4ae1 Mon Sep 17 00:00:00 2001 From: notlee Date: Wed, 18 Sep 2019 15:47:02 +0100 Subject: [PATCH 08/10] Tweak README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a64e2f4..4d326be 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ FT DOM Delegate was developed by [FT Labs](http://labs.ft.com/), part of the Fin State | Major Version | Last Minor Release | Migration guide | :---: | :---: | :---: | :---: ✨ active | 4 | N/A | [migrate to v4](MIGRATION.md#migrating-from-v3-to-v4) | -⚠ maintained | 3 | 3.0.2 | [migrate to v3](MIGRATION.md#migrating-from-v2-to-v3) | +⚠ maintained | 3 | 3.1.0 | [migrate to v3](MIGRATION.md#migrating-from-v2-to-v3) | ╳ deprecated | 2 | 2.2.1 | N/A | ╳ deprecated | 1 | 1.0.6 | N/A | From c7a1718c1b1aa688da4fd2426ace62a4b3c42b1d Mon Sep 17 00:00:00 2001 From: notlee Date: Thu, 21 Nov 2019 14:08:12 +0000 Subject: [PATCH 09/10] Remove patch from "last minor" in readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4d326be..dc245c6 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ FT DOM Delegate was developed by [FT Labs](http://labs.ft.com/), part of the Fin State | Major Version | Last Minor Release | Migration guide | :---: | :---: | :---: | :---: ✨ active | 4 | N/A | [migrate to v4](MIGRATION.md#migrating-from-v3-to-v4) | -⚠ maintained | 3 | 3.1.0 | [migrate to v3](MIGRATION.md#migrating-from-v2-to-v3) | -╳ deprecated | 2 | 2.2.1 | N/A | -╳ deprecated | 1 | 1.0.6 | N/A | +⚠ maintained | 3 | 3.1 | [migrate to v3](MIGRATION.md#migrating-from-v2-to-v3) | +╳ deprecated | 2 | 2.2 | N/A | +╳ deprecated | 1 | 1.0 | N/A | From 70431ab576567006cadd069f66b97d5750b3c688 Mon Sep 17 00:00:00 2001 From: notlee Date: Thu, 21 Nov 2019 14:15:33 +0000 Subject: [PATCH 10/10] Fix migration typo --- MIGRATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index 5f22dbe..5a9b5be 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -6,7 +6,7 @@ To support IE11 and other older browsers v4 requires the [Element.prototype.matc It also uses [ES Modules over CommonJS](https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/) syntax, and updates the default export to the constructor. We recommend to include `ftdomdelegate` using the es modules syntax. -If you used the `.Delegate` constructure update your import: +If you used the `.Delegate` constructor update your import: ```diff -const Delegate = require('ftdomdelegate').Delegate;