diff --git a/.gitignore b/.gitignore index 3c3629e..ba2a97b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b34c6db --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +sudo: false +language: node_js + +node_js: + - "6" + +script: npm test && cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js diff --git a/README.md b/README.md index 228b83d..6d732dd 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ diffHTML Inline Transitions --------------------------- +[![Build Status](https://travis-ci.org/tbranyen/diffhtml-inline-transitions.svg?branch=master)](https://travis-ci.org/tbranyen/diffhtml-inline-transitions) +[![Coverage Status](https://coveralls.io/repos/github/tbranyen/diffhtml-inline-transitions/badge.svg?branch=master)](https://coveralls.io/github/tbranyen/diffhtml-inline-transitions?branch=master) + Tiny module to support binding/unbinding transition hooks declaratively. #### Install diff --git a/dist/inline-transitions.js b/dist/inline-transitions.js index b849947..4a2aa5c 100644 --- a/dist/inline-transitions.js +++ b/dist/inline-transitions.js @@ -1,62 +1,127 @@ (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.inlineTransitions = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + rest[_key - 1] = arguments[_key]; + } - if (element.contains(args[0])) { - return newVal.apply(_this, [element].concat(args)); + // If there are no elements to match here, abort. + if (!map.size) { + return; + } + // If the child element triggered in the transition is the root element, + // this is an easy lookup for the handler. + else if (map.has(child)) { + return map.get(child).apply(child, [child].concat(rest)); } - }))); + // The last resort is looping through all the registered elements to see + // if the child is contained within. If so, it aggregates all the valid + // handlers and if they return Promises return them into a `Promise.all`. + else { + var _ret = function () { + var retVal = []; - addTransitionState(name, internalMap[name]); - } else if (internalMap[name]) { - removeTransitionState(name, internalMap[name]); - delete internalMap[name]; - transitionsMap.set(element, internalMap); - } - }); -}; + // Last resort check for child. + map.forEach(function (fn, element) { + if (element.contains(child)) { + retVal.push(fn.apply(child, [element].concat(child, rest))); + } + }); -function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } + var hasPromise = retVal.some(function (ret) { + return Boolean(ret.then); + }); -var states = ['attached', 'detached', 'replaced', 'attributeChanged', 'textChanged']; + // This is the only time the return value matters. + if (hasPromise) { + return { + v: Promise.all(retVal) + }; + } + }(); -var transitionsMap = new WeakMap();module.exports = exports['default']; + if ((typeof _ret === 'undefined' ? 'undefined' : _typeof(_ret)) === "object") return _ret.v; + } + }; + + // Save the handler for later unbinding. + boundHandlers.push(handler); + + // Add the state handler. + addTransitionState(name, handler); + }); + + return unsubscribe; +}; },{}]},{},[1])(1) }); \ No newline at end of file diff --git a/index.js b/index.js index f31b4dd..1b70239 100644 --- a/index.js +++ b/index.js @@ -1,48 +1,104 @@ -const states = [ - 'attached', - 'detached', - 'replaced', - 'attributeChanged', - 'textChanged', -]; +// Store maps of elements to handlers that are associated to transitions. +const transitionsMap = { + attached: new Map(), + detached: new Map(), + replaced: new Map(), + attributeChanged: new Map(), + textChanged: new Map(), +}; -const transitionsMap = new WeakMap(); +// Internal global transition state handlers, allows us to bind once and match. +const boundHandlers = []; /** * Binds inline transitions to the parent element and triggers for any matching * nested children. */ -export default function({ addTransitionState, removeTransitionState }) { - addTransitionState('attached', (element) => { - if (element.attributes.attached) { - return element.attached.call(this, element, element); +module.exports = function({ addTransitionState, removeTransitionState }) { + // Monitors whenever an element changes an attribute, if the attribute + // is a valid state name, add this element into the related Set. + const attributeChanged = function(element, name, oldVal, newVal) { + const map = transitionsMap[name]; + + // Abort early if not a valid transition or if the new value exists, but + // isn't a function. + if (!map || (newVal && typeof newVal !== 'function')) { + return; } - }); + + // Add or remove based on the value existence and type. + map[typeof newVal === 'function' ? 'set' : 'delete'](element, newVal); + }; + + // This will unbind any internally bound transition states. + const unsubscribe = () => { + // Unbind all the transition states. + removeTransitionState('attributeChanged', attributeChanged); + + // Remove all elements from the internal cache. + Object.keys(transitionsMap).forEach(name => { + const map = transitionsMap[name]; + + // Unbind the associated global handler. + removeTransitionState(name, boundHandlers.shift()); + + // Empty the associated element set. + map.clear(); + }); + + // Empty the bound handlers. + boundHandlers.length = 0; + }; + + // If this function gets repeatedly called, unbind the previous to avoid doubling up. + unsubscribe(); // Set a "global" `attributeChanged` to monitor all elements for transition // states being attached. - addTransitionState('attributeChanged', (element, name, oldVal, newVal) => { - const internalMap = transitionsMap.get(element) || {}; + addTransitionState('attributeChanged', attributeChanged); - if (states.indexOf(name) === -1) { - return; - } + // Add a transition for every type. + Object.keys(transitionsMap).forEach(name => { + const map = transitionsMap[name]; + + const handler = (child, ...rest) => { + // If there are no elements to match here, abort. + if (!map.size) { + return; + } + // If the child element triggered in the transition is the root element, + // this is an easy lookup for the handler. + else if (map.has(child)) { + return map.get(child).apply(child, [child].concat(rest)); + } + // The last resort is looping through all the registered elements to see + // if the child is contained within. If so, it aggregates all the valid + // handlers and if they return Promises return them into a `Promise.all`. + else { + const retVal = []; - if (newVal) { - transitionsMap.set(element, Object.assign(internalMap, { - [name]: (...args) => { - if (element.contains(args[0])) { - return newVal.apply(this, [element].concat(args)); + // Last resort check for child. + map.forEach((fn, element) => { + if (element.contains(child)) { + retVal.push(fn.apply(child, [element].concat(child, rest))); } + }); + + const hasPromise = retVal.some(ret => Boolean(ret.then)); + + // This is the only time the return value matters. + if (hasPromise) { + return Promise.all(retVal); } - })); + } + }; - addTransitionState(name, internalMap[name]) - } - else if (internalMap[name]) { - removeTransitionState(name, internalMap[name]) - delete internalMap[name]; - transitionsMap.set(element, internalMap); - } - }) + // Save the handler for later unbinding. + boundHandlers.push(handler); + + // Add the state handler. + addTransitionState(name, handler); + }); + + return unsubscribe; } diff --git a/package.json b/package.json index cd4ab29..3d660a7 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "Monitors inline attributes and assigns transition hooks", "main": "dist/inline-transitions.js", "scripts": { - "build": "browserify -t babelify -s inlineTransitions index.js -o dist/inline-transitions.js" + "build": "browserify -t babelify -s inlineTransitions index.js -o dist/inline-transitions.js", + "mocha": "mocha test/_setup test/*.js", + "test": "istanbul cover _mocha -- -- test/_setup test/*.js" }, "keywords": [ "diffhtml", @@ -18,6 +20,11 @@ "babel-preset-es2015": "^6.9.0", "babelify": "^7.3.0", "browserify": "^13.0.1", - "derequire": "^2.0.3" + "coveralls": "^2.11.9", + "derequire": "^2.0.3", + "diffhtml": "^0.8.4", + "istanbul": "^1.0.0-alpha.2", + "mocha": "^2.5.3", + "stringdom": "jugglinmike/stringdom#f42ad65227fc4e5a1d120ae432c7ec4eaf6aa11b" } } diff --git a/test/_setup.js b/test/_setup.js new file mode 100644 index 0000000..7d2a2d1 --- /dev/null +++ b/test/_setup.js @@ -0,0 +1,30 @@ +const Document = require('stringdom'); + +// Set up a pretend DOM for the tests. +global.document = new Document(); +global.document.body = document.createElement('body'); +global.document.createComment = function() { + return document.createElement('noscript'); +}; + +// Get access to the Node prototype. +const Node = Object.getPrototypeOf(global.document.body); + +// No-op the event functions. +global.CustomEvent = function() {}; +Node.dispatchEvent = () => {}; + +// Copied from a project by Jonathan Neal. +Node.contains = function(node) { + if (!(0 in arguments)) { + throw new TypeError('1 argument is required'); + } + + do { + if (this === node) { + return true; + } + } while (node = node && node.parentNode); + + return false; +}; diff --git a/test/basics.js b/test/basics.js new file mode 100644 index 0000000..b40d72f --- /dev/null +++ b/test/basics.js @@ -0,0 +1,69 @@ +const assert = require('assert'); +const diff = require('diffhtml'); +const inlineTransitions = require('../index'); + +const { innerHTML, html } = diff; + +describe('Basics', function() { + beforeEach(() => { + this.fixture = document.createElement('div'); + this.unsubscribeInlineTransitions = inlineTransitions(diff); + }); + + afterEach(() => { + this.unsubscribeInlineTransitions(); + }); + + it('can listen for changes', (done) => { + const attached = el => { + assert.equal(el, this.fixture.firstChild); + done(); + }; + + innerHTML(this.fixture, html`
`); + }); + + it('can stop listening for hooks', () => { + let count = 0; + + const attached = el => { + assert.equal(el, this.fixture.firstChild); + count++; + }; + + innerHTML(this.fixture, html`
`); + + this.unsubscribeInlineTransitions(); + + innerHTML(this.fixture, html`
+
+
`); + + assert.equal(count, 1); + }); + + it('will pass through types to a hook', () => { + const attached = true; + innerHTML(this.fixture, html`
`); + assert.equal(this.fixture.firstChild.getAttribute('attached'), true); + }); + + it('can halt on a promise', (done) => { + const detached = el => { + return new Promise(resolve => setTimeout(resolve, 0)); + }; + + innerHTML(this.fixture, html`
+

+
`); + + innerHTML(this.fixture, html`
`); + + assert.ok(this.fixture.querySelector('p')); + + setTimeout(() => { + assert.ok(!this.fixture.querySelector('p')); + done(); + }, 20); + }); +});