├── .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 | [](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;
--------------------------------------------------------------------------------