├── History.md ├── .gitignore ├── test ├── support │ ├── via.js │ ├── chai.js │ ├── mocha.css │ ├── mocha.js │ ├── fake.resource.js │ ├── fake.api.js │ └── cats.api.js ├── browser │ ├── data-list.test.js │ ├── data-text.test.js │ ├── data-toggle.test.js │ ├── data-class.test.js │ ├── index.html │ ├── element.test.js │ └── page.test.js ├── window.test.js ├── resource.test.js ├── utils.test.js ├── uri.test.js └── object.test.js ├── index.js ├── lib ├── elements │ ├── index.js │ ├── page.js │ └── page_links.js ├── attributes │ ├── data-toggle.js │ ├── index.js │ ├── data-onclick.js │ ├── data-val.js │ ├── data-html.js │ ├── data-text.js │ ├── data-class.js │ ├── data-require.js │ ├── data-select.js │ └── data-list.js ├── via.js ├── array.js ├── window.js ├── resource.js ├── events.js ├── uri.js ├── element.js ├── utils.js └── object.js ├── package.json ├── components └── component-domify │ ├── component.json │ └── index.js ├── Makefile ├── component.json └── README.md /History.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /test/support/via.js: -------------------------------------------------------------------------------- 1 | ../../build/via.js -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/via'); 2 | -------------------------------------------------------------------------------- /test/support/chai.js: -------------------------------------------------------------------------------- 1 | ../../node_modules/chai/chai.js -------------------------------------------------------------------------------- /test/support/mocha.css: -------------------------------------------------------------------------------- 1 | ../../node_modules/mocha/mocha.css -------------------------------------------------------------------------------- /test/support/mocha.js: -------------------------------------------------------------------------------- 1 | ../../node_modules/mocha/mocha.js -------------------------------------------------------------------------------- /lib/elements/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | page: require('./page') 3 | , page_links: require('./page_links') 4 | } 5 | -------------------------------------------------------------------------------- /lib/attributes/data-toggle.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Make the element toggle a property when clicked. 3 | */ 4 | module.exports = function(ui,attr) { 5 | this.addEventListener('click', function() { 6 | var cur = ui.data.get(attr); 7 | ui.data.set(attr, !cur); 8 | }); 9 | }; 10 | 11 | -------------------------------------------------------------------------------- /lib/attributes/index.js: -------------------------------------------------------------------------------- 1 | var index = [ 2 | 'list' 3 | , 'text' 4 | , 'html' 5 | , 'require' 6 | , 'class' 7 | , 'val' 8 | , 'toggle' 9 | , 'select' 10 | , 'onclick' 11 | ]; 12 | 13 | for(var i=0,l=index.length; i < l; ++i) { 14 | module.exports[index[i]] = require('./data-'+index[i]); 15 | } 16 | -------------------------------------------------------------------------------- /lib/attributes/data-onclick.js: -------------------------------------------------------------------------------- 1 | module.exports = function(ui,attr) { 2 | 3 | this.addEventListener('click', function(e) { 4 | var fn = ui.data.get(attr); 5 | 6 | console.log(fn,attr); 7 | 8 | if(typeof fn === 'function') { 9 | fn(); 10 | } 11 | else { 12 | // error? 13 | } 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /lib/attributes/data-val.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bind an input val to a synthetic attribute 3 | */ 4 | module.exports = function(ui,value) { 5 | var elem = this; 6 | 7 | ui.data.watch(value, function(v) { 8 | $elem.val(v); 9 | }); 10 | 11 | $elem.change(function() { 12 | console.log(ui, value, $elem.val()); 13 | ui.data.set(value, $elem.val()); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /lib/via.js: -------------------------------------------------------------------------------- 1 | var Via = module.exports = {}; 2 | Via.utils = require('./utils'); 3 | Via.Object = require('./object'); 4 | Via.Array = require('./array'); 5 | Via.URI = require('./uri'); 6 | Via.Resource = require('./resource'); 7 | Via.Window = require('./window'); 8 | Via.Element = require('./element'); 9 | 10 | if(typeof window !== 'undefined') { 11 | Via.window = new Via.Window(window); 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "via" 3 | , "version": "0.0.1" 4 | , "dependencies": { 5 | "request": "*" 6 | , "domify": "*" 7 | }, 8 | "devDependencies": { 9 | "mocha": "1.12" 10 | , "mocha-phantomjs": "3.1" 11 | , "nib": "*" 12 | , "should": "*" 13 | , "expect": "*" 14 | , "chai": "*" 15 | , "stylus": "0.x.x" 16 | , "jade": "0.17.x" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/attributes/data-html.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bind the innerHTML of an element to a synthetic attribute 3 | */ 4 | module.exports = function(ui,attr) { 5 | var elem = this; 6 | 7 | ui.data.watch(attr, function(value) { 8 | if(value.then) { 9 | value.then(function(html) { 10 | elem.innerHTML = html; 11 | }); 12 | } 13 | else { 14 | elem.innerHTML = value; 15 | } 16 | 17 | }); 18 | }; 19 | 20 | 21 | -------------------------------------------------------------------------------- /lib/attributes/data-text.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bind the innerText of a single element to a synthetic attribute 3 | */ 4 | module.exports = function(ui,value) { 5 | var elem = this; 6 | 7 | elem.addEventListener('click', function() { 8 | console.log(ui); 9 | }); 10 | 11 | ui.data.watch(value, function(v,p) { 12 | if(v === null) v = ''; 13 | elem.innerText = ''+v; 14 | elem.innerHTML = elem.innerHTML.replace(/\n/g,'
'); 15 | }); 16 | }; 17 | 18 | 19 | -------------------------------------------------------------------------------- /lib/attributes/data-class.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Bind a class name to a synthetic attribute 3 | */ 4 | module.exports = function(ui,value) { 5 | var elem = this; 6 | 7 | var staticClass = elem.className; 8 | ui.data.watch(value, function(newV,preV) { 9 | var newClass = newV; 10 | 11 | var endOfPath = value.split('.').slice(-1)[0]; 12 | if(newV === true) { newClass = endOfPath; } 13 | if(!newV) { newClass = '';} 14 | 15 | elem.className = [staticClass,newClass].join(' ').trim(); 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /components/component-domify/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "domify", 3 | "version": "1.0.0", 4 | "description": "turn HTML into DOM elements", 5 | "scripts": [ 6 | "index.js" 7 | ], 8 | "development": { 9 | "component/assert": "*", 10 | "visionmedia/mocha": "*", 11 | "visionmedia/mocha-cloud": "*" 12 | }, 13 | "keywords": [ 14 | "dom", 15 | "html", 16 | "client", 17 | "browser", 18 | "component" 19 | ], 20 | "license": "MIT", 21 | "repo": "https://raw.github.com/component/domify" 22 | } -------------------------------------------------------------------------------- /test/browser/data-list.test.js: -------------------------------------------------------------------------------- 1 | describe('[data-list]', function() { 2 | // TODO: You should be able to use top level primitives 3 | // without wrapping them up in objects like this. 4 | var numbers = new Via.Array([{i:1},{i:2},{i:3}]); 5 | 6 | it('creates elements for each item', function() { 7 | var template = ''; 8 | var ui = new Via.Element({numbers: numbers}, template); 9 | 10 | var html = ''; 11 | expect(ui.rootElement.outerHTML).eq(html); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /lib/attributes/data-require.js: -------------------------------------------------------------------------------- 1 | module.exports = function(ui,attr) { 2 | var elem = this; 3 | var empty = this.querySelector('empty'); 4 | 5 | if(empty) { 6 | empty.parentNode.removeChild(empty); 7 | } 8 | 9 | ui.data.watch(attr+'._http_status', function(status,prev) { 10 | if(status == 404) { 11 | elem.parentNode.insertBefore(empty, elem); 12 | elem.parentNode.removeChild(elem); 13 | } 14 | else if(prev == 404) { 15 | empty.parentNode.insertBefore(elem, empty); 16 | empty.parentNode.removeChild(empty); 17 | } 18 | }); 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /test/support/fake.resource.js: -------------------------------------------------------------------------------- 1 | if(typeof module !== 'undefined') { 2 | module.exports = FakeResource; 3 | ReactiveResource = require('../../lib/resource'); 4 | FakeAPI = require('./fake.api'); 5 | } 6 | 7 | 8 | function FakeResource(init,mock) { 9 | ReactiveResource.call(this, init); 10 | 11 | if(mock instanceof FakeAPI) 12 | this.FakeAPI = mock; 13 | else 14 | this.fakeAPI = new FakeAPI(mock); 15 | } 16 | 17 | FakeResource.prototype = new ReactiveResource({ 18 | request: function() { 19 | return this.fakeAPI.request.apply(this.fakeAPI, arguments); 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /test/browser/data-text.test.js: -------------------------------------------------------------------------------- 1 | describe('[data-text]', function() { 2 | it('initializes', function() { 3 | var ui = new Via.Element({test:123}, '
empty
'); 4 | expect(ui.rootElement.innerText).eq('123'); 5 | }); 6 | 7 | it('updates', function() { 8 | var ui = new Via.Element({}, '
empty
'); 9 | ui.data.set('test', 123); 10 | expect(ui.rootElement.innerText).eq('123'); 11 | }); 12 | 13 | it('uses empty string for null', function() { 14 | var ui = new Via.Element({test:null}, '
empty
'); 15 | expect(ui.rootElement.innerText).eq(''); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | STYLUS = ./node_modules/stylus/bin/stylus -u nib 2 | JS_SOURCES = *.js lib/*.js lib/elements/*.js lib/attributes/*.js 3 | HTML_SOURCES = lib/elements/templates/*.html 4 | COMPONENT_SOURCES = component.json $(JS_SOURCES) 5 | 6 | all: node_modules build/via.js 7 | 8 | build/via.js: $(COMPONENT_SOURCES) 9 | mkdir -p build 10 | component build -s Via -o build -n via 11 | 12 | test-client: 13 | @node_modules/.bin/mocha test -R list 14 | 15 | test-browser: build/via.js 16 | @node_modules/.bin/mocha-phantomjs -R list test/browser/index.html 17 | 18 | test: test-client test-browser 19 | 20 | clean: 21 | rm -fr build components template.js 22 | 23 | .PHONY: clean test test-browser test-client 24 | -------------------------------------------------------------------------------- /test/window.test.js: -------------------------------------------------------------------------------- 1 | describe('Via.Window', function() { 2 | var Via = require('../lib/via'), 3 | expect = require('chai').expect; 4 | 5 | var rwin; 6 | beforeEach(function() { 7 | var fakeWindow = { 8 | location: { 9 | href: 'http://example.net/path?hello=world' 10 | } 11 | , addEventListener: function() {} 12 | }; 13 | rwin = new Via.Window(fakeWindow); 14 | }); 15 | 16 | it('gets url from location', function() { 17 | expect(rwin.location.href).eq(rwin.actual.location.href); 18 | }); 19 | 20 | it('treats initial params as defaults', function() { 21 | }); 22 | 23 | it('does a pushState when query params change', function() { 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /lib/array.js: -------------------------------------------------------------------------------- 1 | module.exports = ReactiveArray; 2 | 3 | var ReactiveObject = require('./object') 4 | , utils = require('./utils'); 5 | 6 | function ReactiveArray(obj) { 7 | this.set(obj); 8 | 9 | this.synth('length', /\n+/, function(i) { 10 | return Math.max(this.root.length, i); 11 | }); 12 | 13 | this.watch('length', function(newv,prev) { 14 | for(var i=newv, l=prev; i < l; ++i) { 15 | this.root.set(i, undefined); 16 | } 17 | }); 18 | } 19 | 20 | ReactiveArray.prototype = new ReactiveObject({ 21 | slice: function(start,end) { 22 | var result = new ReactiveArray(); 23 | this.watch(/\n+/, function(i,v) { 24 | result.set(i,v); 25 | }); 26 | return result; 27 | } 28 | }); 29 | -------------------------------------------------------------------------------- /test/browser/data-toggle.test.js: -------------------------------------------------------------------------------- 1 | describe('[data-toggle]', function() { 2 | it('toggles a boolean property when clicked', function() { 3 | var ui = new Via.Element({}, '
empty
'); 4 | click(ui.rootElement); 5 | expect(ui.data.bool).eq(true); 6 | click(ui.rootElement); 7 | expect(ui.data.bool).eq(false); 8 | }); 9 | }); 10 | 11 | function click(el){ 12 | var ev = document.createEvent("MouseEvent"); 13 | ev.initMouseEvent( 14 | "click", 15 | true /* bubble */, true /* cancelable */, 16 | window, null, 17 | 0, 0, 0, 0, /* coordinates */ 18 | false, false, false, false, /* modifier keys */ 19 | 0 /*left*/, null 20 | ); 21 | el.dispatchEvent(ev); 22 | } 23 | -------------------------------------------------------------------------------- /lib/elements/page.js: -------------------------------------------------------------------------------- 1 | module.exports = function page() { 2 | 3 | this.data.synth('page', 'src'); 4 | this.data.synth('page.size', 'size'); 5 | this.data.synth('page.number', 'number'); 6 | this.data.set('page.number', this.data.page.number || 1); 7 | 8 | this.data.synth('page.length', 'page.number page.size page.total', 9 | function(n,s,t) { 10 | if(n * s >= t) { 11 | return t - n * s; 12 | } 13 | return s; 14 | }); 15 | 16 | this.data.synth('info', 'page.total page.size page.number', 17 | function(total, size, n) { 18 | if(total <= size) 19 | return 'Showing ' + total + ' of ' + total; 20 | 21 | var a = (n-1)*size+1; 22 | var b = Math.min(n*size, total); 23 | 24 | return 'Showing ' + a + '-' + b + ' of ' + total; 25 | }); 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /test/support/fake.api.js: -------------------------------------------------------------------------------- 1 | if(typeof module !== 'undefined') { 2 | module.exports = FakeAPI; 3 | } 4 | 5 | function FakeAPI(mock) { 6 | this.mock = mock || {}; 7 | this.baseUrl = ''; 8 | } 9 | 10 | FakeAPI.prototype.request = function(method, uri, data, callback) { 11 | var str = ''+method+' '+uri; 12 | 13 | if(!this.mock.hasOwnProperty(str)) { 14 | throw 'fake API "'+str+'" not mocked'; 15 | } 16 | else { 17 | var res = this.mock[str]; 18 | if(typeof res === 'function') { 19 | res = res(data); 20 | } 21 | res = res || {}; 22 | 23 | setTimeout(function() { 24 | if(res.error) { 25 | callback(res.error); 26 | } 27 | else { 28 | res.meta = res.meta || {}; 29 | res.meta.status = res.meta.status || 200; 30 | callback(null, res); 31 | } 32 | }); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /lib/attributes/data-select.js: -------------------------------------------------------------------------------- 1 | var ReactiveElement = require('../element'); 2 | 3 | 4 | /** 5 | * Bind a property to selection which can be set by clicking 6 | * other [data-select="invoice"] 7 | * TODO: Use delegation on the element associated 8 | * with the data that actually changes. 9 | * The path tells us the delegation root! 10 | */ 11 | 12 | module.exports = function(ui,attr) { 13 | 14 | ui.data.watch('parent.selection', function(newV, preV) { 15 | 16 | if( same(ui.data.get(attr), newV) ) { 17 | ui.data.set('selected', true); 18 | } 19 | else { 20 | ui.data.set('selected', false); 21 | } 22 | }); 23 | 24 | this.addEventListener('click', function(event) { 25 | ui.data.set('parent.selection', ui.data.get(attr)); 26 | }); 27 | } 28 | 29 | function same(a,b) { 30 | if(a === b) return true; 31 | 32 | if(a && typeof a.compare === 'function') { 33 | return a.compare(b); 34 | } 35 | 36 | return false; 37 | } 38 | -------------------------------------------------------------------------------- /test/browser/data-class.test.js: -------------------------------------------------------------------------------- 1 | describe('[data-class]', function() { 2 | it('adds a class named after the value of the property', function() { 3 | var ui = new Via.Element({hello:'world'}, '
empty
'); 4 | expect(ui.rootElement.className).eq('dontmindme world'); 5 | }); 6 | 7 | it('treats boolean values as a class toggle of the property name', function() { 8 | var ui = new Via.Element({hello:true}, '
empty
'); 9 | expect(ui.rootElement.className).eq('dontmindme hello'); 10 | ui.data.set('hello', false); 11 | expect(ui.rootElement.className).eq('dontmindme'); 12 | }); 13 | 14 | it('only uses the last property in a path for boolean classes', function() { 15 | var ui = new Via.Element({classes:{hello:true}}, '
empty
'); 16 | expect(ui.rootElement.className).eq('dontmindme hello'); 17 | }); 18 | 19 | 20 | }); 21 | -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "via", 3 | "description": "RFP library for node and browser", 4 | "repo": "em/via", 5 | "version": "0.0.0", 6 | "keywords": [], 7 | "dependencies": { 8 | "component/domify": "1.0.0" 9 | }, 10 | "development": {}, 11 | "license": "MIT", 12 | "scripts": [ 13 | "index.js", 14 | "lib/via.js", 15 | "lib/utils.js", 16 | "lib/events.js", 17 | "lib/object.js", 18 | "lib/array.js", 19 | "lib/resource.js", 20 | "lib/uri.js", 21 | "lib/window.js", 22 | "lib/element.js", 23 | "lib/elements/index.js", 24 | "lib/elements/page.js", 25 | "lib/elements/page_links.js", 26 | "lib/attributes/index.js", 27 | "lib/attributes/data-class.js", 28 | "lib/attributes/data-text.js", 29 | "lib/attributes/data-html.js", 30 | "lib/attributes/data-list.js", 31 | "lib/attributes/data-require.js", 32 | "lib/attributes/data-val.js", 33 | "lib/attributes/data-toggle.js", 34 | "lib/attributes/data-select.js", 35 | "lib/attributes/data-onclick.js" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /test/support/cats.api.js: -------------------------------------------------------------------------------- 1 | if(typeof module !== 'undefined') { 2 | FakeAPI = require('./fake.api'); 3 | } 4 | 5 | catsAPI = new FakeAPI({ 6 | // Model stuff 7 | 'GET cats/1.json': { 8 | cat: {id:1, name:'Fluffy'} 9 | }, 10 | 'PUT cats/1.json': function(data) { 11 | data.cat.was = "updated"; 12 | return data; 13 | }, 14 | 'GET cats/2.json': { 15 | cat: {id:2, name: 'Scratch'} 16 | }, 17 | 18 | // Collection stuff 19 | 'GET cats/': function(params) { 20 | var cursor = params.cursor || 0; 21 | params.per_page = parseInt(params.per_page) 22 | if(params.page && params.per_page) { 23 | cursor = (params.page-1) * params.per_page; 24 | } 25 | 26 | var response = { 27 | cats: [ 28 | {id:1, name: 'Fluffy'}, 29 | {id:2, name: 'Scratch'}, 30 | {id:3, name: 'Leonard'} 31 | ].slice(cursor,cursor+params.per_page), 32 | meta: { 33 | status: 200 34 | , count: 3 35 | } 36 | }; 37 | 38 | return response; 39 | }, 40 | 'POST cats/': function() { 41 | return {cat: {id: 2, was:'created'}} 42 | }, 43 | 44 | }); 45 | 46 | if(typeof module !== 'undefined') { 47 | module.exports = catsAPI; 48 | } 49 | -------------------------------------------------------------------------------- /lib/window.js: -------------------------------------------------------------------------------- 1 | var ReactiveObject = require('./object'), 2 | ReactiveURI = require('./uri'); 3 | 4 | module.exports = ReactiveWindow; 5 | 6 | function ReactiveWindow(window) { 7 | this.actual = window; 8 | this.location = new ReactiveURI({href:window.location.href}); 9 | 10 | var self = this; 11 | var pushState = new ReactiveURI(window.location); 12 | var popping = false; 13 | window.addEventListener('popstate', function() { 14 | popping = true; 15 | self.location.set('href', window.location.href); 16 | popping = false; 17 | }); 18 | 19 | pushState.watch('pathname search', 20 | function(path,search) { 21 | if(!popping) { 22 | window.history.pushState(null, null, path+search); 23 | } 24 | }); 25 | 26 | var defaultParams = {}; 27 | this.watch('location.params', function(params) { 28 | var uniqueParams = {}; 29 | 30 | for(var k in params) { 31 | defaultParams[k] = defaultParams[k] || params[k]; 32 | 33 | if(defaultParams[k] != params[k]) { 34 | uniqueParams[k] = params[k]; 35 | } 36 | } 37 | 38 | pushState.set('params', uniqueParams); 39 | }); 40 | } 41 | 42 | ReactiveWindow.prototype = new ReactiveObject(); 43 | -------------------------------------------------------------------------------- /test/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 33 | 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /lib/attributes/data-list.js: -------------------------------------------------------------------------------- 1 | var ReactiveElement = require('../element') 2 | , utils = require('../utils'); 3 | 4 | /** 5 | * Bind a collection to a repeating block scoped per item 6 | */ 7 | module.exports = function(ui,value,template) { 8 | var target = this; 9 | target.innerHTML = ''; 10 | 11 | ui.data.watch(value, function(collection) { 12 | if(!collection) return; 13 | 14 | // Remove any element beyond the collection length 15 | collection.watch('length',function(length) { 16 | for(var i=length, l = target.children.length-1; l >= i; --l) { 17 | target.removeChild(target.children[l]); 18 | } 19 | }); 20 | 21 | // Update or append as any numeric index changes 22 | collection.watch(/\d+/,function(i,item,prev) { 23 | if(item === undefined) return; 24 | 25 | var oldElem = target.children[i]; 26 | 27 | if(item) { 28 | var itemui = new ReactiveElement(item, template); 29 | itemui.data.set('parent', ui.data); 30 | 31 | if(oldElem) { 32 | target.insertBefore(itemui.rootElement, oldElem); 33 | target.removeChild(oldElem); 34 | } 35 | else { 36 | target.appendChild(itemui.rootElement); 37 | } 38 | } 39 | }); 40 | 41 | }) 42 | }; 43 | 44 | 45 | -------------------------------------------------------------------------------- /lib/resource.js: -------------------------------------------------------------------------------- 1 | module.exports = ReactiveResource; 2 | 3 | var ReactiveObject = require('./object') 4 | , ReactiveURI = require('./uri') 5 | , utils = require('./utils'); 6 | 7 | 8 | function ReactiveResource(init) { 9 | this.set(init); 10 | this.location = new ReactiveURI(this.location); 11 | this.synth('url', 'location.href'); 12 | 13 | // this.watch('autoload url', function(autoload, url) { 14 | // if(autoload) this.root.reload(); 15 | // }); 16 | 17 | this.watch('autosave data', function(autosave, data) { 18 | if(autosave) this.root.save(); 19 | }); 20 | 21 | this.synth('id', 'location.file'); 22 | } 23 | 24 | ReactiveResource.prototype = new ReactiveObject({ 25 | save: function(callback) { 26 | this.set('invalid', true); 27 | 28 | if(this.url) { 29 | var body = this.format.stringify(res); 30 | this.request('put', body, function(err, res) { 31 | this.set('response', this.format.parse(res)); 32 | }); 33 | } 34 | else if(this.parent) { 35 | var body = this.format.stringify(res); 36 | this.request('post', body, function(err, res) { 37 | this.set('response', this.format.parse(res)); 38 | }); 39 | } 40 | 41 | } 42 | , load: function(callback) { 43 | if(this.__loaded__) return; 44 | this.__loaded__ = true; 45 | 46 | var self = this; 47 | this.watch('url', function(url) { 48 | self.request('get', url, null, function(err, res) { 49 | self.set('response', res); 50 | callback && callback(err, res); 51 | }); 52 | }); 53 | } 54 | , reload: function() { 55 | this.set('outdated', true); 56 | } 57 | , request: function(method, url, data, callback) { 58 | // var request = require('request'); 59 | // api.request(method, url, data, callback); 60 | } 61 | , format: JSON 62 | }); 63 | -------------------------------------------------------------------------------- /test/resource.test.js: -------------------------------------------------------------------------------- 1 | describe('Via.Resource', function() { 2 | var Via = require('../lib/via'), 3 | expect = require('chai').expect, 4 | FakeResource = require('./support/fake.resource'); 5 | 6 | beforeEach(function() { 7 | }); 8 | 9 | var mock = { 10 | 'get http://example.net/greeting.json': { 11 | hello: 'world' 12 | } 13 | }; 14 | 15 | it('binds url and location.href', function() { 16 | var url = 'http://example.net/greeting.json'; 17 | var r = new FakeResource({ 18 | url: url 19 | }); 20 | expect(url).eq(r.url).eq(r.location.href); 21 | }); 22 | 23 | it('loads', function(done) { 24 | var r = new FakeResource({ 25 | url: 'http://example.net/greeting.json' 26 | }, mock); 27 | 28 | r.load(function() { 29 | expect(r.response.hello).eq('world'); 30 | done(); 31 | }); 32 | }); 33 | 34 | it('can autoload when params changes', function(done) { 35 | var stub = { 36 | 'get http://example.net/greeting.json': { 37 | hello:'world' 38 | } 39 | , 'get http://example.net/greeting.json?message=dude': { 40 | hello:'dude' 41 | } 42 | }; 43 | 44 | var resource = new FakeResource({ 45 | url: 'http://example.net/greeting.json' 46 | }, stub); 47 | 48 | resource.load(); 49 | 50 | var count = 0; 51 | resource.watch('response.hello', function(hello) { 52 | count++; 53 | 54 | if(count === 1) { 55 | expect(hello).eq('world'); 56 | } 57 | if(count === 2) { 58 | expect(hello).eq('dude'); 59 | done(); 60 | } 61 | }); 62 | 63 | resource.set('location.params.message', 'dude'); 64 | }); 65 | 66 | it('default post does http post', function() { 67 | }); 68 | 69 | it('default get does http get', function() { 70 | }); 71 | 72 | }); 73 | -------------------------------------------------------------------------------- /lib/elements/page_links.js: -------------------------------------------------------------------------------- 1 | module.exports = function(ui,attrs) { 2 | 3 | // TODO: Should this be a generic thing? 4 | // Maybe this elem exists outside of page, 5 | // but nesting only makes "page" default to parent 6 | ui.data.synth('page', attrs.page || 'parent.page'); 7 | 8 | ui.rootElement.addEventListener('click', function(event) { 9 | ui.data.set('page.number', event.target.getAttribute('data-page')); 10 | ui.data.set('selected', event.target); 11 | }); 12 | 13 | ui.data.synth('links', 'page.total page.size page.number', function(total, size, n) { 14 | if(total <= size) return ''; 15 | 16 | n = parseInt(n); 17 | 18 | var result = ''; 19 | var l = Math.ceil(total/size); 20 | var max = 5; 21 | var start = Math.max(1, n - max); 22 | var end = Math.min(start+l, start+max); 23 | var hops = {}; 24 | 25 | if(start > 1) { 26 | hops[1] = start+1; 27 | } 28 | 29 | if(end < l) { 30 | hops[end] = l-1; 31 | } 32 | 33 | if(l > 1) { 34 | for(var i = 1; i <= l; ++i) { 35 | if(i === n) { 36 | result += ''+i+''; 37 | } 38 | else { 39 | result += ''+i+''; 40 | } 41 | 42 | if(hops[i]) { 43 | result += '' 44 | i = hops[i]; 45 | } 46 | } 47 | } 48 | 49 | if(n !== 1) { 50 | result = 'Prev' + result; 51 | } 52 | else { 53 | result = 'Prev' + result; 54 | } 55 | 56 | if(n < l) { 57 | var i = n+1; 58 | result += 'Next'; 59 | } 60 | else { 61 | result += 'Next'; 62 | } 63 | 64 | return result; 65 | }); 66 | }; 67 | 68 | module.exports.template = ''; 69 | -------------------------------------------------------------------------------- /components/component-domify/index.js: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Expose `parse`. 4 | */ 5 | 6 | module.exports = parse; 7 | 8 | /** 9 | * Wrap map from jquery. 10 | */ 11 | 12 | var map = { 13 | option: [1, ''], 14 | optgroup: [1, ''], 15 | legend: [1, '
', '
'], 16 | thead: [1, '', '
'], 17 | tbody: [1, '', '
'], 18 | tfoot: [1, '', '
'], 19 | colgroup: [1, '', '
'], 20 | caption: [1, '', '
'], 21 | tr: [2, '', '
'], 22 | td: [3, '', '
'], 23 | th: [3, '', '
'], 24 | col: [2, '', '
'], 25 | _default: [0, '', ''] 26 | }; 27 | 28 | /** 29 | * Parse `html` and return the children. 30 | * 31 | * @param {String} html 32 | * @return {Array} 33 | * @api private 34 | */ 35 | 36 | function parse(html) { 37 | if ('string' != typeof html) throw new TypeError('String expected'); 38 | 39 | // tag name 40 | var m = /<([\w:]+)/.exec(html); 41 | if (!m) throw new Error('No elements were generated.'); 42 | var tag = m[1]; 43 | 44 | // body support 45 | if (tag == 'body') { 46 | var el = document.createElement('html'); 47 | el.innerHTML = html; 48 | return el.removeChild(el.lastChild); 49 | } 50 | 51 | // wrap map 52 | var wrap = map[tag] || map._default; 53 | var depth = wrap[0]; 54 | var prefix = wrap[1]; 55 | var suffix = wrap[2]; 56 | var el = document.createElement('div'); 57 | el.innerHTML = prefix + html + suffix; 58 | while (depth--) el = el.lastChild; 59 | 60 | var els = el.children; 61 | if (1 == els.length) { 62 | return el.removeChild(els[0]); 63 | } 64 | 65 | var fragment = document.createDocumentFragment(); 66 | while (els.length) { 67 | fragment.appendChild(el.removeChild(els[0])); 68 | } 69 | 70 | return fragment; 71 | } 72 | -------------------------------------------------------------------------------- /lib/events.js: -------------------------------------------------------------------------------- 1 | var utils = require('./utils'); 2 | 3 | module.exports = Events; 4 | 5 | /** 6 | * Simple lightweight event emmitter 7 | */ 8 | 9 | function Events(){}; 10 | 11 | Events.prototype = { 12 | on: function(events, fct){ 13 | if(!this.hasOwnProperty('_events')) { 14 | this._events = {}; 15 | } 16 | 17 | var split = events.split(' '); 18 | for(var i = 0, l = split.length; i 1) { 51 | for(var i = 0, l = split.length; i < l; ++i) { 52 | event = split[i]; 53 | this.trigger.apply(this, arguments); 54 | } 55 | return; 56 | } 57 | 58 | if( event in this._events === false ) return; 59 | var i = this._events[event].length, ret; 60 | while(i-- && ret !== false) { 61 | ret = this._events[event][i].apply(this, Array.prototype.slice.call(arguments, 1)); 62 | } 63 | 64 | return ret; 65 | } 66 | }; 67 | 68 | Events.mixin = function(destObject){ 69 | var props = ['on', 'once', 'unbind', 'trigger']; 70 | var proto = destObject.prototype || destObject; 71 | for(var i = 0; i < props.length; i ++){ 72 | proto[props[i]] = this.prototype[props[i]]; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | var utils = require('../lib/utils.js'), 2 | expect = require('chai').expect; 3 | 4 | describe('utils#traverse', function(){ 5 | it('accesses simple direct properties', function() { 6 | var test = {a: 123}; 7 | expect( utils.traverse(test, 'a') ).eq(123); 8 | }); 9 | 10 | it('accesses deeply nested properties', function() { 11 | var test = {a: {b: 123}}; 12 | expect( utils.traverse(test, 'a.b') ).eq(123); 13 | }); 14 | 15 | it('returns undefined for non-existent paths', function() { 16 | var test = {a: {b: 123}}; 17 | expect( utils.traverse(test, 'a.c') ).eq(undefined); 18 | }); 19 | 20 | it('returns undefined when non-object nodes in path', function() { 21 | var test = {a: undefined}; 22 | expect( utils.traverse(test, 'a.b') ).eq(undefined); 23 | }); 24 | 25 | it('calls function for every object in path', function() { 26 | var test = {a: {b: 123}}; 27 | var sequence = [ 28 | [test, 'a', 'b.c'], 29 | [test.a, 'b', 'c'], 30 | [test.a.b, 'c', false] 31 | ]; 32 | 33 | var i = 0; 34 | utils.traverse(test, 'a.b.c', function(obj,key, 35 | nearestEmitter, shortestPath, remaining) { 36 | expect( [obj, key, remaining] ).eql( sequence[i++] ); 37 | }); 38 | }); 39 | 40 | it('passes nearest emitter and path from it', function() { 41 | var test = { 42 | a: { 43 | trigger: function(){}, 44 | b: { 45 | c: 123 46 | } 47 | } 48 | }; 49 | 50 | var sequence = [ 51 | [test, 'a', undefined, undefined, 'b.c'] 52 | , [test.a, 'b', test.a, 'b', 'c'] 53 | , [test.a.b, 'c', test.a, 'b.c', false] 54 | ]; 55 | 56 | var i = 0; 57 | utils.traverse(test, 'a.b.c', function(deepObj, deepAttr, 58 | nearestEmitter, shortestPath, remaining) { 59 | expect( arguments ).eql( sequence[i++] ); 60 | }); 61 | }); 62 | 63 | it('stops traversal and returns current value if callback returns false', function() { 64 | var test = {a: {b: 123}}; 65 | 66 | var count = 0; 67 | var result = utils.traverse(test, 'a.b', function() { 68 | expect(count).eq(0); 69 | count++; 70 | return false; 71 | }); 72 | 73 | expect(result).eq(test); 74 | }); 75 | }); 76 | 77 | -------------------------------------------------------------------------------- /test/browser/element.test.js: -------------------------------------------------------------------------------- 1 | describe('Via.Element', function() { 2 | it('accepts dom element as template using its innerHTML', function() { 3 | var elem = document.createElement('test'); 4 | var ui = new Via.Element({}, elem); 5 | expect(ui.rootElement.outerHTML).eq(''); 6 | }); 7 | 8 | it('uses default template defined on custom element', function() { 9 | var custom = function() {}; 10 | custom.template = '
'; 11 | var template = ''; 12 | var ui = new Via.Element({}, {elements: {custom: custom}, template: template}); 13 | expect(ui.rootElement.outerHTML).eq(custom.template); 14 | }); 15 | 16 | it('can be replaced directly with another custom element', function() { 17 | var a = function() {}; 18 | a.template = '
'; 19 | var b = function() {}; 20 | b.template = '
'; 21 | 22 | 23 | var template = ''; 24 | var ui = new Via.Element({}, {elements: {a:a,b:b}, template: template}); 25 | expect(ui.rootElement.outerHTML).eq(b.template); 26 | }); 27 | 28 | it('accepts template as second argument', function() { 29 | var template = ''; 30 | var ui = new Via.Element({}, template); 31 | expect(ui.rootElement.outerHTML).eq(template); 32 | }); 33 | 34 | it('can handle descendent custom elements', function() { 35 | var template = '
'; 36 | var custom = function() { 37 | return '
'; 38 | }; 39 | var ui = new Via.Element({}, {elements: {custom: custom}, template: template}); 40 | expect(ui.rootElement.outerHTML).eq('
'); 41 | }); 42 | 43 | it('can handle custom attrs on root element', function() { 44 | var template = '
'; 45 | var ui = new Via.Element({test:123}, template); 46 | expect(ui.rootElement.outerHTML).eq('
123
'); 47 | }); 48 | 49 | it('can handle custom element as root', function() { 50 | var template = ''; 51 | var custom = function(ui, data) { 52 | return '
'; 53 | }; 54 | var ui = new Via.Element({}, {elements: {custom: custom}, template: template}); 55 | expect(ui.rootElement.outerHTML).eq('
'); 56 | }); 57 | 58 | it('synthesizes element data from attributes', function() { 59 | var template = ''; 60 | var custom = function(ui, data) { 61 | return '
'; 62 | }; 63 | var ui = new Via.Element({parent:{path: 'value'}}, {elements: {custom: custom}, template: template}); 64 | expect(ui.data.a).eq('value'); 65 | expect(ui.data.b).eq('literal'); 66 | }); 67 | 68 | }); 69 | -------------------------------------------------------------------------------- /test/uri.test.js: -------------------------------------------------------------------------------- 1 | describe('Via.URI', function() { 2 | var Via = require('../lib/via'), 3 | expect = require('chai').expect; 4 | 5 | var uri; 6 | beforeEach(function() { 7 | uri = new Via.URI({}); 8 | }); 9 | 10 | // it('parses authority', function() { 11 | // uri.set('authority', '//user:pass@example.net'); 12 | // expect(uri.host).eq('example.net'); 13 | // console.log(uri); 14 | // expect(uri.credentials).eq('user:pass'); 15 | // uri.set('authority', '//apikey@example.net'); 16 | // expect(uri.credentials).eq('apikey'); 17 | // uri.set('authority', 'missingslashes.net'); 18 | // expect(uri.host).eq('missingslashes.net'); 19 | // expect(uri.authority).eq('//missingslashes.net'); 20 | // }); 21 | 22 | // it('builds authority', function() { 23 | // uri.set({ 24 | // credentials: 'user:pass' 25 | // , host: 'example.net' 26 | // }); 27 | 28 | // expect(uri.authority).eq('//user:pass@example.net'); 29 | // }); 30 | 31 | 32 | // it('host via hostname and port', function() { 33 | // uri.set({ 34 | // hostname: 'example.net' 35 | // , port: '8080' 36 | // }); 37 | // expect(uri.host).eq('example.net:8080'); 38 | // }); 39 | 40 | // it('host via hostname with no port', function() { 41 | // uri.set({ 42 | // hostname: 'example.net' 43 | // }); 44 | // expect(uri.host).eq('example.net'); 45 | // }); 46 | // 47 | // it('hostname and port via host', function() { 48 | // uri.set('host', 'example.net:8080'); 49 | // expect(uri.hostname).eq('example.net'); 50 | // expect(uri.port).eq('8080'); 51 | // }); 52 | 53 | it('query via search', function() { 54 | uri.set('search', '?a=1#fragment'); 55 | expect(uri.query).eq('a=1'); 56 | }); 57 | 58 | it('search via query', function() { 59 | uri.set('query', 'a=1'); 60 | expect(uri.search).eq('?a=1'); 61 | }); 62 | 63 | it('query via params', function() { 64 | uri.set('params', {a:1,b:2}); 65 | expect(uri.query).eq('a=1&b=2'); 66 | }); 67 | 68 | it('params via query', function() { 69 | uri.set('query', 'a=1&b=2'); 70 | expect(uri.params).eql({a:'1',b:'2'}); 71 | }); 72 | 73 | it('parses href', function() { 74 | uri.set('href', 'http://user:pass@example.net:8080/oh/hi.txt?q=there#lol'); 75 | expect(uri.scheme).eq('http'); 76 | expect(uri.authority).eq('user:pass@example.net:8080'); 77 | expect(uri.path).eq('/oh/hi.txt'); 78 | expect(uri.query).eq('q=there'); 79 | expect(uri.search).eq('?q=there'); 80 | expect(uri.fragment).eq('lol'); 81 | }); 82 | 83 | it('builds href', function() { 84 | uri.set({ 85 | scheme: 'http' 86 | , authority: 'user:pass@example.net:8080' 87 | , path: '/oh/hi.txt' 88 | , query: 'q=there' 89 | , fragment: 'lol' 90 | }); 91 | expect(uri.href).eq('http://user:pass@example.net:8080/oh/hi.txt?q=there#lol'); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /lib/uri.js: -------------------------------------------------------------------------------- 1 | module.exports = ReactiveURI; 2 | 3 | var ReactiveObject = require('./object'), 4 | utils = require('./utils'); 5 | 6 | // RFC 3986 Appendix B 7 | var regex = new RegExp("^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?"); 8 | // scheme = $2 9 | // authority = $4 10 | // path = $5 11 | // query = $7 12 | // fragment = $9 13 | 14 | function ReactiveURI(init) { 15 | if(typeof init === "string") init = {href: init}; 16 | 17 | this.set(init); 18 | 19 | this.synth('href', 'scheme authority path query? fragment?', 20 | function(scheme, authority, path, query, fragment) { 21 | // RFC 3986 Section 5.3 22 | var result = ''; 23 | 24 | if(scheme) 25 | result += scheme + ':'; 26 | if(authority) 27 | result += '//' + authority; 28 | result += path; 29 | if(query) 30 | result += '?' + query; 31 | if(fragment) 32 | result += '#' + fragment; 33 | 34 | return result; 35 | }); 36 | 37 | this.synth('scheme authority path query fragment', 'href', 38 | function(href) { 39 | var m = regex.exec(href); 40 | return [m[2], m[4], m[5], m[7], m[9]]; 41 | }); 42 | 43 | this.synth('path', 'dir file ext?', function(dir, file, ext) { 44 | return [dir, file].join('') + (ext ? '.' + ext : ''); 45 | }); 46 | 47 | this.synth('dir file ext', 'path', function(path) { 48 | var parts = path.split('/'); 49 | var file = parts[parts.length-1]; 50 | if(file) { 51 | parts.pop(); 52 | parts.push(''); 53 | return [parts.join('/')].push( file.split('.').slice(-1) ); 54 | } 55 | 56 | return [parts.join('/')]; 57 | }); 58 | 59 | this.synth('authority', 'credentials host', function(c,h) { 60 | c = c && (c + '@') || ''; 61 | return c + h; 62 | }); 63 | 64 | this.synth('credentials host', 'authority', function(auth) { 65 | var match = auth.match(/(?:(.+?)@)?(.*)/); 66 | return match && match.slice(1); 67 | }); 68 | 69 | this.synth('query', 'search', function(search, prev) { 70 | var match = (''+search).match(/\?(.+?)(?:#|$)/); 71 | return match && match[1] || ''; 72 | }); 73 | 74 | this.synth('search', 'query', function(query) { 75 | if(!query) return ''; 76 | return '?'+query; 77 | }); 78 | 79 | this.synth('query', 'params', function(params) { 80 | var str = []; 81 | for(var p in params) { 82 | if(params[p] !== undefined) { 83 | str.push(encodeURIComponent(p) + "=" + encodeURIComponent(params[p])); 84 | } 85 | } 86 | return str.join("&"); 87 | }); 88 | 89 | this.synth('params', 'query', function(query) { 90 | if(!query) return {}; 91 | 92 | query = query.replace('+',' '); 93 | 94 | var params = {}, tokens, 95 | re = /[?&]?([^=]+)=([^&]*)/g; 96 | 97 | while (tokens = re.exec(query)) { 98 | params[decodeURIComponent(tokens[1])] 99 | = decodeURIComponent(tokens[2]); 100 | } 101 | 102 | return params; 103 | }); 104 | 105 | } 106 | 107 | ReactiveURI.prototype = new ReactiveObject({ 108 | rel: function(path) { 109 | var result = new ReactiveURI({source:this}); 110 | this.watch('href', function(href) { 111 | result.set('href', href); 112 | }); 113 | result.synth('path', 'source.path', function(base) { 114 | return absolute(base, path); 115 | }); 116 | return result; 117 | } 118 | }); 119 | 120 | function absolute(base, relative) { 121 | var stack = base.split("/"), 122 | parts = relative.split("/"); 123 | stack.pop(); // remove current file name (or empty string) 124 | // (omit if "base" is the current folder without trailing slash) 125 | for (var i=0; i 2.055555555555556 61 | 62 | ``` 63 | 64 | This also provides us with a means of separating concerns, where 65 | independent parts of code can manipulate each other's 66 | data through a common interface. 67 | 68 | e.g. We can independently model a resistor: 69 | 70 | ``` 71 | function Resistor(init) { 72 | this.set(init); 73 | 74 | this.synth('color_bands', 'resistance', function(i) { 75 | // ... 76 | }); 77 | 78 | this.synth('resistance', 'color_bands', function(i) { 79 | // ... 80 | }); 81 | 82 | this.synth('heat', 'current resistance', function(i,r) { 83 | return i*i*r; 84 | }); 85 | } 86 | Resistor.prototype = new Via.Object(); 87 | 88 | var circuit = new Via.Object({ 89 | conductor: new Conductor() 90 | , resistor = new Resistor() 91 | }); 92 | circuit.synth('conductor.resistance', 'resistor.resistance'); 93 | ``` 94 | 95 | ## Reversible computing 96 | Synth can accept an additional function to convert the output 97 | back to the input. 98 | 99 | ``` 100 | synth('a', 'b', function(b) { return b*2; }, function(a) { return a/2; }); 101 | // No matter what changes, b will always be 1/2 of a 102 | ``` 103 | 104 | Additionally, when synth() is given a simple direct 1-1 binding, 105 | i.e. no function is given, and no information is lost. 106 | It will also do the reverse, and keep the input updated with the output. 107 | 108 | In this way synth() can be used to interlink data across libraries 109 | without any transformation. 110 | 111 | ## Unbinding 112 | Since all data flows are bound by specifying paths within an objected structure, 113 | breaking bindings is simply a matter of breaking that path. Via automatically cleans up watch() events when the path becomes unreachable. 114 | 115 | Using our circuit example from before: 116 | ``` 117 | circuit.set('resistor', false); 118 | ``` 119 | 120 | ## Testing 121 | $ make test 122 | 123 | 124 | ## License 125 | 126 | (MIT License) 127 | 128 | Copyright (C) 2013 by Emery Denuccio 129 | 130 | Permission is hereby granted, free of charge, to any person obtaining a copy 131 | of this software and associated documentation files (the "Software"), to deal 132 | in the Software without restriction, including without limitation the rights 133 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 134 | copies of the Software, and to permit persons to whom the Software is 135 | furnished to do so, subject to the following conditions: 136 | 137 | The above copyright notice and this permission notice shall be included in 138 | all copies or substantial portions of the Software. 139 | 140 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 141 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 142 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 143 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 144 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 145 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 146 | THE SOFTWARE. 147 | -------------------------------------------------------------------------------- /test/browser/page.test.js: -------------------------------------------------------------------------------- 1 | describe('', function() { 2 | var template = '
'; 3 | 4 | var data, ui; 5 | 6 | beforeEach(function() { 7 | // FIXME: page has to be a Via.Object for links and info to be bound 8 | // It's counter-intuitive, with many possible solutions 9 | data = {page: new Via.Object({ 10 | total: 3 11 | })}; 12 | 13 | ui = new Via.Element(data, template); 14 | }); 15 | 16 | context('[size=1]', function() { 17 | beforeEach(function() { 18 | ui.data.set('page.size', 1) 19 | }); 20 | 21 | context('page 1 of 3', function() { 22 | beforeEach(function() { 23 | ui.data.set('page.number', 1) 24 | }); 25 | 26 | it('has correct info', function() { 27 | expect(ui.data.get('info')).eq('Showing 1-1 of 3'); 28 | }); 29 | 30 | it('has correct links', function() { 31 | var linksui = $(ui.rootElement).find('.page_links')[0]._viaElement; 32 | var html = 'Prev123Next'; 33 | expect(linksui.data.get('links')).eq(html); 34 | }); 35 | }); 36 | 37 | context('page 2 of 3', function() { 38 | beforeEach(function() { 39 | ui.data.set('page.number', 2) 40 | }); 41 | 42 | it('has correct info', function() { 43 | expect(ui.data.get('info')).eq('Showing 2-2 of 3'); 44 | }); 45 | 46 | it('has correct links', function() { 47 | var linksui = $(ui.rootElement).find('.page_links')[0]._viaElement; 48 | var html = 'Prev123Next'; 49 | expect(linksui.data.get('links')).eq(html); 50 | }); 51 | }); 52 | 53 | context('page 3 of 3', function() { 54 | beforeEach(function() { 55 | ui.data.set('page.number', 3) 56 | }); 57 | 58 | it('has correct info', function() { 59 | expect(ui.data.get('info')).eq('Showing 3-3 of 3'); 60 | }); 61 | 62 | it('has correct links', function() { 63 | var linksui = $(ui.rootElement).find('.page_links')[0]._viaElement; 64 | var html = 'Prev123Next'; 65 | expect(linksui.data.get('links')).eq(html); 66 | }); 67 | }); 68 | }); 69 | 70 | context('[size=2]', function() { 71 | beforeEach(function() { 72 | ui.data.set('page.size', 2) 73 | }); 74 | 75 | context('page 1 of 2', function() { 76 | beforeEach(function() { 77 | ui.data.set('page.number', 1) 78 | }); 79 | 80 | it('has correct info', function() { 81 | expect(ui.data.get('info')).eq('Showing 1-2 of 3'); 82 | }); 83 | 84 | it('has correct links', function() { 85 | var linksui = $(ui.rootElement).find('.page_links')[0]._viaElement; 86 | var html = 'Prev12Next'; 87 | expect(linksui.data.get('links')).eq(html); 88 | }); 89 | }); 90 | 91 | context('page 2 of 2', function() { 92 | beforeEach(function() { 93 | ui.data.set('page.number', 2) 94 | }); 95 | 96 | it('has correct info', function() { 97 | expect(ui.data.get('info')).eq('Showing 3-3 of 3'); 98 | }); 99 | 100 | it('has correct links', function() { 101 | var linksui = $(ui.rootElement).find('.page_links')[0]._viaElement; 102 | var html = 'Prev12Next'; 103 | expect(linksui.data.get('links')).eq(html); 104 | }); 105 | }); 106 | }); 107 | 108 | context('[size=3]', function() { 109 | beforeEach(function() { 110 | ui.data.set('page.size', 3) 111 | }); 112 | 113 | it('has correct info', function() { 114 | expect(ui.data.get('info')).eq('Showing 3 of 3'); 115 | }); 116 | 117 | it('has correct links', function() { 118 | var linksui = $(ui.rootElement).find('.page_links')[0]._viaElement; 119 | var html = ''; 120 | expect(linksui.data.get('links')).eq(html); 121 | }); 122 | }); 123 | 124 | context('[size=1000]', function() { 125 | var template = '
'; 126 | beforeEach(function() { 127 | ui.data.set('page.size', 1000) 128 | }); 129 | 130 | it('has correct info', function() { 131 | expect(ui.data.get('info')).eq('Showing 3 of 3'); 132 | }); 133 | 134 | it('has correct links', function() { 135 | var linksui = $(ui.rootElement).find('.page_links')[0]._viaElement; 136 | var html = ''; 137 | expect(linksui.data.get('links')).eq(html); 138 | }); 139 | }); 140 | }); 141 | -------------------------------------------------------------------------------- /lib/element.js: -------------------------------------------------------------------------------- 1 | module.exports = ReactiveElement; 2 | 3 | var ReactiveObject = require('./object') 4 | , Via = require('./via') 5 | , utils = require('./utils') 6 | , globalElements = require('./elements') 7 | , globalAttributes = require('./attributes'); 8 | 9 | /** 10 | * Represents a single UI instance for a model's ever changing data. 11 | */ 12 | function ReactiveElement(data, options) { 13 | options = options || {}; 14 | 15 | var parent = options.parent || this; 16 | var document = options.document || window.document; 17 | 18 | this.data = new ReactiveObject({ 19 | ui: this 20 | , url: Via.window.location 21 | }); 22 | 23 | function Elements() {} 24 | function Attributes() {} 25 | Elements.prototype = parent.elements; 26 | Attributes.prototype = parent.attributes; 27 | this.elements = new Elements(); 28 | this.attributes = new Attributes(); 29 | 30 | // Accept new custom elements from options 31 | if(options.elements) { 32 | this.elements.set(options.elements); 33 | } 34 | 35 | // Accept new custom elements from options 36 | if(options.attributes) { 37 | this.attributes.set(options.attributes); 38 | } 39 | 40 | // Allow string in lieu of options for a template 41 | if(typeof options === 'string') { 42 | options = {template: options}; 43 | } 44 | // Handle dom elements (anything with an outerHTML) 45 | else if(options && options.outerHTML) { 46 | options = {template: options}; 47 | } 48 | 49 | // If the template is an element, take the outerHTML 50 | if(options.template && options.template.outerHTML) { 51 | options.template = options.template.outerHTML; 52 | } 53 | 54 | // Accept direct data if it has a symbolic name 55 | // by assigning it to an empty object with a single 56 | // property of that name 57 | if(data.symbolicName) { 58 | var name = data.symbolicName(); 59 | this.data.set(name, data); 60 | } 61 | else if(typeof data === 'object') { 62 | this.data.set(data); 63 | } 64 | else { 65 | // fail 66 | } 67 | 68 | this.template = options.template; 69 | this.rootElement = utils.domify(this.template); 70 | 71 | var rootTagName = this.rootElement.tagName.toLowerCase(); 72 | var implFn = options.impl || this.elements[rootTagName]; 73 | 74 | if(implFn) { 75 | var elemattrs = {}; 76 | for(var i=0, attrs=this.rootElement.attributes, 77 | l=attrs.length; i < l; i++) { 78 | var attr = attrs.item(i) 79 | , k = attr.nodeName, v = attr.nodeValue; 80 | elemattrs[k] = v; 81 | 82 | this.data.synth(k, 'parent.'+v); 83 | 84 | // if(k === 'array') { debugger; } 85 | // Default any undefined values to the literal attribute value 86 | if(!this.data[k]) { 87 | this.data.set(k,v); 88 | } 89 | } 90 | 91 | 92 | if(this.rootElement.children.length) { 93 | this.template = this.rootElement.innerHTML; 94 | } 95 | else if(implFn.template) { 96 | this.template = implFn.template; 97 | } 98 | 99 | this.rootElement = utils.domify(this.template); 100 | 101 | var result = implFn.call(this, 102 | this, elemattrs, this.template); 103 | 104 | if(typeof result === 'string') { 105 | this.rootElement = utils.domify(result); 106 | } 107 | else if(result && result.nodeName) { 108 | this.rootElement = result; 109 | } 110 | } 111 | 112 | this.rootElement._viaElement = this; 113 | 114 | this.build(); 115 | return this; 116 | }; 117 | 118 | ReactiveElement.find = function(domElement) { 119 | return domElement && domElement._viaElement; 120 | }; 121 | 122 | ReactiveElement.prototype = new ReactiveObject({ 123 | build: function(options) { 124 | var self = this; 125 | 126 | // TODO: More efficient approach using querySelectorAll if available 127 | function recurseChildren(node) { 128 | var tagName = node.tagName && node.tagName.toLowerCase(); 129 | 130 | if(self.elements[tagName]) { 131 | var template = node.outerHTML; 132 | var newElem = new ReactiveElement({parent: self.data}, {template: template, parent: self}); 133 | if(node === self.rootElement) { 134 | self.rootElement = newElem.rootElement; 135 | } 136 | else { 137 | node.parentNode.insertBefore(newElem.rootElement, node); 138 | node.parentNode.removeChild(node); 139 | } 140 | } 141 | else { 142 | for (var i = 0; i < node.childNodes.length; i++) { 143 | var child = node.childNodes[i]; 144 | recurseChildren(child); 145 | } 146 | } 147 | } 148 | 149 | recurseChildren(this.rootElement); 150 | 151 | // Any data- attributes we find in the template but don't have handlers for. 152 | // Make them map to the equivalent non-data attribute of the same name. 153 | var matchedTplAttrs = this.rootElement.outerHTML; 154 | matchedTplAttrs = matchedTplAttrs && matchedTplAttrs.match(/data-(\w+)[^\w]/gi) || []; 155 | matchedTplAttrs = utils.map(matchedTplAttrs, function(match) { 156 | return match.match(/-(\w+)/)[1]; } ); 157 | utils.each(matchedTplAttrs, function(i,attrName) { 158 | if(!self.attributes[attrName]) { 159 | self.attributes[attrName] = function(ui, keypath) { 160 | var self = this; 161 | ui.data.watch(keypath, function(value) { 162 | self.setAttribute(attrName, value); 163 | }); 164 | } 165 | } 166 | }); 167 | 168 | // This is a temporary hack for data-list 169 | // Run attributes that use the third template argument last 170 | // See issue #34 171 | for(var i = 3; i > 1; --i) { 172 | 173 | // Handle all custom attribute in template 174 | for(var attrName in this.attributes) { 175 | var impl = this.attributes[attrName]; 176 | 177 | if(typeof impl !== 'function') continue; 178 | if(impl.length !== i) continue; 179 | 180 | function buildAttr() { 181 | var value = this.getAttribute('data-'+attrName); 182 | var template = this.innerHTML; 183 | self.attributes[attrName].call(this, self, value, template); 184 | this.removeAttribute('data-'+attrName); 185 | } 186 | 187 | if(this.rootElement.getAttribute('data-'+attrName)) { 188 | buildAttr.call(this.rootElement); 189 | } 190 | 191 | var found = this.rootElement.querySelectorAll('[data-'+attrName+']'); 192 | for(var i2=0, l2 = found.length; i2 < l2; ++i2) { 193 | buildAttr.call(found[i2]); 194 | } 195 | } 196 | } 197 | } 198 | , elements: new ReactiveObject(globalElements) 199 | , attributes: new ReactiveObject(globalAttributes) 200 | }); 201 | -------------------------------------------------------------------------------- /test/object.test.js: -------------------------------------------------------------------------------- 1 | var Via = require('../lib/via'), 2 | expect = require('chai').expect; 3 | 4 | describe('Via.Object', function() { 5 | var cat; 6 | beforeEach(function() { 7 | cat = new Via.Object({ 8 | first_name: 'Fluffy', 9 | last_name: 'Brown' 10 | }); 11 | }); 12 | 13 | describe('#get', function() { 14 | it('gets simple direct attributes', function() { 15 | expect(cat.get('first_name')).eq('Fluffy'); 16 | }); 17 | 18 | it('accepts callback to require arguments ', function() { 19 | expect(cat.get('first_name')).eq('Fluffy'); 20 | }); 21 | }); 22 | 23 | 24 | describe('#set', function() { 25 | it('accepts simple key/value arguments', function() { 26 | cat.set('hello', 'world'); 27 | expect(cat.hello).eq('world'); 28 | }); 29 | 30 | it('accepts object and sets for each property', function() { 31 | cat.set({ 32 | hello: 'world' 33 | , goodbye: 'cruel world' 34 | }); 35 | expect(cat.hello).eq('world'); 36 | expect(cat.goodbye).eq('cruel world'); 37 | }); 38 | 39 | it('accepts space separated list of keys and array of values', function() { 40 | cat.set('hello goodbye', ['world', 'cruel world']); 41 | expect(cat.hello).eq('world'); 42 | expect(cat.goodbye).eq('cruel world'); 43 | }); 44 | }); 45 | 46 | describe('#watch', function() { 47 | it('walks keypaths', function(done) { 48 | var stray = new Via.Object({best_friend: cat}); 49 | 50 | stray.watch('best_friend.first_name', function(first_name) { 51 | expect(first_name).eq(cat.first_name); 52 | done(); 53 | }); 54 | }); 55 | 56 | it('callsback immediately', function() { 57 | var called; 58 | cat.watch('first_name', function(first) { 59 | expect(first).eq('Fluffy'); 60 | called = true; 61 | }); 62 | expect(called).eq(true); 63 | }); 64 | 65 | it('only calls if all arguments are defined', function() { 66 | var called = false; 67 | cat.watch('a b c', function() { 68 | called = true; 69 | }); 70 | expect(called).eq(false); 71 | cat.set({a: 1, b:2}); 72 | expect(called).eq(false); 73 | cat.set({c: 3}); 74 | expect(called).eq(true); 75 | }); 76 | 77 | it('adds only one set event per input', function() { 78 | var preEventCount = Object.keys(cat._events).length; 79 | cat.watch('cat_code first_name', function(){}); 80 | expect(cat._events['set:cat_code'].length).eq(1); 81 | var postEventCount = Object.keys(cat._events).length; 82 | expect(postEventCount - preEventCount).eq(2); 83 | }); 84 | 85 | it('cleans up events on old subpaths when high level nodes change', function() { 86 | var strayA = new Via.Object({name: 'Fluffy'}); 87 | var strayB = new Via.Object({name: 'Scratch'}); 88 | var obj = new Via.Object({friend: strayA}); 89 | obj.watch('friend.name', function() {}); 90 | expect(strayA._events['set:name'].length).eq(1); 91 | expect(strayB._events['set:name']).eq(undefined); 92 | obj.set('friend', strayB); 93 | expect(strayA._events['set:name'].length).eq(0); 94 | expect(strayB._events['set:name'].length).eq(1); 95 | }); 96 | 97 | it('callsback on set', function(done) { 98 | cat.watch('cat_code', function(code) { 99 | expect(code).eq('test'); 100 | done(); 101 | }); 102 | 103 | cat.set('cat_code', 'test'); 104 | }); 105 | 106 | it('constructs callback arguments from input values', function(done) { 107 | cat.watch('first_name last_name', function(first,last) { 108 | expect(first).eq('Fluffy'); 109 | expect(last).eq('Brown'); 110 | done(); 111 | }); 112 | }); 113 | 114 | it('monitors any change on the model if no attribute specified', function(done) { 115 | cat.watch(function(arg) { 116 | expect(arg).eq(undefined); 117 | expect(this).eq(cat); 118 | done(); 119 | }); 120 | }); 121 | 122 | it('interpolates strings with embedded {keypath} notation', function(done) { 123 | cat.watch('Hello, {first_name}', function(greeting) { 124 | expect(greeting).eq('Hello, Fluffy'); 125 | done(); 126 | }); 127 | }); 128 | 129 | it('requires all inputs to be defined', function() { 130 | var called = false; 131 | cat.watch('first_name something_undefined', function(a,b) { 132 | called = true; 133 | }); 134 | 135 | expect(called).eq(false); 136 | }); 137 | 138 | it('allows undefined inputs with a "?" suffix', function(done) { 139 | cat.watch('first_name something_undefined?', function(a,b) { 140 | expect(b).eq(undefined); 141 | done(); 142 | }); 143 | }); 144 | 145 | it('forces literals with "!" suffix', function(done) { 146 | cat.watch('first_name 10!', function(name,num) { 147 | expect(name).eq('Fluffy'); 148 | expect(num).eq('10'); 149 | done(); 150 | }); 151 | }); 152 | 153 | describe('#stop', function() { 154 | it('removes all events', function() { 155 | var obj = new Via.Object({name: 'Fluffy'}); 156 | var mtr = obj.watch('name', function(){}); 157 | mtr.stop(); 158 | expect(obj._events['set:name'].length).eq(0); 159 | }); 160 | 161 | it('does not propagate undefined', function(done) { 162 | var obj = new Via.Object({name: 'Fluffy'}); 163 | obj.watch('name', function(name){ 164 | expect(name).eq('Fluffy'); 165 | done(); // Only once! 166 | this.stop(); 167 | }); 168 | }); 169 | }); 170 | }); 171 | 172 | describe('#synth', function() { 173 | it('sets resultant attribute', function() { 174 | var obj = new Via.Object({first_name: 'Fluffy', last_name: 'Brown'}); 175 | obj.synth('full_name', 'first_name last_name', function(first,last) { 176 | return first + ' ' + last; 177 | }); 178 | expect( obj.get('full_name') ).eq('Fluffy Brown'); 179 | }); 180 | 181 | it('defaults to straight equality', function() { 182 | var obj = new Via.Object({a:1}); 183 | obj.synth('b','a'); 184 | expect(obj.get('b')).eq(1); 185 | }); 186 | 187 | it('defaults to reverse straight equality', function() { 188 | var obj = new Via.Object({a:1}); 189 | obj.synth('b','a'); 190 | obj.set('b',2); 191 | expect(obj.get('a')).eq(2); 192 | }); 193 | 194 | 195 | it('can handle sets', function() { 196 | var obj = new Via.Object({a:1}); 197 | obj.synth('b c','a', function(c) { 198 | return [c+1,c+2]; 199 | }); 200 | 201 | expect(obj.b).eq(2); 202 | expect(obj.c).eq(3); 203 | }); 204 | 205 | it('can handle reversed sets', function() { 206 | var obj = new Via.Object({a:4}); 207 | obj.synth('a','b c', function(b,c) { 208 | return b+c; 209 | }, function(a) { 210 | return [a/2,a/2]; 211 | }); 212 | 213 | expect(obj.a).eq(4); 214 | expect(obj.b).eq(2); 215 | expect(obj.c).eq(2); 216 | }); 217 | 218 | }); 219 | 220 | }); 221 | 222 | 223 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | var utils = module.exports = {}; 2 | 3 | /** 4 | * Find a nested property from a object with a "keypath" 5 | * e.g. traverse({foo: {bar: 123}}, 'foo.bar'); 6 | * The last argument is a function that will be call 7 | * for every object in the path that receives: 8 | * 9 | * deepObj: The parent object 10 | * deepKey: The key of the next child object 11 | * nearestEmitter: Nearest ancestor that can trigger events 12 | * shortestPath: Path from nearestEmitter to deepObj 13 | * remainingPath: The remaining keypath from this object down 14 | * 15 | * Returning false from the callback stops traversal. 16 | */ 17 | utils.traverse = function(obj, keypath, fn) { 18 | if(!keypath) return; 19 | 20 | var arr = keypath.split('.'); 21 | var k; 22 | 23 | var nearestEmitter; 24 | var shortestPath; 25 | 26 | for(var i=0,l=arr.length; i < l; ++i) { 27 | k = arr[i]; 28 | 29 | if(fn && obj && typeof obj !== 'undefined') { 30 | if(obj.trigger) { 31 | nearestEmitter = obj; 32 | shortestPath = k; 33 | } 34 | else if(nearestEmitter) { 35 | shortestPath += '.' + k; 36 | } 37 | 38 | var keepGoing = fn(obj, k, 39 | nearestEmitter, 40 | shortestPath, 41 | (i+1 < l ? arr.slice(i+1).join('.') : false)); 42 | if(keepGoing === false) break; 43 | } 44 | 45 | if(obj && Object.prototype.hasOwnProperty.call(obj, k)){ 46 | obj = obj[k]; 47 | } 48 | else { 49 | return; 50 | } 51 | } 52 | 53 | return obj; 54 | } 55 | 56 | var _class2type = {}; 57 | 58 | var _type = function( obj ) { 59 | return obj == null ? 60 | String( obj ) : 61 | _class2type[ toString.call(obj) ] || "object"; 62 | }; 63 | 64 | var _isWindow = function( obj ) { 65 | return obj != null && obj == obj.window; 66 | }; 67 | 68 | var _isFunction = function(obj){ 69 | return typeof obj === "function"; 70 | }; 71 | 72 | 73 | utils.inArray = function( elem, arr, i ) { 74 | var len, 75 | core_indexOf = Array.prototype.indexOf; 76 | 77 | if ( arr ) { 78 | if ( core_indexOf ) { 79 | return core_indexOf.call( arr, elem, i ); 80 | } 81 | 82 | len = arr.length; 83 | i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0; 84 | 85 | for ( ; i < len; i++ ) { 86 | // Skip accessing in sparse arrays 87 | if ( i in arr && arr[ i ] === elem ) { 88 | return i; 89 | } 90 | } 91 | } 92 | 93 | return -1; 94 | }; 95 | 96 | /** 97 | * Based on jQuery.isArray 98 | */ 99 | var _isArray = Array.isArray || function( obj ) { 100 | return _type(obj) === "array"; 101 | }; 102 | 103 | 104 | /** 105 | * Based on jQuery.isPlainObject 106 | */ 107 | var _isPlainObject = function( obj ) { 108 | 109 | return typeof obj === "object"; 110 | 111 | // Must be an Object. 112 | // Because of IE, we also have to check the presence of the constructor property. 113 | // Make sure that DOM nodes and window objects don't pass through, as well 114 | if ( !obj || _type(obj) !== "object" || obj.nodeType || _isWindow( obj ) ) { 115 | return false; 116 | } 117 | 118 | try { 119 | // Not own constructor property must be Object 120 | if ( obj.constructor && 121 | !hasOwn.call(obj, "constructor") && 122 | !hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) { 123 | return false; 124 | } 125 | } catch ( e ) { 126 | // IE8,9 Will throw exceptions on certain host objects #9897 127 | return false; 128 | } 129 | 130 | // Own properties are enumerated firstly, so to speed up, 131 | // if last one is own, then all properties are own. 132 | 133 | var key; 134 | for ( key in obj ) {} 135 | 136 | return key === undefined || hasOwn.call( obj, key ); 137 | }; 138 | 139 | utils.isEmptyObject = function( obj ) { 140 | var name; 141 | for ( name in obj ) { 142 | return false; 143 | } 144 | return true; 145 | }; 146 | 147 | /** 148 | * Based on jQuery.each 149 | */ 150 | utils.each = function( obj, callback, args ) { 151 | if(typeof obj === 'undefined') 152 | return; 153 | 154 | var name, 155 | i = 0, 156 | length = obj.length, 157 | isObj = length === undefined || _isFunction( obj ); 158 | 159 | if ( args ) { 160 | if ( isObj ) { 161 | for ( name in obj ) { 162 | if ( callback.apply( obj[ name ], args ) === false ) { 163 | break; 164 | } 165 | } 166 | } else { 167 | for ( ; i < length; ) { 168 | if ( callback.apply( obj[ i++ ], args ) === false ) { 169 | break; 170 | } 171 | } 172 | } 173 | 174 | // A special, fast, case for the most common use of each 175 | } else { 176 | if ( isObj ) { 177 | for ( name in obj ) { 178 | if ( callback.call( obj[ name ], name, obj[ name ] ) === false ) { 179 | break; 180 | } 181 | } 182 | } else { 183 | for ( ; i < length; ) { 184 | if ( callback.call( obj[ i ], i, obj[ i++ ] ) === false ) { 185 | break; 186 | } 187 | } 188 | } 189 | } 190 | 191 | return obj; 192 | }; 193 | 194 | utils.each("Boolean Number String Function Array Date RegExp Object".split(" "), function(i, name) { 195 | _class2type[ "[object " + name + "]" ] = name.toLowerCase(); 196 | }); 197 | 198 | 199 | utils.map = function(obj,fn) { 200 | var result = []; 201 | utils.each(obj, function(i,v) { 202 | result[i] = fn(v); 203 | }); 204 | return result; 205 | }; 206 | 207 | /** 208 | * Based on jQuery.extend 209 | */ 210 | utils.extend = function() { 211 | var options, name, src, copy, copyIsArray, clone, 212 | target = arguments[0] || {}, 213 | i = 1, 214 | length = arguments.length, 215 | deep = false; 216 | 217 | // Handle a deep copy situation 218 | if ( typeof target === "boolean" ) { 219 | deep = target; 220 | target = arguments[1] || {}; 221 | // skip the boolean and the target 222 | i = 2; 223 | } 224 | 225 | // Handle case when target is a string or something (possible in deep copy) 226 | if ( typeof target !== "object" && !_isFunction(target) ) { 227 | // target = {}; 228 | } 229 | 230 | if ( length === i ) { 231 | target = this; 232 | --i; 233 | } 234 | 235 | for ( ; i < length; i++ ) { 236 | // Only deal with non-null/undefined values 237 | if ( (options = arguments[ i ]) != null ) { 238 | // Extend the base object 239 | for ( name in options ) { 240 | src = target[ name ]; 241 | copy = options[ name ]; 242 | 243 | // Prevent never-ending loop 244 | if ( target === copy ) { 245 | continue; 246 | } 247 | 248 | // Recurse if we're merging plain objects or arrays 249 | if ( deep && copy && ( _isPlainObject(copy) || (copyIsArray = _isArray(copy)) ) ) { 250 | if ( copyIsArray ) { 251 | copyIsArray = false; 252 | clone = src && _isArray(src) ? src : []; 253 | 254 | } else { 255 | clone = src && _isPlainObject(src) ? src : {}; 256 | } 257 | 258 | // Never move original objects, clone them 259 | target[ name ] = utils.extend( deep, clone, copy ); 260 | 261 | // Don't bring in undefined values 262 | } else if ( copy !== undefined ) { 263 | target[ name ] = copy; 264 | } 265 | } 266 | } 267 | } 268 | 269 | // Return the modified object 270 | return target; 271 | }; 272 | 273 | utils.domify = require('domify'); 274 | 275 | // TODO: Replace all uses with ReactiveURI 276 | utils.urlParams = function(params) { 277 | var str = []; 278 | for(var p in params) { 279 | if(params[p] !== undefined) { 280 | str.push(encodeURIComponent(p) + "=" + encodeURIComponent(params[p])); 281 | } 282 | } 283 | return str.join("&"); 284 | } 285 | -------------------------------------------------------------------------------- /lib/object.js: -------------------------------------------------------------------------------- 1 | module.exports = ReactiveObject; 2 | 3 | var Events = require('./events') 4 | , utils = require('./utils'); 5 | 6 | /** 7 | * Common model constructor 8 | * This gets called directly by more specific models 9 | */ 10 | function ReactiveObject(obj) { 11 | this.set(obj); 12 | }; 13 | 14 | ReactiveObject.prototype = { 15 | /** 16 | * Set a single attribute on the model 17 | * or an object of key-value pairs which gets 18 | * deep merged. 19 | * 20 | * Setting always triggers a "set:{key}" event 21 | * and a "changed" event with the key as an argument 22 | * against the nearest emitter 23 | * after each property is assigned. 24 | */ 25 | set: function(k,v) { 26 | var self = this; 27 | 28 | if(typeof k === 'undefined') return; 29 | 30 | function setAttr(target,k,newV) { 31 | // if(typeof newV === 'string') { 32 | // newV = target.substituteString(newV); 33 | // } 34 | 35 | var queue = []; 36 | var changed = false; 37 | 38 | utils.traverse(target, k, function(deepObj, deepAttr, 39 | nearestEmitter, shortestPath, remainingPath) { 40 | 41 | if(remainingPath && (!deepObj.hasOwnProperty(deepAttr) 42 | || typeof deepObj[deepAttr] === 'undefined')) { 43 | deepObj[deepAttr] = {}; 44 | changed = true; 45 | } 46 | 47 | var preV = deepObj[deepAttr]; 48 | 49 | if(!remainingPath) { 50 | deepObj[deepAttr] = newV; 51 | 52 | if(newV !== preV) { 53 | changed = true; 54 | } 55 | 56 | self._lastChangedProperty = k; 57 | self._lastChangedValue = preV; 58 | } 59 | 60 | queue.push([nearestEmitter, shortestPath, deepObj[deepAttr], preV]); 61 | }); 62 | 63 | // Bubble set and changed events for every node 64 | // in the path if the leaf actually changed. 65 | var e; 66 | if(changed) { 67 | while(e = queue.pop()) { 68 | e[0].trigger('set:'+e[1],e[2],e[3]); 69 | e[0].trigger('changed',e[1],e[2],e[3]); 70 | } 71 | } 72 | } 73 | 74 | if(typeof k === 'object') { 75 | function recurse(target,src) { 76 | for(var k in src) { 77 | var srcv = src[k]; 78 | if(src.hasOwnProperty(k)) { 79 | if(typeof srcv === 'object' && target.hasOwnProperty(k)) { 80 | recurse(target[k], srcv); 81 | } 82 | else { 83 | setAttr(target,k,srcv); 84 | } 85 | } 86 | } 87 | } 88 | 89 | 90 | recurse(this,k); 91 | } 92 | else { 93 | k = ''+k; 94 | var split = k.split(' '); 95 | 96 | if(split.length === 1) { 97 | setAttr(this, k, v); 98 | } 99 | else { 100 | for(var i=0; i < split.length; ++i) { 101 | setAttr(this, split[i], v[i]); 102 | } 103 | } 104 | } 105 | 106 | // this.trigger('changed', k, v); 107 | } 108 | 109 | /** 110 | * Get a single attribute after triggering a get:{key} 111 | * event which gives handlers the opportunity to change 112 | * the property's value. 113 | */ 114 | , get: function(keypath, callback, allowDefault) { 115 | 116 | var result = utils.traverse(this, keypath, function(obj,key, 117 | nearestEmitter, shortestPath) { 118 | if(nearestEmitter) { 119 | nearestEmitter.trigger('get:'+shortestPath); 120 | } 121 | }); 122 | 123 | if(callback) { 124 | if(result !== undefined) { 125 | callback.call(this, result); 126 | } 127 | else { 128 | this.watch(keypath, function() { 129 | var got = callback.apply(this.root, arguments); 130 | this.stop(); 131 | }); 132 | } 133 | } 134 | 135 | return result; 136 | } 137 | 138 | , debug: function(attr) { 139 | this.watch(attr, function(newV, preV) { 140 | console.log(attr, preV, '->', newV); 141 | }); 142 | } 143 | 144 | /** 145 | * Monitors a space-separated list of attributes for changes, 146 | * calling a function after any one has changed. 147 | * The values of the source attributes are passed to the 148 | * callback in the same order they were given. 149 | */ 150 | , watch: function(attr, fn) { 151 | var self = this; 152 | 153 | // Substitutions 154 | var interpolated = (typeof attr === 'string') && attr.match(/\{.+?\}/g); 155 | if(interpolated) { 156 | interpolated = utils.map(interpolated, function(a) { 157 | return a.slice(1, a.length-1); 158 | }); 159 | 160 | return this.watch(interpolated.join(' '), function() { 161 | var result = attr; 162 | for(var i=0; i < arguments.length; ++i) { 163 | var v = arguments[i]; 164 | result = result.replace('{'+interpolated[i]+'}', v); 165 | } 166 | fn.call(this, result); 167 | }); 168 | } 169 | 170 | // this.load && this.load(); 171 | 172 | // If only a function, we monitor any direct change 173 | // to the object attributes via the "changed" event 174 | if(arguments.length === 1 && typeof attr === 'function') { 175 | fn = attr; 176 | attr = '*'; 177 | } 178 | 179 | if(attr === '*') { 180 | var event = this.on('changed', fn); 181 | fn.call(this); // Initial update 182 | // this.load && this.load(); 183 | return event; 184 | } 185 | 186 | if(attr instanceof RegExp) { 187 | var event = this.on('changed', function(k,newV,preV) { 188 | if(attr.exec(k) !== null) { 189 | fn.call(this,k,newV,preV); 190 | } 191 | }); 192 | 193 | for(var k in this) { 194 | if(attr.exec(k) !== null) { 195 | var v = this.get(k); 196 | fn.call(this,k,v); 197 | } 198 | } 199 | // fn.call(this); // Initial update 200 | // this.load && this.load(); 201 | 202 | return; 203 | } 204 | 205 | // If no callback, just return gracefully and do nothing 206 | if(!fn) return; 207 | 208 | 209 | // Split out multiple input attributes 210 | var multi = attr.split(' '); 211 | 212 | // We encapsulate all of our events 213 | // under an outside container that only has one 214 | // attribute point to self 215 | // This allows us to unbind every event by simply 216 | // setting root to an eventless object, such as null. 217 | // This container is returned. 218 | var container = new ReactiveObject({ 219 | root: self 220 | }); 221 | 222 | var stopping = false; 223 | container.stop = function() { 224 | stopping = true; 225 | this.set('root', null); 226 | stopping = false; 227 | } 228 | 229 | 230 | var optional = []; 231 | 232 | // Prefix the attributes with root 233 | multi = utils.map(multi, function(attr) { 234 | 235 | if(attr.slice(-1) === '!') { 236 | return attr; 237 | } 238 | 239 | if(attr.slice(-1) === '?') { 240 | attr = attr.slice(0,-1) 241 | optional.push('root.'+attr); 242 | } 243 | 244 | return 'root.'+attr; 245 | }); 246 | 247 | // Updater function we call when any invalidation occurs 248 | // Builds a list of arguments from the input attrs, 249 | // and envokes the callback with those values. 250 | function update(preV) { 251 | if(stopping) return; 252 | 253 | if(fn.norecurse) { 254 | // console.log('stopped recursion'); 255 | // return; 256 | } 257 | 258 | var allDefined = true; 259 | var args = utils.map(multi, function(attr) { 260 | var val; 261 | if(attr.slice(-1) === '!') { 262 | val = attr.slice(0,-1); 263 | } 264 | else { 265 | val = container.get(attr); 266 | } 267 | if(val === undefined && utils.inArray(attr,optional) === -1) { 268 | allDefined = false; 269 | } 270 | return val; 271 | }); 272 | 273 | if(!allDefined) 274 | return; 275 | 276 | args = args.concat( 277 | Array.prototype.slice.call(arguments,0) 278 | ); 279 | 280 | fn.norecurse = true; 281 | fn.apply(container, args); 282 | fn.norecurse = false; 283 | } 284 | 285 | utils.each(multi, function(i,attr) { 286 | 287 | if(attr.slice(-1) === '!') { 288 | return; 289 | } 290 | 291 | utils.traverse(container, attr, function(deepObj, deepAttr, 292 | nearestEmitter, shortestPath, remainingPath) { 293 | 294 | if(nearestEmitter) { 295 | nearestEmitter.on('set:'+shortestPath, updateAttr); 296 | } 297 | 298 | // if(deepObj && deepObj.load) { 299 | // deepObj.load(); 300 | // } 301 | 302 | function updateAttr(newV,preV) { 303 | if(newV !== preV) { 304 | utils.traverse(newV, remainingPath, function(deepObj, deepAttr, 305 | nearestEmitter, shortestPath) { 306 | if(nearestEmitter) { 307 | nearestEmitter.on('set:'+shortestPath, updateAttr); 308 | } 309 | 310 | // if(deepObj && deepObj.load) { 311 | // deepObj.load(); 312 | // } 313 | }); 314 | 315 | utils.traverse(preV, remainingPath, function(deepObj, deepAttr, 316 | nearestEmitter, shortestPath) { 317 | if(nearestEmitter) 318 | nearestEmitter.unbind('set:'+shortestPath, updateAttr); 319 | }); 320 | } 321 | 322 | update(preV, attr); 323 | } 324 | 325 | }); 326 | }); 327 | 328 | update(); 329 | 330 | return container; 331 | } 332 | 333 | // TODO: Do these better... 334 | // they might not actually be called promises 335 | // but a special case of something like Watchers 336 | // This gets the job done for now. 337 | , promise: function(fn) { 338 | var obj = { 339 | then: function(thenf) { 340 | if(this.result) { 341 | thenf(this.result); 342 | return; 343 | } 344 | 345 | this.waiting = thenf; 346 | fn.call(this, done); 347 | 348 | } 349 | , waiting: null 350 | , result: null 351 | , done: done 352 | }; 353 | 354 | function done(result) { 355 | if(obj.waiting) { 356 | obj.waiting(result); 357 | return; 358 | } 359 | 360 | obj.result = result; 361 | } 362 | 363 | 364 | return obj; 365 | } 366 | 367 | /** 368 | * Use watch parameters to set a single result attribute 369 | */ 370 | , synth: function(output,input,fwdFn,revFn) { 371 | function straight() { 372 | return arguments[0]; 373 | }; 374 | 375 | if(!fwdFn) { 376 | // We can only default reverse 377 | // if forward is default, so 378 | // we default them together. 379 | fwdFn = straight; 380 | 381 | var isReversible = typeof input === 'string' && 382 | input.indexOf('{') === -1; 383 | 384 | if(isReversible) 385 | revFn = straight; 386 | } 387 | 388 | var self = this; 389 | self.watch(input, function() { 390 | var result = fwdFn.apply(self, arguments); 391 | 392 | // if(result && result.then) { 393 | // result.then(function(result) { 394 | // self.set(output, result); 395 | // }); 396 | // } 397 | // else { 398 | self.set(output, result); 399 | // } 400 | }); 401 | 402 | if(revFn) { 403 | this.synth(input, output, revFn); 404 | } 405 | 406 | return this; 407 | } 408 | , filter: function(extra) { 409 | var copy = new this.constructor(); 410 | copy.set(this); 411 | 412 | if(arguments.length > 0) { 413 | copy.set.apply(copy, arguments); 414 | } 415 | 416 | return copy; 417 | } 418 | , substituteString: function(str) { 419 | var self = this; 420 | return str.replace(/\{.+?\}/g, function(m) { 421 | return self.get(m); 422 | }); 423 | } 424 | }; 425 | 426 | /** 427 | * Make an event emmitters 428 | */ 429 | Events.mixin(ReactiveObject); 430 | --------------------------------------------------------------------------------