├── .gitignore ├── .travis.yml ├── lib ├── index.js ├── text-binding.js ├── render.js ├── bindings.js ├── directive.js ├── attr-binding.js ├── model.js ├── child-binding.js └── view.js ├── test ├── .jshintrc ├── karma.conf.js ├── runner.html ├── specs │ ├── owners.js │ ├── attribute-interpolation.js │ ├── interpolation.js │ ├── directives.js │ ├── destroy.js │ ├── model.js │ ├── mounting.js │ ├── lifecycle.js │ ├── composing.js │ ├── view.js │ ├── text-interpolation.js │ ├── watching.js │ └── scope.js └── utils │ └── mocha.css ├── .jshintrc ├── package.json ├── component.json ├── Makefile ├── History.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | components 2 | build 3 | node_modules -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - 0.10 5 | 6 | script: 7 | - make ci 8 | 9 | notifications: 10 | email: 11 | - antshort+travis@gmail.com -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var view = require('./view'); 2 | 3 | module.exports = function(template) { 4 | if(template.indexOf('#') === 0 || template.indexOf('.') === 0) { 5 | template = document.querySelector(template); 6 | } 7 | if(typeof template.innerHTML === 'string') { 8 | template = template.innerHTML; 9 | } 10 | return view(template); 11 | }; -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "eqeqeq": true, 3 | "browser": true, 4 | "asi": true, 5 | "undef": true, 6 | "unused": true, 7 | "trailing": true, 8 | "sub": true, 9 | "node": true, 10 | "laxbreak": true, 11 | "globals": { 12 | "console": true, 13 | "it": true, 14 | "describe": true, 15 | "before": true, 16 | "after": true 17 | } 18 | } -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "eqeqeq": true, 3 | "camelcase": true, 4 | "indent": 2, 5 | "newcap": true, 6 | "eqnull": true, 7 | "browser": true, 8 | "asi": true, 9 | "multistr": true, 10 | "undef": true, 11 | "unused": true, 12 | "trailing": true, 13 | "sub": true, 14 | "node": true, 15 | "laxbreak": true, 16 | "globals": { 17 | "console": true 18 | } 19 | } -------------------------------------------------------------------------------- /test/karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | config.set({ 3 | basePath: '../', 4 | autoWatch: false, 5 | port: 9876, 6 | colors: true, 7 | captureTimeout: 60000, 8 | singleRun: true, 9 | logLevel: config.LOG_INFO, 10 | frameworks: ['mocha'], 11 | reporters: ['progress'], 12 | files: [ 13 | 'build/build.js', 14 | 'test/specs/**/*.js' 15 | ], 16 | browsers: [ 17 | 'Chrome', 18 | 'Firefox', 19 | 'Safari' 20 | ] 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ripplejs", 3 | "version": "0.4.0", 4 | "description": "Minimal reactive views for building user interfaces", 5 | "devDependencies": { 6 | "karma": "~0.12.1", 7 | "karma-mocha": "~0.1.3", 8 | "karma-coverage": "~0.2.1", 9 | "karma-script-launcher": "~0.1.0", 10 | "karma-phantomjs-launcher": "~0.1.2", 11 | "karma-chrome-launcher": "~0.1.2", 12 | "karma-firefox-launcher": "~0.1.3", 13 | "karma-safari-launcher": "~0.1.1", 14 | "mocha-phantomjs": "~3.3.2", 15 | "jshint": "~2.4.4", 16 | "component": "0.19.9", 17 | "bump": "git://github.com/ianstormtaylor/bump", 18 | "minify": "~0.2.6" 19 | } 20 | } -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ripple", 3 | "version": "0.4.0", 4 | "main": "lib/index.js", 5 | "scripts": [ 6 | "lib/index.js", 7 | "lib/view.js", 8 | "lib/bindings.js", 9 | "lib/model.js", 10 | "lib/render.js", 11 | "lib/directive.js", 12 | "lib/text-binding.js", 13 | "lib/attr-binding.js", 14 | "lib/child-binding.js" 15 | ], 16 | "dependencies": { 17 | "anthonyshort/attributes": "*", 18 | "anthonyshort/dom-walk": "0.1.0", 19 | "anthonyshort/is-boolean-attribute": "*", 20 | "anthonyshort/raf-queue": "0.2.0", 21 | "component/domify": "*", 22 | "component/each": "*", 23 | "component/emitter": "*", 24 | "ripplejs/interpolate": "0.4.3", 25 | "ripplejs/path-observer": "0.2.0", 26 | "yields/uniq": "*" 27 | }, 28 | "development": { 29 | "component/assert": "*" 30 | } 31 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | COMPONENT = ./node_modules/.bin/component 2 | KARMA = ./node_modules/karma/bin/karma 3 | JSHINT = ./node_modules/.bin/jshint 4 | MOCHA = ./node_modules/.bin/mocha-phantomjs 5 | BUMP = ./node_modules/.bin/bump 6 | MINIFY = ./node_modules/.bin/minify 7 | 8 | build: components $(find lib/*.js) 9 | @${COMPONENT} build --dev 10 | 11 | components: node_modules component.json 12 | @${COMPONENT} install --dev 13 | 14 | clean: 15 | rm -fr build components dist 16 | 17 | node_modules: 18 | npm install 19 | 20 | minify: build 21 | ${MINIFY} build/build.js build/build.min.js 22 | 23 | karma: build 24 | ${KARMA} start test/karma.conf.js --no-auto-watch --single-run 25 | 26 | lint: node_modules 27 | ${JSHINT} lib/*.js 28 | 29 | test: lint build 30 | ${MOCHA} /test/runner.html 31 | 32 | ci: test 33 | 34 | patch: 35 | ${BUMP} patch 36 | 37 | minor: 38 | ${BUMP} minor 39 | 40 | release: test 41 | VERSION=`node -p "require('./component.json').version"` && \ 42 | git changelog --tag $$VERSION && \ 43 | git release $$VERSION 44 | 45 | .PHONY: clean test karma patch release 46 | -------------------------------------------------------------------------------- /test/runner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mocha 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /test/specs/owners.js: -------------------------------------------------------------------------------- 1 | describe('owners', function () { 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | var View, parent, grandchild, child; 5 | 6 | beforeEach(function () { 7 | View = ripple('
'); 8 | parent = new View(); 9 | child = new View({ 10 | owner: parent 11 | }); 12 | grandchild = new View({ 13 | owner: child 14 | }); 15 | }); 16 | 17 | it('should be able to have an owner', function () { 18 | assert(child.owner === parent); 19 | assert(grandchild.owner == child); 20 | }); 21 | 22 | it('should set the root', function () { 23 | assert(grandchild.root == parent); 24 | assert(child.root == parent); 25 | }); 26 | 27 | it('should store the children', function () { 28 | assert(parent.children[0] === child); 29 | assert(child.children[0] === grandchild); 30 | }); 31 | 32 | it('should remove when a child is destroyed', function () { 33 | child.destroy(); 34 | assert(parent.children.length === 0); 35 | }); 36 | 37 | it('should remove children when destroyed', function () { 38 | parent.destroy(); 39 | assert(parent.children.length === 0); 40 | assert(child.children.length === 0); 41 | }); 42 | 43 | }); -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 0.4.0 / 2014-04-29 2 | ================== 3 | 4 | * Allow watching for all changes with `view.watch(callback)` 5 | * Using an updated/simplified path observer - `0.2.0` 6 | * Added `view.create` method for creating child views with the same bindings 7 | * Moved `render` into the view so it can be modified by plugins. eg. virtual dom 8 | 9 | 0.3.5 / 2014-04-23 10 | ================== 11 | 12 | * Added make targets for releases 13 | 14 | 0.3.4 / 2014-04-23 15 | ================== 16 | 17 | * Fixed before/after helper methods 18 | * Updated examples README.md 19 | * Updated clock example 20 | 21 | 0.3.3 / 2014-04-19 22 | ================== 23 | 24 | * Merge pull request #11 from olivoil/master 25 | * Continue walking DOM nodes after child binding 26 | * Merge pull request #6 from Nami-Doc/patch-1 27 | * Fix small typo 28 | * Added docs on composing views 29 | * Updated docs 30 | 31 | 0.3.2 / 2014-04-16 32 | ================== 33 | 34 | * Using raf-queue which is a simpler version of fastdom 35 | * Made requirable by browserify 36 | * Added docs and examples 37 | 38 | 0.3.0 / 2014-04-13 39 | ================== 40 | 41 | * Allow custom templates per view 42 | 43 | 0.2.3 / 2014-04-13 44 | ================== 45 | 46 | * Passing el and view through to directives to reduce use of confusing `this` 47 | -------------------------------------------------------------------------------- /lib/text-binding.js: -------------------------------------------------------------------------------- 1 | var raf = require('raf-queue'); 2 | 3 | function TextBinding(view, node) { 4 | this.update = this.update.bind(this); 5 | this.view = view; 6 | this.text = node.data; 7 | this.node = node; 8 | this.props = view.props(this.text); 9 | this.render = this.render.bind(this); 10 | if(this.props.length) { 11 | this.bind(); 12 | } 13 | } 14 | 15 | TextBinding.prototype.bind = function(){ 16 | var view = this.view; 17 | var update = this.update; 18 | 19 | this.props.forEach(function(prop){ 20 | view.watch(prop, update); 21 | }); 22 | 23 | this.render(); 24 | }; 25 | 26 | TextBinding.prototype.unbind = function(){ 27 | var view = this.view; 28 | var update = this.update; 29 | 30 | this.props.forEach(function(prop){ 31 | view.unwatch(prop, update); 32 | }); 33 | 34 | if(this.job) { 35 | raf.cancel(this.job); 36 | } 37 | }; 38 | 39 | TextBinding.prototype.render = function(){ 40 | var node = this.node; 41 | var val = this.view.interpolate(this.text); 42 | 43 | if(val == null) { 44 | this.node.data = ''; 45 | } 46 | else if(val instanceof Element) { 47 | node.parentNode.replaceChild(val, node); 48 | this.node = val; 49 | } 50 | else { 51 | var newNode = document.createTextNode(val); 52 | node.parentNode.replaceChild(newNode, node); 53 | this.node = newNode; 54 | } 55 | }; 56 | 57 | TextBinding.prototype.update = function(){ 58 | if(this.job) { 59 | raf.cancel(this.job); 60 | } 61 | this.job = raf(this.render, this); 62 | }; 63 | 64 | module.exports = TextBinding; 65 | -------------------------------------------------------------------------------- /test/specs/attribute-interpolation.js: -------------------------------------------------------------------------------- 1 | describe('attribute interpolation', function () { 2 | var assert = require('assert'); 3 | var ripple = require('ripple'); 4 | var frame = require('raf-queue'); 5 | var View, view, el; 6 | 7 | beforeEach(function () { 8 | View = ripple(''); 9 | view = new View({ 10 | data: { 11 | foo: 'bar', 12 | hidden: true 13 | } 14 | }); 15 | el = view.el; 16 | view.appendTo(document.body); 17 | }); 18 | 19 | afterEach(function () { 20 | view.destroy(); 21 | }); 22 | 23 | it('should interpolate attributes', function(done){ 24 | frame.defer(function(){ 25 | assert(el.id === 'bar'); 26 | done(); 27 | }); 28 | }) 29 | 30 | it('should render initial values immediately', function () { 31 | assert(el.id === 'bar'); 32 | }); 33 | 34 | it('should not render undefined', function () { 35 | var View = ripple('
'); 36 | var view = new View(); 37 | assert(view.el.id === ""); 38 | }); 39 | 40 | it('should update interpolated attributes', function(done){ 41 | view.set('foo', 'baz'); 42 | frame.defer(function(){ 43 | assert(el.id === 'baz'); 44 | done(); 45 | }); 46 | }) 47 | 48 | it('should toggle boolean attributes', function(done){ 49 | frame.defer(function(){ 50 | assert(view.el.hasAttribute('hidden')); 51 | view.set('hidden', false); 52 | frame.defer(function(){ 53 | assert(view.el.hasAttribute('hidden') === false); 54 | done(); 55 | }); 56 | }); 57 | }) 58 | 59 | }); -------------------------------------------------------------------------------- /test/specs/interpolation.js: -------------------------------------------------------------------------------- 1 | describe('interpolation', function(){ 2 | var assert = require('assert'); 3 | var ripple = require('ripple'); 4 | var frame = require('raf-queue'); 5 | var View, view; 6 | 7 | beforeEach(function () { 8 | View = ripple('
'); 9 | View.filter('caps', function(val){ 10 | return val.toUpperCase(); 11 | }); 12 | view = new View(); 13 | }); 14 | 15 | it('should add filters', function () { 16 | view.set('foo', 'bar'); 17 | assert( view.interpolate('{{foo | caps}}') === "BAR"); 18 | }); 19 | 20 | it('should add filters as objects', function () { 21 | var View = ripple('
'); 22 | View.filter({ 23 | caps: function(val){ 24 | return val.toUpperCase(); 25 | }, 26 | lower: function(val){ 27 | return val.toLowerCase(); 28 | } 29 | }); 30 | view = new View(); 31 | view.set('foo', 'bar'); 32 | assert( view.interpolate('{{foo | caps | lower}}') === "bar"); 33 | }); 34 | 35 | it('should return the raw value for simple expressions', function(){ 36 | view.set('names', ['Fred']); 37 | var val = view.interpolate('{{names}}'); 38 | assert(Array.isArray(val)); 39 | assert(val[0] === 'Fred'); 40 | }); 41 | 42 | it('should interpolate properties with a $', function () { 43 | view.set('$value', 'Fred'); 44 | var val = view.interpolate('{{$value}}'); 45 | assert(val === 'Fred'); 46 | }); 47 | 48 | it('should not interpolate properties named this', function () { 49 | view.set('this', 'Fred'); 50 | var val = view.interpolate('{{this}}'); 51 | assert(val === view); 52 | }); 53 | 54 | }); -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | var walk = require('dom-walk'); 2 | var each = require('each'); 3 | var attrs = require('attributes'); 4 | var domify = require('domify'); 5 | var TextBinding = require('./text-binding'); 6 | var AttrBinding = require('./attr-binding'); 7 | var ChildBinding = require('./child-binding'); 8 | var Directive = require('./directive'); 9 | 10 | module.exports = function(options) { 11 | var view = options.view; 12 | var bindings = options.bindings; 13 | var el = domify(options.template); 14 | var fragment = document.createDocumentFragment(); 15 | fragment.appendChild(el); 16 | 17 | var activeBindings = []; 18 | 19 | // Walk down the newly created view element 20 | // and bind everything to the model 21 | walk(el, function(node, next){ 22 | if(node.nodeType === 3) { 23 | activeBindings.push(new TextBinding(view, node)); 24 | } 25 | else if(node.nodeType === 1) { 26 | var View = bindings.component(node); 27 | if(View) { 28 | activeBindings.push(new ChildBinding(view, node, View)); 29 | return next(); 30 | } 31 | each(attrs(node), function(attr){ 32 | var binding = bindings.directive(attr); 33 | if(binding) { 34 | activeBindings.push(new Directive(view, node, attr, binding)); 35 | } 36 | else { 37 | activeBindings.push(new AttrBinding(view, node, attr)); 38 | } 39 | }); 40 | } 41 | next(); 42 | }); 43 | 44 | view.once('destroying', function(){ 45 | while(activeBindings.length) { 46 | activeBindings.shift().unbind(); 47 | } 48 | }); 49 | 50 | view.activeBindings = activeBindings; 51 | 52 | return fragment.firstChild; 53 | }; 54 | -------------------------------------------------------------------------------- /lib/bindings.js: -------------------------------------------------------------------------------- 1 | var Interpolator = require('interpolate'); 2 | 3 | /** 4 | * The compiler will take a set of views, an element and 5 | * a scope and process each node going down the tree. Whenever 6 | * it finds a node matching a directive it will process it. 7 | */ 8 | function Bindings() { 9 | this.components = {}; 10 | this.directives = {}; 11 | this.interpolator = new Interpolator(); 12 | } 13 | 14 | /** 15 | * Add a component binding. This will be rendered as a separate 16 | * view and have it's own scope. 17 | * 18 | * @param {String|Regex} matches String or regex to match an element name 19 | * @param {Function} View 20 | * @param {Object} options 21 | */ 22 | Bindings.prototype.component = function(name, fn) { 23 | if(!fn) { 24 | return this.components[name.nodeName.toLowerCase()]; 25 | } 26 | this.components[name.toLowerCase()] = fn; 27 | return this; 28 | }; 29 | 30 | /** 31 | * Add an attribute binding. Whenever this attribute is matched 32 | * in the DOM the function will be code with the current view 33 | * and the element. 34 | * 35 | * @param {String|Regex} matches String or regex to match an attribute name 36 | * @param {Function} process 37 | * @param {Object} options 38 | */ 39 | Bindings.prototype.directive = function(attr, fn) { 40 | if(!fn) { 41 | return this.directives[attr]; 42 | } 43 | this.directives[attr] = fn; 44 | return this; 45 | }; 46 | 47 | /** 48 | * Add an interpolation filter 49 | * 50 | * @param {String} name 51 | * @param {Function} fn 52 | * 53 | * @return {Bindings} 54 | */ 55 | Bindings.prototype.filter = function(name, fn) { 56 | if(!fn) { 57 | return this.interpolator.filters[name]; 58 | } 59 | this.interpolator.filter(name, fn); 60 | return this; 61 | }; 62 | 63 | module.exports = Bindings; -------------------------------------------------------------------------------- /lib/directive.js: -------------------------------------------------------------------------------- 1 | var raf = require('raf-queue'); 2 | 3 | /** 4 | * Creates a new directive using a binding object. 5 | * 6 | * @param {View} view 7 | * @param {Element} node 8 | * @param {String} attr 9 | * @param {Object} binding 10 | */ 11 | function Directive(view, node, attr, binding) { 12 | this.queue = this.queue.bind(this); 13 | this.view = view; 14 | if(typeof binding === 'function') { 15 | this.binding = { update: binding }; 16 | } 17 | else { 18 | this.binding = binding; 19 | } 20 | this.text = node.getAttribute(attr); 21 | this.node = node; 22 | this.attr = attr; 23 | this.props = view.props(this.text); 24 | this.bind(); 25 | } 26 | 27 | /** 28 | * Start watching the view for changes 29 | */ 30 | Directive.prototype.bind = function(){ 31 | var view = this.view; 32 | var queue = this.queue; 33 | 34 | if(this.binding.bind) { 35 | this.binding.bind.call(this, this.node, this.view); 36 | } 37 | 38 | this.props.forEach(function(prop){ 39 | view.watch(prop, queue); 40 | }); 41 | 42 | this.update(); 43 | }; 44 | 45 | /** 46 | * Stop watching the view for changes 47 | */ 48 | Directive.prototype.unbind = function(){ 49 | var view = this.view; 50 | var queue = this.queue; 51 | 52 | this.props.forEach(function(prop){ 53 | view.unwatch(prop, queue); 54 | }); 55 | 56 | if(this.job) { 57 | raf.cancel(this.job); 58 | } 59 | 60 | if(this.binding.unbind) { 61 | this.binding.unbind.call(this, this.node, this.view); 62 | } 63 | }; 64 | 65 | /** 66 | * Update the attribute. 67 | */ 68 | Directive.prototype.update = function(){ 69 | var value = this.view.interpolate(this.text); 70 | this.binding.update.call(this, value, this.node, this.view); 71 | }; 72 | 73 | /** 74 | * Queue an update 75 | */ 76 | Directive.prototype.queue = function(){ 77 | if(this.job) { 78 | raf.cancel(this.job); 79 | } 80 | this.job = raf(this.update, this); 81 | }; 82 | 83 | module.exports = Directive; -------------------------------------------------------------------------------- /test/specs/directives.js: -------------------------------------------------------------------------------- 1 | describe('directives', function () { 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | 5 | it('should match directives with a string', function(done){ 6 | var View = ripple('
'); 7 | View.directive('data-test', { 8 | update: function(value, el, view){ 9 | assert(value === 'foo'); 10 | assert(el.hasAttribute('data-test')); 11 | assert(view instanceof View); 12 | done(); 13 | } 14 | }); 15 | var view = new View(); 16 | view.appendTo('body'); 17 | view.destroy(); 18 | }); 19 | 20 | it('should use just an update method', function(done){ 21 | var View = ripple('
'); 22 | View.directive('data-test', function(value){ 23 | assert(value === 'foo'); 24 | done(); 25 | }); 26 | var view = new View(); 27 | }); 28 | 29 | it('should pass in the element and the view', function(done){ 30 | var View = ripple('
'); 31 | View.directive('data-test', function(value, el, view) { 32 | assert(value === 'foo'); 33 | assert(el.hasAttribute('data-test')); 34 | assert(view instanceof View); 35 | done(); 36 | }); 37 | var view = new View(); 38 | }); 39 | 40 | it('should update with interpolated values', function(done){ 41 | var View = ripple('
'); 42 | View.directive('data-test', { 43 | update: function(value) { 44 | assert(value === 'bar'); 45 | done(); 46 | } 47 | }); 48 | var view = new View({ 49 | data: { 50 | foo: 'bar' 51 | } 52 | }); 53 | }); 54 | 55 | it('should call the binding in the context of the directive', function (done) { 56 | var View = ripple('
'); 57 | View.directive('data-test', function(value){ 58 | assert(this.constructor.name === 'Directive'); 59 | done(); 60 | }); 61 | var view = new View(); 62 | }); 63 | 64 | }); -------------------------------------------------------------------------------- /test/specs/destroy.js: -------------------------------------------------------------------------------- 1 | describe('destroying', function () { 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | var frame = require('raf-queue'); 5 | var View; 6 | 7 | beforeEach(function () { 8 | View = ripple('
{{text}}
'); 9 | }); 10 | 11 | it('should remove all event listeners', function (done) { 12 | var view = new View(); 13 | view.on('foo', function(){ 14 | done(false); 15 | }); 16 | view.destroy(); 17 | view.emit('foo'); 18 | done(); 19 | }); 20 | 21 | it('should remove all change listeners', function (done) { 22 | var view = new View({ 23 | foo: 'bar' 24 | }); 25 | view.watch('foo', function(){ 26 | done(false); 27 | }); 28 | view.destroy(); 29 | view.set('foo', 'baz'); 30 | done(); 31 | }); 32 | 33 | it('should unmount when destroyed', function (done) { 34 | View.on('unmounted', function(){ 35 | done(); 36 | }); 37 | view = new View(); 38 | view.appendTo(document.body); 39 | view.destroy(); 40 | }); 41 | 42 | it('should unbind all bindings', function () { 43 | view = new View(); 44 | view.appendTo(document.body); 45 | assert(view.activeBindings.length !== 0); 46 | view.destroy(); 47 | assert(view.activeBindings.length === 0); 48 | }); 49 | 50 | it('should not run text changes after it has been destroyed', function (done) { 51 | view = new View(); 52 | var el = view.el; 53 | view.appendTo(document.body); 54 | view.set('text', 'foo'); 55 | view.destroy(); 56 | frame.defer(function(){ 57 | assert(el.innerHTML === ''); 58 | done(); 59 | }); 60 | }); 61 | 62 | it('should not run attribute changes after it has been destroyed', function (done) { 63 | var View = ripple('
'); 64 | view = new View(); 65 | var el = view.el; 66 | view.appendTo(document.body); 67 | view.set('text', 'foo'); 68 | view.destroy(); 69 | frame.defer(function(){ 70 | assert(el.id === ''); 71 | done(); 72 | }); 73 | }); 74 | 75 | }); -------------------------------------------------------------------------------- /lib/attr-binding.js: -------------------------------------------------------------------------------- 1 | var isBoolean = require('is-boolean-attribute'); 2 | var raf = require('raf-queue'); 3 | 4 | /** 5 | * Creates a new attribute text binding for a view. 6 | * If the view attribute contains interpolation, the 7 | * attribute will be automatically updated whenever the 8 | * result of the expression changes. 9 | * 10 | * Updating will be called once per tick. So if there 11 | * are multiple changes to the view in a single tick, 12 | * this will only touch the DOM once. 13 | * 14 | * @param {View} view 15 | * @param {Element} node 16 | * @param {String} attr 17 | */ 18 | function AttrBinding(view, node, attr) { 19 | this.update = this.update.bind(this); 20 | this.view = view; 21 | this.text = node.getAttribute(attr); 22 | this.node = node; 23 | this.attr = attr; 24 | this.props = view.props(this.text); 25 | this.bind(); 26 | } 27 | 28 | /** 29 | * Start watching the view for changes 30 | */ 31 | AttrBinding.prototype.bind = function(){ 32 | if(!this.props.length) return; 33 | var view = this.view; 34 | var update = this.update; 35 | 36 | this.props.forEach(function(prop){ 37 | view.watch(prop, update); 38 | }); 39 | 40 | this.render(); 41 | }; 42 | 43 | /** 44 | * Stop watching the view for changes 45 | */ 46 | AttrBinding.prototype.unbind = function(){ 47 | if(!this.props.length) return; 48 | var view = this.view; 49 | var update = this.update; 50 | 51 | this.props.forEach(function(prop){ 52 | view.unwatch(prop, update); 53 | }); 54 | 55 | if(this.job) { 56 | raf.cancel(this.job); 57 | } 58 | }; 59 | 60 | /** 61 | * Update the attribute 62 | * 63 | * @return {[type]} 64 | */ 65 | AttrBinding.prototype.render = function(){ 66 | var val = this.view.interpolate(this.text); 67 | if(val == null) val = ''; 68 | if(isBoolean(this.attr) && !val) { 69 | this.node.removeAttribute(this.attr); 70 | } 71 | else { 72 | this.node.setAttribute(this.attr, val); 73 | } 74 | }; 75 | 76 | /** 77 | * Update the attribute. 78 | */ 79 | AttrBinding.prototype.update = function(){ 80 | if(this.job) { 81 | raf.cancel(this.job); 82 | } 83 | this.job = raf(this.render, this); 84 | }; 85 | 86 | module.exports = AttrBinding; -------------------------------------------------------------------------------- /test/specs/model.js: -------------------------------------------------------------------------------- 1 | describe('model', function(){ 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | var View, view; 5 | 6 | beforeEach(function(){ 7 | View = ripple('
'); 8 | }); 9 | 10 | it('should set properties in the constructor', function(){ 11 | view = new View({ 12 | data: {'foo' : 'bar' } 13 | }); 14 | assert( view.get('foo') === 'bar' ); 15 | assert( view.data.foo === 'bar' ); 16 | }) 17 | 18 | it('should work with no properties', function(){ 19 | view = new View(); 20 | view.set('foo', 'bar'); 21 | assert( view.get('foo') === 'bar' ); 22 | assert( view.data.foo === 'bar' ); 23 | }) 24 | 25 | it('should set key and value', function(){ 26 | view = new View(); 27 | view.set('foo', 'bar'); 28 | assert( view.get('foo') === 'bar' ); 29 | }); 30 | 31 | it('should set key and value with an object', function(){ 32 | view = new View(); 33 | view.set({ 'foo' : 'bar' }); 34 | assert( view.get('foo') === 'bar' ); 35 | assert( view.data.foo === 'bar' ); 36 | }); 37 | 38 | it('should set and object with a falsy 2nd param', function(){ 39 | view = new View(); 40 | view.set({ 'foo' : 'bar' }, undefined); 41 | assert( view.get('foo') === 'bar' ); 42 | }); 43 | 44 | it('should emit change events', function(){ 45 | var match = false; 46 | view = new View(); 47 | view.watch('foo', function(){ 48 | match = true; 49 | }); 50 | view.set('foo', 'bar'); 51 | assert(match === true); 52 | }); 53 | 54 | it('should set properties in constructor', function(){ 55 | var obj = new View({ 56 | data: { 57 | 'foo':'bar' 58 | } 59 | }); 60 | assert( obj.get('foo') === 'bar' ); 61 | }); 62 | 63 | it('should set nested properties', function(){ 64 | view = new View(); 65 | view.set('foo.bar', 'baz'); 66 | assert( view.get('foo').bar === 'baz' ); 67 | }); 68 | 69 | it('should get nested properties', function(){ 70 | view = new View(); 71 | view.set('foo', { 72 | bar: 'baz' 73 | }); 74 | assert( view.get('foo.bar') === 'baz' ); 75 | }); 76 | 77 | it('should return undefined for missing nested properties', function(){ 78 | view = new View(); 79 | view.set('razz.tazz', 'bar'); 80 | assert( view.get('foo') === undefined ); 81 | assert( view.get('foo.bar') === undefined ); 82 | assert( view.get('razz.tazz.jazz') === undefined ); 83 | }) 84 | 85 | 86 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ripple.js 2 | 3 | [![Build Status](https://travis-ci.org/ripplejs/ripple.png?branch=master)](https://travis-ci.org/ripplejs/ripple) 4 | 5 | A tiny foundation for building reactive views with plugins. It aims to have a similar API to [Reactive](https://github.com/component/reactive), but allow composition of views, like [React](http://facebook.github.io/react/). 6 | The major difference for other view libraries is that there are no globals used at all. Each view has its own set of bindings and plugins. This 7 | makes composition of views really easy. 8 | 9 | ```js 10 | var Person = ripple('
{{name}}
') 11 | .use(events) 12 | .use(each) 13 | .use(dispatch); 14 | 15 | var person = new Person({ 16 | data: { 17 | name: 'Tom' 18 | } 19 | }); 20 | 21 | person.appendTo(document.body); 22 | ``` 23 | 24 | ## Install 25 | 26 | ```js 27 | component install ripplejs/ripple 28 | ``` 29 | 30 | ## Browser Support 31 | 32 | Supports real browsers and IE9+. 33 | 34 | ## Documentation 35 | 36 | [Documentation is on the wiki](https://github.com/ripplejs/ripple/wiki). 37 | 38 | ## Examples 39 | 40 | * [Clock](http://jsfiddle.net/chrisbuttery/QnHPj/3/) 41 | * [Counter](http://jsfiddle.net/anthonyshort/ybq9Q/light/) 42 | * [Like Button](http://jsfiddle.net/anthonyshort/ZA2gQ/6/light/) 43 | * [Markdown Editor](http://jsfiddle.net/anthonyshort/QGK3r/light/) 44 | * [Iteration](http://jsfiddle.net/chrisbuttery/4j5ZD/1/light/) 45 | 46 | See more examples at [ripplejs/examples](https://github.com/ripplejs/examples) 47 | 48 | ## Plugins 49 | 50 | * [events](https://github.com/ripplejs/events) - add event listeners to the DOM and call methods on the view 51 | * [each](https://github.com/ripplejs/each) - Basic iteration using the `each` directive. 52 | * [bind-methods](https://github.com/ripplejs/bind-methods) - Bind all methods on the prototype to the view 53 | * [markdown](https://github.com/ripplejs/markdown) - Adds a directive to render markdown using Marked. 54 | * [extend](https://github.com/ripplejs/extend) - Makes adding methods to the view prototype a little cleaner 55 | * [intervals](https://github.com/ripplejs/intervals) - Easily add and remove intervals 56 | * [computed](https://github.com/ripplejs/computed) - Add computed properties. 57 | * [refs](https://github.com/ripplejs/refs) - Easily reference elements within the template 58 | * [dispatch](https://github.com/ripplejs/dispatch) - Dispatch custom DOM events up the tree 59 | 60 | [View and add them on the wiki](https://github.com/ripplejs/ripple/wiki/Plugins) 61 | 62 | 63 | ## License 64 | 65 | MIT -------------------------------------------------------------------------------- /test/specs/mounting.js: -------------------------------------------------------------------------------- 1 | describe('mounting', function () { 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | var View; 5 | 6 | beforeEach(function () { 7 | View = ripple('
'); 8 | }); 9 | 10 | it('should mount to an element', function(done){ 11 | View.on('mounted', function(){ 12 | assert(document.body.contains(view.el)); 13 | done(); 14 | }); 15 | view = new View(); 16 | view.appendTo(document.body); 17 | view.remove(); 18 | }) 19 | 20 | it('should mount using a selector', function (done) { 21 | View.on('mounted', function(){ 22 | assert(document.body.contains(view.el)); 23 | done(); 24 | }); 25 | view = new View(); 26 | view.appendTo('body'); 27 | view.remove(); 28 | }); 29 | 30 | it('should unmount', function(){ 31 | view = new View(); 32 | view.appendTo(document.body); 33 | var el = view.el; 34 | view.remove(); 35 | assert(document.body.contains(el) === false); 36 | }) 37 | 38 | it('should not unmount when mounting another element', function () { 39 | var test = document.createElement('div'); 40 | document.body.appendChild(test); 41 | var count = 0; 42 | View.on('unmounted', function(){ 43 | count++; 44 | }); 45 | view = new View(); 46 | view.appendTo('body'); 47 | view.appendTo(test); 48 | assert(count === 0); 49 | view.remove(); 50 | }); 51 | 52 | it('should replace an element', function(){ 53 | var test = document.createElement('div'); 54 | document.body.appendChild(test); 55 | view = new View(); 56 | view.replace(test); 57 | assert( test.parentNode == null ); 58 | view.remove(); 59 | }); 60 | 61 | it('should insert before an element', function(){ 62 | var test = document.createElement('div'); 63 | document.body.appendChild(test); 64 | view = new View(); 65 | view.before(test); 66 | assert( test.previousSibling === view.el ); 67 | view.remove(); 68 | }); 69 | 70 | it('should insert after an element', function(){ 71 | var test = document.createElement('div'); 72 | test.classList.add('parentEl'); 73 | document.body.appendChild(test); 74 | view = new View(); 75 | view.after(".parentEl"); 76 | assert( test.nextSibling === view.el ); 77 | view.remove(); 78 | }); 79 | 80 | it('should not unmount if not mounted', function () { 81 | var count = 0; 82 | View.on('unmounted', function(){ 83 | count += 1; 84 | }); 85 | view = new View(); 86 | view 87 | .appendTo('body') 88 | .remove() 89 | .remove(); 90 | assert(count === 1); 91 | }); 92 | }); -------------------------------------------------------------------------------- /lib/model.js: -------------------------------------------------------------------------------- 1 | var observer = require('path-observer'); 2 | var emitter = require('emitter'); 3 | 4 | module.exports = function(){ 5 | 6 | /** 7 | * Model. 8 | * 9 | * Watch an objects properties for changes. 10 | * 11 | * Properties must be set using the `set` method for 12 | * changes to fire events. 13 | * 14 | * @param {Object} 15 | */ 16 | function Model(props){ 17 | if(!(this instanceof Model)) return new Model(props); 18 | this.props = props || {}; 19 | this.observer = observer(this.props); 20 | Model.emit('construct', this); 21 | } 22 | 23 | /** 24 | * Mixins 25 | */ 26 | emitter(Model); 27 | 28 | /** 29 | * Use a plugin 30 | * 31 | * @return {Model} 32 | */ 33 | Model.use = function(fn, options){ 34 | fn(this, options); 35 | return this; 36 | }; 37 | 38 | /** 39 | * Add a function to fire whenever a keypath changes. 40 | * 41 | * @param {String} key 42 | * @param {Function} fn Function to call on event 43 | * 44 | * @return {Model} 45 | */ 46 | Model.prototype.watch = function(key, callback) { 47 | if(arguments.length === 1) { 48 | callback = key; 49 | this.observer.on('change', callback); 50 | } 51 | else { 52 | this.observer(key).on('change', callback); 53 | } 54 | return this; 55 | }; 56 | 57 | /** 58 | * Stop watching a property for changes 59 | * 60 | * @param {String} key 61 | * @param {Function} fn 62 | * 63 | * @return {Model} 64 | */ 65 | Model.prototype.unwatch = function(key, callback) { 66 | if(arguments.length === 1) { 67 | callback = key; 68 | this.observer.off('change', callback); 69 | } 70 | else { 71 | this.observer(key).off('change', callback); 72 | } 73 | return this; 74 | }; 75 | 76 | /** 77 | * Set a property using a keypath 78 | * 79 | * @param {String} key eg. 'foo.bar' 80 | * @param {Mixed} val 81 | */ 82 | Model.prototype.set = function(key, val) { 83 | this.observer(key).set(val); 84 | return this; 85 | }; 86 | 87 | /** 88 | * Get an attribute using a keypath. If an array 89 | * of keys is passed in an object is returned with 90 | * those keys 91 | * 92 | * @param {String|Array} key 93 | * 94 | * @api public 95 | * @return {Mixed} 96 | */ 97 | Model.prototype.get = function(keypath) { 98 | return this.observer(keypath).get(); 99 | }; 100 | 101 | /** 102 | * Destroy all observers 103 | * 104 | * @return {Model} 105 | */ 106 | Model.prototype.destroy = function(){ 107 | this.observer.dispose(); 108 | return this; 109 | }; 110 | 111 | return Model; 112 | }; -------------------------------------------------------------------------------- /lib/child-binding.js: -------------------------------------------------------------------------------- 1 | var attrs = require('attributes'); 2 | var each = require('each'); 3 | var unique = require('uniq'); 4 | var raf = require('raf-queue'); 5 | 6 | /** 7 | * Creates a new sub-view at a node and binds 8 | * it to the parent 9 | * 10 | * @param {View} view 11 | * @param {Element} node 12 | * @param {Function} View 13 | */ 14 | function ChildBinding(view, node, View) { 15 | this.update = this.update.bind(this); 16 | this.view = view; 17 | this.attrs = attrs(node); 18 | this.props = this.getProps(); 19 | var data = this.values(); 20 | data.yield = node.innerHTML; 21 | this.child = new View({ 22 | owner: view, 23 | data: data 24 | }); 25 | this.child.replace(node); 26 | this.child.on('destroyed', this.unbind.bind(this)); 27 | this.node = this.child.el; 28 | this.bind(); 29 | } 30 | 31 | /** 32 | * Get all of the properties used in all of the attributes 33 | * 34 | * @return {Array} 35 | */ 36 | ChildBinding.prototype.getProps = function(){ 37 | var ret = []; 38 | var view = this.view; 39 | each(this.attrs, function(name, value){ 40 | ret = ret.concat(view.props(value)); 41 | }); 42 | return unique(ret); 43 | }; 44 | 45 | /** 46 | * Bind to changes on the view. Whenever a property 47 | * changes we'll update the child with the new values. 48 | */ 49 | ChildBinding.prototype.bind = function(){ 50 | var self = this; 51 | var view = this.view; 52 | 53 | this.props.forEach(function(prop){ 54 | view.watch(prop, self.update); 55 | }); 56 | 57 | this.send(); 58 | }; 59 | 60 | /** 61 | * Get all the data from the node 62 | * 63 | * @return {Object} 64 | */ 65 | ChildBinding.prototype.values = function(){ 66 | var view = this.view; 67 | var ret = {}; 68 | each(this.attrs, function(name, value){ 69 | ret[name] = view.interpolate(value); 70 | }); 71 | return ret; 72 | }; 73 | 74 | /** 75 | * Send the data to the child 76 | */ 77 | ChildBinding.prototype.send = function(){ 78 | this.child.set(this.values()); 79 | }; 80 | 81 | /** 82 | * Unbind this view from the parent 83 | */ 84 | ChildBinding.prototype.unbind = function(){ 85 | var view = this.view; 86 | var update = this.update; 87 | 88 | this.props.forEach(function(prop){ 89 | view.unwatch(prop, update); 90 | }); 91 | 92 | if(this.job) { 93 | raf.cancel(this.job); 94 | } 95 | }; 96 | 97 | /** 98 | * Update the child view will updated values from 99 | * the parent. This will batch changes together 100 | * and only fire once per tick. 101 | */ 102 | ChildBinding.prototype.update = function(){ 103 | if(this.job) { 104 | raf.cancel(this.job); 105 | } 106 | this.job = raf(this.send, this); 107 | }; 108 | 109 | module.exports = ChildBinding; 110 | -------------------------------------------------------------------------------- /test/specs/lifecycle.js: -------------------------------------------------------------------------------- 1 | describe('lifecycle events', function () { 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | 5 | beforeEach(function () { 6 | View = ripple('
'); 7 | }); 8 | 9 | it('should fire a construct event', function (done) { 10 | View.on('construct', function(){ 11 | done(); 12 | }); 13 | new View(); 14 | }); 15 | 16 | it('should have a construct method', function (done) { 17 | View.construct(function(options){ 18 | assert(options.foo === 'bar'); 19 | assert( this instanceof View ); 20 | done(); 21 | }); 22 | new View({ 23 | foo: 'bar' 24 | }); 25 | }); 26 | 27 | it('should fire a created event', function (done) { 28 | View.on('created', function(){ 29 | done(); 30 | }); 31 | new View(); 32 | }); 33 | 34 | it('should have a created method', function (done) { 35 | View.created(function(){ 36 | assert( this instanceof View ); 37 | done(); 38 | }); 39 | new View(); 40 | }); 41 | 42 | it('should fire a ready event', function (done) { 43 | View.on('ready', function(){ 44 | done(); 45 | }); 46 | new View(); 47 | }); 48 | 49 | it('should have a ready method', function (done) { 50 | View.ready(function(){ 51 | assert( this instanceof View ); 52 | done(); 53 | }); 54 | new View(); 55 | }); 56 | 57 | it('should fire a mounted event', function (done) { 58 | View.on('mounted', function(){ 59 | done(); 60 | }); 61 | new View() 62 | .appendTo(document.body) 63 | .remove(); 64 | }); 65 | 66 | it('should have a mounted method', function (done) { 67 | View.mounted(function(){ 68 | assert( this instanceof View ); 69 | done(); 70 | }); 71 | new View() 72 | .appendTo(document.body) 73 | .remove(); 74 | }); 75 | 76 | it('should fire an unmounted event', function (done) { 77 | View.on('unmounted', function(){ 78 | done(); 79 | }); 80 | new View() 81 | .appendTo(document.body) 82 | .remove(); 83 | }); 84 | 85 | it('should have an unmounted method', function (done) { 86 | View.unmounted(function(){ 87 | assert( this instanceof View ); 88 | done(); 89 | }); 90 | new View() 91 | .appendTo(document.body) 92 | .remove(); 93 | }); 94 | 95 | it('should fire a destroy event', function (done) { 96 | View.on('destroyed', function(){ 97 | done(); 98 | }); 99 | new View() 100 | .destroy() 101 | }); 102 | 103 | it('should have an destroy method', function (done) { 104 | View.destroyed(function(){ 105 | assert( this instanceof View ); 106 | done(); 107 | }); 108 | new View() 109 | .destroy(); 110 | }); 111 | 112 | }); 113 | -------------------------------------------------------------------------------- /test/specs/composing.js: -------------------------------------------------------------------------------- 1 | describe('composing views', function () { 2 | 3 | var assert = require('assert'); 4 | var ripple = require('ripple'); 5 | var frame = require('raf-queue'); 6 | var child, view; 7 | 8 | beforeEach(function () { 9 | Child = ripple('
'); 10 | Parent = ripple(''); 11 | Parent.compose('child', Child); 12 | view = new Parent({ 13 | data: { 14 | color: 'red' 15 | } 16 | }); 17 | view.appendTo(document.body); 18 | }); 19 | 20 | afterEach(function () { 21 | view.remove(); 22 | }); 23 | 24 | it('should not traverse composed view elements', function () { 25 | Child = ripple('
'); 26 | Parent = ripple('
{{foo}}
'); 27 | Parent.compose('child', Child); 28 | var parent = new Parent(); 29 | parent.appendTo(document.body); 30 | parent.remove(); 31 | }); 32 | 33 | it('should pass data to the component', function () { 34 | assert(view.el.id === "test", view.el.id); 35 | }); 36 | 37 | it('should pass data as an expression to the component', function () { 38 | assert(view.el.getAttribute('color') === "red"); 39 | }); 40 | 41 | it('should update data passed to the component', function (done) { 42 | view.set('color', 'blue'); 43 | frame.defer(function(){ 44 | assert(view.el.getAttribute('color') === "blue"); 45 | done(); 46 | }); 47 | }); 48 | 49 | it('should use custom content', function (done) { 50 | var Child = ripple('
{{yield}}
'); 51 | var Parent = ripple('foo'); 52 | Parent.compose('child', Child); 53 | var view = new Parent(); 54 | view.appendTo(document.body); 55 | frame.defer(function(){ 56 | assert(view.el.outerHTML === '
foo
'); 57 | view.remove(); 58 | done(); 59 | }); 60 | }); 61 | 62 | it('should allow a component as the root element', function (done) { 63 | Child = ripple('
child
'); 64 | Parent = ripple(''); 65 | Parent.compose('child', Child); 66 | view = new Parent(); 67 | view.appendTo(document.body); 68 | frame.defer(function(){ 69 | assert(view.el.outerHTML === '
child
'); 70 | done(); 71 | }); 72 | }); 73 | 74 | it('should keep parsing the template', function (done) { 75 | var Child = ripple('
Child
'); 76 | var Other = ripple('
Other
'); 77 | var Parent = ripple('
'); 78 | Parent.compose('child', Child); 79 | Parent.compose('other', Other); 80 | Parent.directive('test', function(value){ 81 | assert(value === "bar"); 82 | done(); 83 | }); 84 | var view = new Parent(); 85 | view.appendTo(document.body); 86 | frame.defer(function(){ 87 | view.remove(); 88 | }); 89 | }); 90 | 91 | }); -------------------------------------------------------------------------------- /test/specs/view.js: -------------------------------------------------------------------------------- 1 | describe('View', function(){ 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | var View; 5 | 6 | it('should create a function that returns an View', function(){ 7 | View = ripple('
'); 8 | var view = new View(); 9 | assert(view); 10 | }); 11 | 12 | it('should create a view with a selector', function () { 13 | var test = document.createElement('div'); 14 | test.id = 'foo'; 15 | document.body.appendChild(test); 16 | View = ripple('#foo'); 17 | var view = new View(); 18 | assert(view.template = '
'); 19 | }); 20 | 21 | it('should construct with properties', function(){ 22 | var view = new View({ 23 | data: { 24 | foo: 'bar' 25 | } 26 | }); 27 | assert(view.data.foo === 'bar'); 28 | }) 29 | 30 | it('should set values', function () { 31 | var view = new View({ 32 | data: { 33 | foo: 'bar' 34 | } 35 | }); 36 | view.set('foo', 'baz'); 37 | assert( view.data.foo === 'baz' ); 38 | }); 39 | 40 | it('should be able to set default properties', function () { 41 | View.parse = function(options){ 42 | return { 43 | first: 'Fred', 44 | last: 'Flintstone' 45 | }; 46 | }; 47 | var view = new View(); 48 | view.set('first', 'Wilma'); 49 | assert(view.data.first === 'Wilma'); 50 | assert(view.data.last === 'Flintstone'); 51 | }); 52 | 53 | it('should have different bindings for each view', function () { 54 | var i = 0; 55 | var One = ripple('
'); 56 | One.directive('foo', function(val){ 57 | i++; 58 | }); 59 | var Two = ripple('
'); 60 | var one = new One(); 61 | var two = new Two(); 62 | assert(i === 1); 63 | }); 64 | 65 | it('should have the same bindings for each instance', function () { 66 | var one = new View(); 67 | var two = new View(); 68 | assert(two.bindings === one.bindings); 69 | }); 70 | 71 | it('should allow a custom template when created', function () { 72 | var view = new View({ 73 | template: '' 74 | }); 75 | assert(view.el.outerHTML === ''); 76 | }); 77 | 78 | describe('creating child views', function () { 79 | 80 | beforeEach(function () { 81 | View = ripple('
'); 82 | }); 83 | 84 | it('should create child views with the same bindings', function (done) { 85 | View.directive('foo', function(val){ 86 | assert(val === 'bar'); 87 | done(); 88 | }); 89 | var Child = View.create('
'); 90 | new Child(); 91 | }); 92 | 93 | it('should not have the same lifecycle events', function (done) { 94 | View.created(function(val){ 95 | done(false); 96 | }); 97 | var Child = View.create('
'); 98 | new Child(); 99 | done(); 100 | }); 101 | 102 | }); 103 | 104 | }) -------------------------------------------------------------------------------- /test/specs/text-interpolation.js: -------------------------------------------------------------------------------- 1 | describe('text interpolation', function () { 2 | var assert = require('assert'); 3 | var ripple = require('ripple'); 4 | var frame = require('raf-queue'); 5 | var View, view, el; 6 | 7 | beforeEach(function () { 8 | View = ripple('
{{text}}
'); 9 | view = new View({ 10 | data: { 11 | text: 'Ted' 12 | } 13 | }); 14 | view.appendTo('body'); 15 | }); 16 | 17 | afterEach(function(){ 18 | view.remove(); 19 | }); 20 | 21 | it('should interpolate text nodes', function(done){ 22 | frame.defer(function(){ 23 | assert(view.el.innerHTML === 'Ted'); 24 | done(); 25 | }); 26 | }) 27 | 28 | it('should render initial props immediately', function () { 29 | assert(view.el.innerHTML === 'Ted'); 30 | }); 31 | 32 | it('should not render null or undefined', function () { 33 | var View = ripple('
{{foo}}
'); 34 | var view = new View(); 35 | assert(view.el.innerHTML === ""); 36 | }); 37 | 38 | it('should remove the binding when the view is destroyed', function(done){ 39 | var el = view.el; 40 | frame.defer(function(){ 41 | view.destroy(); 42 | view.set('text', 'Barney'); 43 | frame.defer(function(){ 44 | assert(el.innerHTML === "Ted"); 45 | done(); 46 | }); 47 | }); 48 | }); 49 | 50 | it('should batch text node interpolation', function(done){ 51 | var count = 0; 52 | var view = new View(); 53 | var previous = view.interpolate; 54 | 55 | view.interpolate = function(){ 56 | count++; 57 | return previous.apply(this, arguments); 58 | }; 59 | 60 | view.set('text', 'one'); 61 | view.set('text', 'two'); 62 | view.set('text', 'three'); 63 | 64 | frame.defer(function(){ 65 | assert(count === 1); 66 | assert(view.el.innerHTML === 'three'); 67 | done(); 68 | }); 69 | }) 70 | 71 | it('should update interpolated text nodes', function(done){ 72 | view.set('text', 'Fred'); 73 | frame.defer(function(){ 74 | assert(view.el.innerHTML === 'Fred'); 75 | done(); 76 | }); 77 | }) 78 | 79 | it('should handle elements as values', function(done){ 80 | var test = document.createElement('div'); 81 | view.set('text', test); 82 | frame.defer(function(){ 83 | assert(view.el.firstChild === test); 84 | done(); 85 | }); 86 | }) 87 | 88 | it('should update elements as values', function(done){ 89 | var test = document.createElement('div'); 90 | var test2 = document.createElement('ul'); 91 | view.set('text', test); 92 | frame.defer(function(){ 93 | view.set('text', test2); 94 | frame.defer(function(){ 95 | assert(view.el.firstChild === test2); 96 | done(); 97 | }); 98 | }); 99 | }) 100 | 101 | it('should handle when the value is no longer an element', function(done){ 102 | var test = document.createElement('div'); 103 | view.set('text', test); 104 | frame.defer(function(){ 105 | view.set('text', 'bar'); 106 | frame.defer(function(){ 107 | assert(view.el.innerHTML === 'bar'); 108 | done(); 109 | }); 110 | }); 111 | }); 112 | 113 | it('should update from an non-string value', function(done){ 114 | view.set('text', null); 115 | frame.defer(function(){ 116 | view.set('text', 'bar'); 117 | frame.defer(function(){ 118 | assert(view.el.innerHTML === 'bar'); 119 | done(); 120 | }); 121 | }); 122 | }); 123 | 124 | }); -------------------------------------------------------------------------------- /test/specs/watching.js: -------------------------------------------------------------------------------- 1 | describe('watching', function(){ 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | var View = ripple('
'); 5 | 6 | it('should watch for changes', function(done){ 7 | var view = new View(); 8 | view.set('foo', 'bar'); 9 | view.watch('foo', function(){ 10 | done(); 11 | }) 12 | view.set('foo', 'baz'); 13 | }) 14 | 15 | it('should unwatch all changes to a property', function(done){ 16 | var view = new View(); 17 | view.set('foo', 'bar'); 18 | view.watch('foo', function(){ 19 | done(false); 20 | }) 21 | view.unwatch('foo'); 22 | view.set('foo', 'baz'); 23 | done(); 24 | }) 25 | 26 | it('should unwatch changes with a property and a function', function(done){ 27 | var view = new View(); 28 | view.set('foo', 'bar'); 29 | function change(){ 30 | done(false); 31 | } 32 | view.watch('foo', change); 33 | view.unwatch('foo', change); 34 | view.set('foo', 'baz'); 35 | done(); 36 | }) 37 | 38 | it('should use the change method for binding to changes', function(done){ 39 | view = new View(); 40 | view.watch('one', function(change){ 41 | assert(change === 1); 42 | done(); 43 | }); 44 | view.set('one', 1); 45 | }) 46 | 47 | if('should watch all changes', function(done){ 48 | view = new View(); 49 | view.watch(function(){ 50 | done(); 51 | }); 52 | view.set('one', 1); 53 | }); 54 | 55 | if('should unwatch all changes', function(done){ 56 | view = new View(); 57 | view.watch(function change(){ 58 | done(false); 59 | }); 60 | view.unwatch(change); 61 | view.set('one', 1); 62 | done(); 63 | }); 64 | 65 | it('should bind to changes of multiple properties', function(){ 66 | var called = 0; 67 | view = new View(); 68 | view.watch(['one', 'two'], function(attr, value){ 69 | called += 1; 70 | }); 71 | view.set('one', 1); 72 | assert(called === 1); 73 | }) 74 | 75 | it('should unbind to changes of multiple properties', function(){ 76 | var called = 0; 77 | view = new View(); 78 | function change(){ 79 | called += 1; 80 | } 81 | view.watch(['one', 'two'], change); 82 | view.unwatch(['one', 'two'], change); 83 | view.set('one', 1); 84 | view.set('two', 1); 85 | assert(called === 0); 86 | }) 87 | 88 | describe('nested properties', function(){ 89 | var view; 90 | 91 | beforeEach(function(){ 92 | view = new View({ 93 | foo: { 94 | bar: 'baz' 95 | } 96 | }); 97 | }); 98 | 99 | it('should emit events for the bottom edge', function(done){ 100 | view.watch('foo.bar', function(){ 101 | done(); 102 | }); 103 | view.set('foo.bar', 'zab'); 104 | }) 105 | 106 | it('should not emit events in the middle', function(){ 107 | var called = false; 108 | view.watch('foo', function(val){ 109 | called = true; 110 | }); 111 | view.set('foo.bar', 'zab'); 112 | assert(called === false); 113 | }) 114 | 115 | it('should emit when setting an object in the middle', function () { 116 | var called = false; 117 | view.watch('foo', function(val){ 118 | called = true; 119 | }); 120 | view.set('foo', { 121 | bar: 'zab' 122 | }); 123 | assert(called === true); 124 | }); 125 | 126 | it('should not emit events if the value has not changed', function(){ 127 | var called = 0; 128 | view.set('foo.bar', 'zab'); 129 | view.watch('foo', function(val){ 130 | called++; 131 | }); 132 | view.watch('foo.bar', function(val){ 133 | called++; 134 | }); 135 | view.set('foo', { 136 | bar: 'zab' 137 | }); 138 | assert(called === 0); 139 | }) 140 | 141 | }) 142 | 143 | }) -------------------------------------------------------------------------------- /test/specs/scope.js: -------------------------------------------------------------------------------- 1 | describe('scope', function(){ 2 | var ripple = require('ripple'); 3 | var assert = require('assert'); 4 | var View = ripple('
'); 5 | 6 | it('should get data from the parent scope', function(){ 7 | var parent = new View(); 8 | parent.set('foo', 'bar'); 9 | var child = new View({ 10 | scope: parent 11 | }); 12 | assert( child.get('foo') === 'bar' ); 13 | }) 14 | 15 | it('should watch for changes on the parent scope', function(done){ 16 | var parent = new View(); 17 | parent.set('foo', 'bar'); 18 | var child = new View({ 19 | scope: parent 20 | }); 21 | child.watch('foo', function(){ 22 | done(); 23 | }) 24 | parent.set('foo', 'baz'); 25 | }) 26 | 27 | it('should watch for multiple changes on the parent scope', function(){ 28 | var count = 0; 29 | var parent = new View(); 30 | parent.set({ 31 | 'foo': 'bar', 32 | 'raz': 'taz' 33 | }); 34 | var child = new View({ 35 | scope: parent 36 | }); 37 | child.watch(['foo', 'raz'], function(){ 38 | count++; 39 | }) 40 | parent.set({ 41 | 'foo': 'baz', 42 | 'raz': 'baz' 43 | }); 44 | assert(count === 2); 45 | }) 46 | 47 | it('should unwatch for changes on the parent scope', function () { 48 | var parent = new View(); 49 | parent.set('foo', 'bar'); 50 | var child = new View({ 51 | scope: parent 52 | }); 53 | child.watch('foo', function(){ 54 | assert(false, 'it should be remove this callback'); 55 | }) 56 | child.unwatch('foo'); 57 | parent.set('foo', 'baz'); 58 | assert(child.scopeWatchers.foo.length === 0); 59 | }); 60 | 61 | it('should unwatch for multiple changes on the parent scope', function () { 62 | var count = 0; 63 | var parent = new View(); 64 | parent.set({ 65 | 'foo': 'bar', 66 | 'raz': 'taz' 67 | }); 68 | var child = new View({ 69 | scope: parent 70 | }); 71 | child.watch(['foo', 'raz'], function(){ 72 | count++; 73 | }); 74 | child.unwatch(['foo', 'raz']); 75 | parent.set({ 76 | 'foo': 'baz', 77 | 'raz': 'baz' 78 | }); 79 | assert(count === 0); 80 | }); 81 | 82 | it('should unwatch some changes on the parent scope', function () { 83 | var count = 0; 84 | var parent = new View(); 85 | parent.set({ 86 | 'foo': 'bar', 87 | 'raz': 'taz' 88 | }); 89 | var child = new View({ 90 | scope: parent 91 | }); 92 | child.watch(['foo', 'raz'], function(){ 93 | count++; 94 | }); 95 | child.unwatch('foo'); 96 | parent.set({ 97 | 'foo': 'baz', 98 | 'raz': 'baz' 99 | }); 100 | assert(count === 1); 101 | }); 102 | 103 | it('should unwatch for changes on the parent scope if the child sets the value', function () { 104 | var count = 0; 105 | var parent = new View(); 106 | parent.set('foo', 'bar'); 107 | var child = new View({ 108 | scope: parent 109 | }); 110 | child.watch('foo', function(value){ 111 | assert(value === 'raz'); 112 | count++; 113 | }) 114 | child.set('foo', 'raz'); 115 | parent.set('foo', 'baz'); 116 | assert(count === 1); 117 | }); 118 | 119 | it('should unwatch for changes on the parent scope if the child sets the value as an object', function () { 120 | var count = 0; 121 | var parent = new View(); 122 | parent.set('foo', 'bar'); 123 | var child = new View({ 124 | scope: parent 125 | }); 126 | child.watch('foo', function(value){ 127 | assert(value === 'raz'); 128 | count++; 129 | }) 130 | child.set({ 'foo': 'raz' }); 131 | parent.set('foo', 'baz'); 132 | assert(count === 1); 133 | }); 134 | 135 | it('should unwatch for changes on the parent scope but still keep parent listeners', function (done) { 136 | var parent = new View(); 137 | parent.set('foo', 'bar'); 138 | var child = new View({ 139 | scope: parent 140 | }); 141 | parent.watch('foo', function(){ 142 | done(); 143 | }); 144 | child.set('foo', 'raz'); 145 | parent.set('foo', 'baz'); 146 | }); 147 | 148 | it('should remove all the scope watchers when the child gets the value', function () { 149 | var parent = new View(); 150 | parent.set('foo', 'bar'); 151 | var child = new View({ 152 | scope: parent 153 | }); 154 | child.watch('foo', function(){}); 155 | child.set('foo', 'raz'); 156 | assert(child.scopeWatchers['foo'] === undefined); 157 | }); 158 | 159 | it('should remove all scope listeners when destroyed', function () { 160 | var parent = new View(); 161 | parent.set('foo', 'bar'); 162 | var child = new View({ 163 | scope: parent 164 | }); 165 | child.watch('foo', function(){ 166 | assert(false, 'this should be unbound'); 167 | }); 168 | child.destroy(); 169 | parent.set('foo', 'baz'); 170 | }); 171 | 172 | it('should interpolate with properties from the parent scope', function () { 173 | var parent = new View(); 174 | parent.set('foo', 'bar'); 175 | var child = new View({ 176 | scope: parent 177 | }); 178 | assert( child.interpolate('{{foo}}') === 'bar' ); 179 | }); 180 | 181 | it('should run interpolation in the parent scope', function () { 182 | var parent = new View(); 183 | parent.set('foo', 'bar'); 184 | var child = new View({ 185 | scope: parent 186 | }); 187 | assert( child.interpolate('{{this}}') === parent ); 188 | }); 189 | 190 | }) -------------------------------------------------------------------------------- /test/utils/mocha.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | margin:0; 5 | } 6 | 7 | #mocha { 8 | font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; 9 | margin: 60px 50px; 10 | } 11 | 12 | #mocha ul, 13 | #mocha li { 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | #mocha ul { 19 | list-style: none; 20 | } 21 | 22 | #mocha h1, 23 | #mocha h2 { 24 | margin: 0; 25 | } 26 | 27 | #mocha h1 { 28 | margin-top: 15px; 29 | font-size: 1em; 30 | font-weight: 200; 31 | } 32 | 33 | #mocha h1 a { 34 | text-decoration: none; 35 | color: inherit; 36 | } 37 | 38 | #mocha h1 a:hover { 39 | text-decoration: underline; 40 | } 41 | 42 | #mocha .suite .suite h1 { 43 | margin-top: 0; 44 | font-size: .8em; 45 | } 46 | 47 | #mocha .hidden { 48 | display: none; 49 | } 50 | 51 | #mocha h2 { 52 | font-size: 12px; 53 | font-weight: normal; 54 | cursor: pointer; 55 | } 56 | 57 | #mocha .suite { 58 | margin-left: 15px; 59 | } 60 | 61 | #mocha .test { 62 | margin-left: 15px; 63 | overflow: hidden; 64 | } 65 | 66 | #mocha .test.pending:hover h2::after { 67 | content: '(pending)'; 68 | font-family: arial, sans-serif; 69 | } 70 | 71 | #mocha .test.pass.medium .duration { 72 | background: #c09853; 73 | } 74 | 75 | #mocha .test.pass.slow .duration { 76 | background: #b94a48; 77 | } 78 | 79 | #mocha .test.pass::before { 80 | content: '✓'; 81 | font-size: 12px; 82 | display: block; 83 | float: left; 84 | margin-right: 5px; 85 | color: #00d6b2; 86 | } 87 | 88 | #mocha .test.pass .duration { 89 | font-size: 9px; 90 | margin-left: 5px; 91 | padding: 2px 5px; 92 | color: #fff; 93 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 94 | -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 95 | box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 96 | -webkit-border-radius: 5px; 97 | -moz-border-radius: 5px; 98 | -ms-border-radius: 5px; 99 | -o-border-radius: 5px; 100 | border-radius: 5px; 101 | } 102 | 103 | #mocha .test.pass.fast .duration { 104 | display: none; 105 | } 106 | 107 | #mocha .test.pending { 108 | color: #0b97c4; 109 | } 110 | 111 | #mocha .test.pending::before { 112 | content: '◦'; 113 | color: #0b97c4; 114 | } 115 | 116 | #mocha .test.fail { 117 | color: #c00; 118 | } 119 | 120 | #mocha .test.fail pre { 121 | color: black; 122 | } 123 | 124 | #mocha .test.fail::before { 125 | content: '✖'; 126 | font-size: 12px; 127 | display: block; 128 | float: left; 129 | margin-right: 5px; 130 | color: #c00; 131 | } 132 | 133 | #mocha .test pre.error { 134 | color: #c00; 135 | max-height: 300px; 136 | overflow: auto; 137 | } 138 | 139 | /** 140 | * (1): approximate for browsers not supporting calc 141 | * (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border) 142 | * ^^ seriously 143 | */ 144 | #mocha .test pre { 145 | display: block; 146 | float: left; 147 | clear: left; 148 | font: 12px/1.5 monaco, monospace; 149 | margin: 5px; 150 | padding: 15px; 151 | border: 1px solid #eee; 152 | max-width: 85%; /*(1)*/ 153 | max-width: calc(100% - 42px); /*(2)*/ 154 | word-wrap: break-word; 155 | border-bottom-color: #ddd; 156 | -webkit-border-radius: 3px; 157 | -webkit-box-shadow: 0 1px 3px #eee; 158 | -moz-border-radius: 3px; 159 | -moz-box-shadow: 0 1px 3px #eee; 160 | border-radius: 3px; 161 | } 162 | 163 | #mocha .test h2 { 164 | position: relative; 165 | } 166 | 167 | #mocha .test a.replay { 168 | position: absolute; 169 | top: 3px; 170 | right: 0; 171 | text-decoration: none; 172 | vertical-align: middle; 173 | display: block; 174 | width: 15px; 175 | height: 15px; 176 | line-height: 15px; 177 | text-align: center; 178 | background: #eee; 179 | font-size: 15px; 180 | -moz-border-radius: 15px; 181 | border-radius: 15px; 182 | -webkit-transition: opacity 200ms; 183 | -moz-transition: opacity 200ms; 184 | transition: opacity 200ms; 185 | opacity: 0.3; 186 | color: #888; 187 | } 188 | 189 | #mocha .test:hover a.replay { 190 | opacity: 1; 191 | } 192 | 193 | #mocha-report.pass .test.fail { 194 | display: none; 195 | } 196 | 197 | #mocha-report.fail .test.pass { 198 | display: none; 199 | } 200 | 201 | #mocha-report.pending .test.pass, 202 | #mocha-report.pending .test.fail { 203 | display: none; 204 | } 205 | #mocha-report.pending .test.pass.pending { 206 | display: block; 207 | } 208 | 209 | #mocha-error { 210 | color: #c00; 211 | font-size: 1.5em; 212 | font-weight: 100; 213 | letter-spacing: 1px; 214 | } 215 | 216 | #mocha-stats { 217 | position: fixed; 218 | top: 15px; 219 | right: 10px; 220 | font-size: 12px; 221 | margin: 0; 222 | color: #888; 223 | z-index: 1; 224 | } 225 | 226 | #mocha-stats .progress { 227 | float: right; 228 | padding-top: 0; 229 | } 230 | 231 | #mocha-stats em { 232 | color: black; 233 | } 234 | 235 | #mocha-stats a { 236 | text-decoration: none; 237 | color: inherit; 238 | } 239 | 240 | #mocha-stats a:hover { 241 | border-bottom: 1px solid #eee; 242 | } 243 | 244 | #mocha-stats li { 245 | display: inline-block; 246 | margin: 0 5px; 247 | list-style: none; 248 | padding-top: 11px; 249 | } 250 | 251 | #mocha-stats canvas { 252 | width: 40px; 253 | height: 40px; 254 | } 255 | 256 | #mocha code .comment { color: #ddd; } 257 | #mocha code .init { color: #2f6fad; } 258 | #mocha code .string { color: #5890ad; } 259 | #mocha code .keyword { color: #8a6343; } 260 | #mocha code .number { color: #2f6fad; } 261 | 262 | @media screen and (max-device-width: 480px) { 263 | #mocha { 264 | margin: 60px 0px; 265 | } 266 | 267 | #mocha #stats { 268 | position: absolute; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /lib/view.js: -------------------------------------------------------------------------------- 1 | var emitter = require('emitter'); 2 | var each = require('each'); 3 | var model = require('./model'); 4 | var Bindings = require('./bindings'); 5 | var render = require('./render'); 6 | 7 | /** 8 | * Each of the events that are called on the view 9 | * and have helper methods created for them. 10 | */ 11 | 12 | var lifecycleEvents = [ 13 | 'construct', 14 | 'created', 15 | 'ready', 16 | 'mounted', 17 | 'unmounted', 18 | 'destroying', 19 | 'destroyed' 20 | ]; 21 | 22 | /** 23 | * Get a node using element the element itself 24 | * or a CSS selector 25 | * 26 | * @param {Element|String} node 27 | * 28 | * @return {Element} 29 | */ 30 | 31 | function getNode(node) { 32 | if (typeof node === 'string') { 33 | node = document.querySelector(node); 34 | if (!node) throw new Error('DOM node doesn\'t exist'); 35 | } 36 | return node; 37 | } 38 | 39 | /** 40 | * Create a new view from a template string 41 | * 42 | * @param {String} template 43 | * 44 | * @return {View} 45 | */ 46 | 47 | function createView(template) { 48 | 49 | /** 50 | * The view controls the lifecycle of the 51 | * element that it creates from a template. 52 | * Each element can only have one view and 53 | * each view can only have one element. 54 | */ 55 | 56 | function View(options) { 57 | options = options || {}; 58 | View.emit('construct', this, [options]); 59 | this.options = options; 60 | this.children = []; 61 | this.owner = options.owner; 62 | this.template = options.template || template; 63 | this.root = this; 64 | if (this.owner) { 65 | this.owner.children.push(this); 66 | this.root = this.owner.root; 67 | } 68 | this.scope = options.scope; 69 | this.scopeWatchers = {}; 70 | this.model = new View.Model(View.parse(options)); 71 | this.data = this.model.props; 72 | View.emit('created', this); 73 | this.el = this.render(); 74 | View.emit('ready', this); 75 | } 76 | 77 | /** 78 | * Mixins 79 | */ 80 | 81 | emitter(View); 82 | emitter(View.prototype); 83 | 84 | /** 85 | * Stores all of the directives, views, 86 | * filters etc. that we might want to share 87 | * between views. 88 | * 89 | * @type {Bindings} 90 | */ 91 | 92 | View.bindings = new Bindings(); 93 | 94 | /** 95 | * Stores the state of the view. 96 | * 97 | * @type {Function} 98 | */ 99 | 100 | View.Model = model(); 101 | 102 | /** 103 | * Add a directive 104 | * 105 | * @param {String|Regex} match 106 | * @param {Function} fn 107 | * 108 | * @return {View} 109 | */ 110 | 111 | View.directive = function(match, fn) { 112 | this.bindings.directive(match, fn); 113 | return this; 114 | }; 115 | 116 | /** 117 | * Add a component 118 | * 119 | * @param {String} match 120 | * @param {Function} fn 121 | * 122 | * @return {View} 123 | */ 124 | 125 | View.compose = function(name, Child) { 126 | this.bindings.component(name, Child); 127 | return this; 128 | }; 129 | 130 | /** 131 | * Add interpolation filter 132 | * 133 | * @param {String} name 134 | * @param {Function} fn 135 | * 136 | * @return {View} 137 | */ 138 | 139 | View.filter = function(name, fn) { 140 | if (typeof name !== 'string') { 141 | for(var key in name) { 142 | View.filter(key, name[key]); 143 | } 144 | return; 145 | } 146 | this.bindings.filter(name, fn); 147 | return this; 148 | }; 149 | 150 | /** 151 | * Use a plugin 152 | * 153 | * @return {View} 154 | */ 155 | 156 | View.use = function(fn, options) { 157 | fn(View, options); 158 | return this; 159 | }; 160 | 161 | /** 162 | * Create a new view from a template that shares 163 | * all of the same Bindings 164 | * 165 | * @param {String} template 166 | * 167 | * @return {View} 168 | */ 169 | 170 | View.create = function(template) { 171 | var Child = createView(template); 172 | Child.bindings = this.bindings; 173 | return Child; 174 | }; 175 | 176 | /** 177 | * Create helper methods for binding to events 178 | */ 179 | 180 | lifecycleEvents.forEach(function(name) { 181 | View[name] = function(fn){ 182 | View.on(name, function(view, args){ 183 | fn.apply(view, args); 184 | }); 185 | }; 186 | }); 187 | 188 | /** 189 | * Parse the options for the initial data 190 | */ 191 | 192 | View.parse = function(options) { 193 | return options.data; 194 | }; 195 | 196 | /** 197 | * Set the state off the view. This will trigger 198 | * refreshes to the UI. If we were previously 199 | * watching the parent scope for changes to this 200 | * property, we will remove all of those watchers 201 | * and then bind them to our model instead. 202 | * 203 | * @param {Object} obj 204 | */ 205 | 206 | View.prototype.set = function(key, value) { 207 | if ( typeof key !== 'string' ) { 208 | for(var name in key) this.set(name, key[name]); 209 | return this; 210 | } 211 | if (this.scope && this.scopeWatchers[key]) { 212 | var self = this; 213 | this.scopeWatchers[key].forEach(function(callback){ 214 | self.scope.unwatch(key, callback); 215 | self.model.watch(key, callback); 216 | }); 217 | delete this.scopeWatchers[key]; 218 | } 219 | this.model.set(key, value); 220 | return this; 221 | }; 222 | 223 | /** 224 | * Get some data 225 | * 226 | * @param {String} key 227 | */ 228 | 229 | View.prototype.get = function(key) { 230 | var value = this.model.get(key); 231 | if (value === undefined && this.scope) { 232 | return this.scope.get(key); 233 | } 234 | return value; 235 | }; 236 | 237 | /** 238 | * Get all the properties used in a string 239 | * 240 | * @param {String} str 241 | * 242 | * @return {Array} 243 | */ 244 | 245 | View.prototype.props = function(str) { 246 | return View.bindings.interpolator.props(str); 247 | }; 248 | 249 | /** 250 | * Remove the element from the DOM 251 | */ 252 | 253 | View.prototype.destroy = function() { 254 | var self = this; 255 | this.emit('destroying'); 256 | View.emit('destroying', this); 257 | this.remove(); 258 | this.model.destroy(); 259 | this.off(); 260 | this.children.forEach(function(child){ 261 | child.destroy(); 262 | }); 263 | if (this.owner) { 264 | var index = this.owner.children.indexOf(this); 265 | this.owner.children.splice(index, 1); 266 | } 267 | each(this.scopeWatchers, function(key, callbacks){ 268 | callbacks.forEach(function(callback){ 269 | self.scope.unwatch(key, callback); 270 | }); 271 | }); 272 | this.scopeWatchers = null; 273 | this.scope = null; 274 | this.el = null; 275 | this.owner = null; 276 | this.root = null; 277 | this.data = null; 278 | this.emit('destroyed'); 279 | View.emit('destroyed', this); 280 | }; 281 | 282 | /** 283 | * Is the view mounted in the DOM 284 | * 285 | * @return {Boolean} 286 | */ 287 | 288 | View.prototype.isMounted = function() { 289 | return this.el != null && this.el.parentNode != null; 290 | }; 291 | 292 | /** 293 | * Render the view to an element. This should 294 | * only ever render the element once. 295 | */ 296 | 297 | View.prototype.render = function() { 298 | return render({ 299 | view: this, 300 | template: this.template, 301 | bindings: View.bindings 302 | }); 303 | }; 304 | 305 | /** 306 | * Mount the view onto a node 307 | * 308 | * @param {Element|String} node An element or CSS selector 309 | * 310 | * @return {View} 311 | */ 312 | 313 | View.prototype.appendTo = function(node) { 314 | getNode(node).appendChild(this.el); 315 | this.emit('mounted'); 316 | View.emit('mounted', this); 317 | return this; 318 | }; 319 | 320 | /** 321 | * Replace an element in the DOM with this view 322 | * 323 | * @param {Element|String} node An element or CSS selector 324 | * 325 | * @return {View} 326 | */ 327 | 328 | View.prototype.replace = function(node) { 329 | var target = getNode(node); 330 | target.parentNode.replaceChild(this.el, target); 331 | this.emit('mounted'); 332 | View.emit('mounted', this); 333 | return this; 334 | }; 335 | 336 | /** 337 | * Insert the view before a node 338 | * 339 | * @param {Element|String} node 340 | * 341 | * @return {View} 342 | */ 343 | 344 | View.prototype.before = function(node) { 345 | var target = getNode(node); 346 | target.parentNode.insertBefore(this.el, target); 347 | this.emit('mounted'); 348 | View.emit('mounted', this); 349 | return this; 350 | }; 351 | 352 | /** 353 | * Insert the view after a node 354 | * 355 | * @param {Element|String} node 356 | * 357 | * @return {View} 358 | */ 359 | 360 | View.prototype.after = function(node) { 361 | var target = getNode(node); 362 | target.parentNode.insertBefore(this.el, target.nextSibling); 363 | this.emit('mounted'); 364 | View.emit('mounted', this); 365 | return this; 366 | }; 367 | 368 | /** 369 | * Remove the view from the DOM 370 | * 371 | * @return {View} 372 | */ 373 | 374 | View.prototype.remove = function() { 375 | if (this.isMounted() === false) return this; 376 | this.el.parentNode.removeChild(this.el); 377 | this.emit('unmounted'); 378 | View.emit('unmounted', this); 379 | return this; 380 | }; 381 | 382 | /** 383 | * Interpolate a string 384 | * 385 | * @param {String} str 386 | */ 387 | 388 | View.prototype.interpolate = function(str) { 389 | var self = this; 390 | var data = {}; 391 | var props = this.props(str); 392 | props.forEach(function(prop){ 393 | data[prop] = self.get(prop); 394 | }); 395 | return View.bindings.interpolator.value(str, { 396 | context: this.scope || this, 397 | scope: data 398 | }); 399 | }; 400 | 401 | /** 402 | * Watch a property for changes 403 | * 404 | * @param {Strign} prop 405 | * @param {Function} callback 406 | */ 407 | 408 | View.prototype.watch = function(prop, callback) { 409 | var self = this; 410 | if (Array.isArray(prop)) { 411 | return prop.forEach(function(name){ 412 | self.watch(name, callback); 413 | }); 414 | } 415 | var value = this.model.get(prop); 416 | if (value === undefined && this.scope) { 417 | this.scope.watch(prop, callback); 418 | if (!this.scopeWatchers[prop]) { 419 | this.scopeWatchers[prop] = []; 420 | } 421 | this.scopeWatchers[prop].push(callback); 422 | return; 423 | } 424 | return this.model.watch(prop, callback); 425 | }; 426 | 427 | /** 428 | * Stop watching a property 429 | * 430 | * @param {Strign} prop 431 | * @param {Function} callback 432 | */ 433 | 434 | View.prototype.unwatch = function(prop, callback) { 435 | var self = this; 436 | if (Array.isArray(prop)) { 437 | return prop.forEach(function(name){ 438 | self.unwatch(name, callback); 439 | }); 440 | } 441 | var value = this.model.get(prop); 442 | if (value === undefined && this.scope) { 443 | this.scope.unwatch(prop, callback); 444 | if (!this.scopeWatchers[prop]) return; 445 | var index = this.scopeWatchers[prop].indexOf(callback); 446 | this.scopeWatchers[prop].splice(index, 1); 447 | return; 448 | } 449 | return this.model.unwatch(prop, callback); 450 | }; 451 | 452 | return View; 453 | } 454 | 455 | 456 | /** 457 | * Exports 458 | */ 459 | 460 | module.exports = createView; --------------------------------------------------------------------------------