/*! JsViews v1.0pre: http://github.com/BorisMoore/jsviews */
/*
* Interactive data-driven views using templates and data-linking.
* Requires jQuery, and jsrender.js (next-generation jQuery Templates, optimized for pure string-based rendering)
* See JsRender at http://github.com/BorisMoore/jsrender
*
* Copyright 2012, Boris Moore
* Released under the MIT License.
*/
// informal pre beta commit counter: 2
this.jQuery && jQuery.link || (function( global, undefined ) {
// global is the this object, which is window when running in the usual browser environment.
//========================== Top-level vars ==========================
var versionNumber = "v1.0pre",
rTag, delimOpen0, delimOpen1, delimClose0, delimClose1,
$ = global.jQuery,
// jsviews object (=== $.views) Note: JsViews requires jQuery is loaded)
jsv = $.views,
sub = jsv.sub,
FALSE = false, TRUE = true,
topView = jsv.topView,
templates = jsv.templates,
observable = $.observable,
jsvData = "_jsvData",
linkStr = "link",
viewStr = "view",
propertyChangeStr = "propertyChange",
arrayChangeStr = "arrayChange",
fnSetters = {
value: "val",
html: "html",
text: "text"
},
oldCleanData = $.cleanData,
oldJsvDelimiters = jsv.delimiters,
rTmplOrItemComment = /^(\/?)(?:(item)|(?:(tmpl)(?:\((.*),([^,)]*)\))?(?:\s+([^\s]+))?))$/,
// tokens: [ all, slash, 'item', 'tmpl', path, index, tmplParam ]
//rTmplOrItemComment = /^(\/?)(?:(item)|(?:(tmpl)(?:\(([^,R]*),([^,)]*)\))?(?:\s+([^\s]+))?))$/,
rStartTag = /^item|^tmpl(\(\$?[\w.,]*\))?(\s+[^\s]+)?$/;
if ( !$ ) {
// jQuery is not loaded.
throw "requires jQuery"; // for Beta (at least) we require jQuery
}
if( !(jsv )) {
throw "requires JsRender";
}
//========================== Top-level functions ==========================
//===============
// event handlers
//===============
function elemChangeHandler( ev ) {
var setter, cancel, fromAttr, to, linkContext, sourceValue, cnvtBack, target,
source = ev.target,
$source = $( source ),
view = $.view( source ),
context = view.ctx,
beforeChange = context.beforeChange;
if ( source.getAttribute( jsv.linkAttr ) && (to = jsViewsData( source, "to" ))) {
fromAttr = defaultAttr( source );
setter = fnSetters[ fromAttr ];
sourceValue = $.isFunction( fromAttr ) ? fromAttr( source ) : setter ? $source[setter]() : $source.attr( fromAttr );
if ((!beforeChange || !(cancel = beforeChange.call( view, ev ) === FALSE )) && sourceValue !== undefined ) {
cnvtBack = jsv.converters[ to[ 2 ]];
target = to[ 0 ];
to = to[ 1 ];
linkContext = {
src: source,
tgt: target,
cnvtBack: cnvtBack,
path: to
};
if ( cnvtBack ) {
sourceValue = cnvtBack( sourceValue );
}
if ( sourceValue !== undefined && target ) {
observable( target ).setProperty( to, sourceValue );
if ( context.afterChange ) { //TODO only call this if the target property changed
context.afterChange.call( linkContext, ev );
}
}
ev.stopPropagation(); // Stop bubbling
}
if ( cancel ) {
ev.stopImmediatePropagation();
}
}
}
function propertyChangeHandler( ev, eventArgs, bind ) {
var setter, changed, sourceValue, css,
link = this,
source = link.src,
target = link.tgt,
$target = $( target ),
attr = link.attr || defaultAttr( target, TRUE ),
view = link.view,
context = view.ctx,
beforeChange = context.beforeChange;
// TODO for
//Currently the following scenarios do work:
//$.observable(model).setProperty("a.b", "bar");
//$.observable(model.a).setProperty("b", "bar");
// TODO Add support for $.observable(model).setProperty("a", { b: "bar" });
// var testsourceValue = ev.expr( source, view, jsv, ev.bind );
// TODO call beforeChange on data-link initialization.
// if ( changed && context.afterChange ) {
// context.afterChange.call( link, ev, eventArgs );
// }
if ((!beforeChange || !(eventArgs && beforeChange.call( this, ev, eventArgs ) === FALSE ))
// && (!view || view.onDataChanged( eventArgs ) !== FALSE ) // Not currently supported or needed for property change
) {
sourceValue = link.fn( source, link.view, jsv, bind || returnVal );
if ( $.isFunction( sourceValue )) {
sourceValue = sourceValue.call( source );
}
if ( css = attr.lastIndexOf( "css-", 0 ) === 0 && attr.substr( 4 )) {
if ( changed = $target.css( css ) !== sourceValue ) {
$target.css( css, sourceValue );
}
} else {
setter = fnSetters[ attr ];
if ( setter ) {
if ( changed = $target[setter]() !== sourceValue ) {
$target[setter]( sourceValue );
if ( target.nodeName.toLowerCase() === "input" ) {
$target.blur(); // Issue with IE. This ensures HTML rendering is updated.
}
}
} else if ( changed = $target.attr( attr ) !== sourceValue ) {
$target.attr( attr, sourceValue );
}
}
if ( eventArgs && changed && context.afterChange ) {
context.afterChange.call( link, ev, eventArgs );
}
}
}
function arrayChangeHandler( ev, eventArgs ) {
var context = this.ctx,
beforeChange = context.beforeChange;
if ( !beforeChange || beforeChange.call( this, ev, eventArgs ) !== FALSE ) {
this.onDataChanged( eventArgs );
if ( context.afterChange ) {
context.afterChange.call( this, ev, eventArgs );
}
}
}
function setArrayChangeLink( view ) {
var handler,
data = view.data,
onArrayChange = view._onArrayChange;
if ( onArrayChange ) {
if ( onArrayChange[ 1 ] === data ) {
return;
}
$([ onArrayChange[ 1 ]]).unbind( arrayChangeStr, onArrayChange[ 0 ]);
}
if ( $.isArray( data )) {
handler = function() {
arrayChangeHandler.apply( view, arguments );
};
$([ data ]).bind( arrayChangeStr, handler );
view._onArrayChange = [ handler, data ];
}
}
function defaultAttr( elem, to ) {
// Merge in the default attribute bindings for this target element
var attr = jsv.merge[ elem.nodeName.toLowerCase() ];
return attr
? (to
? attr.to.toAttr
: attr.from.fromAttr)
: "html";
}
function returnVal( value ) {
return value;
}
//===============
// view hierarchy
//===============
function linkedView( view ) {
var i, views, viewsCount;
if ( !view.render ) {
view.onDataChanged = view_onDataChanged;
view.render = view_render;
view.addViews = view_addViews;
view.removeViews = view_removeViews;
view.content = view_content;
if (view.parent) {
if ( !$.isArray( view.data )) {
view.nodes = [];
view._lnk = 0; // compiled link index.
}
views = view.parent.views;
if ( $.isArray( views )) {
i = view.index;
viewsCount = views.length;
while ( i++ < viewsCount-1 ) {
observable( views[ i ] ).setProperty( "index", i );
}
}
setArrayChangeLink( view );
}
}
return view;
}
// Additional methods on view object for linked views (i.e. when JsViews is loaded)
function view_onDataChanged( eventArgs ) {
if ( eventArgs ) {
// This is an observable action (not a trigger/handler call from pushValues, or similar, for which eventArgs will be null)
var self = this,
action = eventArgs.change,
index = eventArgs.index,
items = eventArgs.items;
switch ( action ) {
case "insert":
self.addViews( index, items );
break;
case "remove":
self.removeViews( index, items.length );
break;
case "move":
self.render(); // Could optimize this
break;
case "refresh":
self.render();
// Othercases: (e.g.undefined, for setProperty on observable object) etc. do nothing
}
}
return TRUE;
}
function view_render() {
var self = this,
tmpl = self.tmpl = getTemplate( self.tmpl ),
prevNode = self.prevNode,
nextNode = self.nextNode,
parentNode = prevNode.parentNode;
if ( tmpl ) {
// Remove HTML nodes
$( self.nodes ).remove(); // Also triggers cleanData which removes child views.
// Remove child views
self.removeViews();
self.nodes = [];
$( prevNode ).after( tmpl.render( self.data, self.ctx, self, self.path, true ) );
// Need to the update the annotation info on the prevNode comment marker
// TODO - Include the following two lines, but modified, to keep comments, but add template info:
// prevNode.nodeValue = prevNode.nextSibling.nodeValue;
// nextNode.nodeValue = nextNode.previousSibling.nodeValue;
// Remove the extra comment nodes
parentNode.removeChild( prevNode.nextSibling );
parentNode.removeChild( nextNode.previousSibling );
// Link the new HTML nodes to the data
linkViews( parentNode, self, nextNode, 0, undefined, undefined, prevNode, 0 ); //this.index
setArrayChangeLink( self );
}
return self;
}
function view_addViews( index, dataItems, tmpl ) {
var self = this,
itemsCount = dataItems.length,
context = self.ctx,
views = self.views;
if ( index && !views[index-1] ) {
return; // If subview for provided index does not exist, do nothing
}
if ( itemsCount && (tmpl = getTemplate( tmpl || self.tmpl ))) {
var prevNode = index ? views[ index-1 ].nextNode : self.prevNode,
nextNode = prevNode.nextSibling,
parentNode = prevNode.parentNode;
// Use passed-in template if provided, since self added view may use a different template than the original one used to render the array.
$( prevNode ).after( tmpl.render( dataItems, context, self, undefined, index ) );
// Need to the update the annotation info on the prevNode comment marker
// self.prevNode.nodeValue = prevNode.nextSibling.nodeValue;
// Remove the extra comment nodes
parentNode.removeChild( prevNode.nextSibling );
parentNode.removeChild( nextNode.previousSibling );
// Link the new HTML nodes to the data
linkViews( parentNode, self, nextNode, 0, undefined, undefined, prevNode, index );
}
return self;
}
function view_removeViews( index, itemsCount ) {
// view.removeViews() removes all the child views
// view.removeViews( index ) removes the child view with specified index or key
// view.removeViews( index, count ) removes the specified nummber of child views, starting with the specified index
function removeView( index ) {
var parentElViews, i,
view = views[ index ],
node = view.prevNode,
nextNode = view.nextNode,
nodes = [ node ];
if ( !nextNode ) {
// this view has not been linked, so nothing to remove.
return;
}
parentElViews = parentElViews || jsViewsData( nextNode.parentNode, viewStr );
i = parentElViews.length;
if ( i ) {
view.removeViews();
}
// Remove this view from the parentElViews collection
while ( i-- ) {
if ( parentElViews[ i ] === view ) {
parentElViews.splice( i, 1 );
break;
}
}
// Remove the HTML nodes from the DOM
while ( node !== nextNode ) {
node = node.nextSibling;
nodes.push( node );
}
$( nodes ).remove();
view.data = undefined;
setArrayChangeLink( view );
}
var current,
self = this,
views = self.views,
viewsCount = views.length;
if ( index === undefined ) {
// Remove all child views
if ( viewsCount === undefined ) {
// views and data are objects
for ( index in views ) {
// Remove by key
removeView( index );
}
self.views = {};
} else {
// views and data are arrays
current = viewsCount;
while ( current-- ) {
removeView( current );
}
self.views = [];
}
} else {
if ( itemsCount === undefined ) {
if ( viewsCount === undefined ) {
// Remove child view with key 'index'
removeView( index );
delete views[ index ];
} else {
// The parentView is data array view.
// Set itemsCount to 1, to remove this item
itemsCount = 1;
}
}
if ( itemsCount ) {
current = index + itemsCount;
// Remove indexed items (parentView is data array view);
while ( current-- > index ) {
removeView( current );
}
views.splice( index, itemsCount );
if ( viewsCount = views.length ) {
// Fixup index on following view items...
while ( index < viewsCount ) {
observable( views[ index ] ).setProperty( "index", index++ );
}
}
}
}
return this;
}
function view_content( select ) {
return select ? $( select, this.nodes ) : $( this.nodes );
}
//===============
// data-linking
//===============
function linkViews( node, parent, nextNode, depth, data, context, prevNode, index ) {
var tokens, links, link, attr, linkIndex, parentElViews, convertBack, cbLength, view, parentNode, linkMarkup, expression,
currentView = parent,
viewDepth = depth;
context = context || {};
node = prevNode || node;
if ( !prevNode && node.nodeType === 1 ) {
if ( viewDepth++ === 0 ) {
// Add top-level element nodes to view.nodes
currentView.nodes.push( node );
}
if ( linkMarkup = node.getAttribute( jsv.linkAttr ) ) {
linkIndex = currentView._lnk++;
// Compiled linkFn expressions are stored in the tmpl.links array of the template
links = currentView.links || currentView.tmpl.links;
if ( !(link = links[ linkIndex ] )) {
link = links [ linkIndex ] = {};
if ( linkMarkup.charAt(linkMarkup.length-1) !== "}" ) {
// Simplified syntax is used: data-link="expression"
// Convert to data-link="{:expression}", or for inputs, data-link="{:expression:}" for (default) two-way binding
linkMarkup = delimOpen1 + ":" + linkMarkup + ($.nodeName( node, "input" ) ? ":" : "") + delimClose0;
}
while( tokens = rTag.exec( linkMarkup )) { // TODO require } to be followed by whitespace or $, and remove the \}(!\}) option.
// Iterate over the data-link expressions, for different target attrs, e.g. or
currentView.nextNode = node;
if ( currentView.ctx.onAfterCreate ) {
currentView.ctx.onAfterCreate.call( currentView, currentView );
}
if ( tokens[ 2 ]) {
// An item close tag:
currentView = parent;
} else {
// A tmpl close tag:
return node;
}
} else {
// or
parentElViews = parentElViews || jsViewsData( parentNode, viewStr, TRUE );
if ( tokens[ 2 ]) {
// An item open tag:
parentElViews.push(
currentView = linkedView( currentView.views[ index ] )
);
index++;
currentView.prevNode = node;
} else {
// A tmpl open tag:
parentElViews.push(
view = linkedView( currentView.views[ tokens[ 5 ]] )
);
view.prevNode = node;
// Jump to the nextNode of the tmpl view
node = linkViews( node, view, nextNode, 0, undefined, undefined, undefined, 0 );
}
}
} else if ( viewDepth === 0 ) {
// Add top-level non-element nodes to view.nodes
currentView.nodes.push( node );
}
node = node.nextSibling;
}
}
function bindDataLinkTarget( source, target, attr, linkFn, view ) {
//Add data link bindings for a link expression in data-link attribute markup
var boundParams = [],
storedLinks = jsViewsData( target, linkStr, TRUE ),
handler = function() {
propertyChangeHandler.apply({ tgt: target, src: source, attr: attr, fn: linkFn, view: view }, arguments );
};
// Store for unbinding
storedLinks[ attr ] = { srcs: boundParams, hlr: handler };
// Call the handler for initialization and parameter binding
handler( undefined, undefined, function ( object, leafToken ) {
// Binding callback called on each dependent object (parameter) that the link expression depends on.
// For each path add a propertyChange binding to the leaf object, to trigger the compiled link expression,
// and upate the target attribute on the target element
boundParams.push( object );
if ( linkFn.to !== undefined ) {
// If this link is a two-way binding, add the linkTo info to JsViews stored data
$.data( target, jsvData ).to = [ object, leafToken, linkFn.to ];
// For two-way binding, there should be only one path. If not, will bind to the last one.
}
if ( $.isArray( object )) {
$([ object ]).bind( arrayChangeStr, function() {
handler();
});
} else {
$( object ).bind( propertyChangeStr, handler );
}
return object;
});
// Note that until observable deals with managing listeners on object graphs, we can't support changing objects higher up the chain, so there is no reason
// to attach listeners to them. Even $.observable( person ).setProperty( "address.city", ... ); is in fact triggering propertyChange on the leaf object (address)
}
//===============
// helpers
//===============
function jsViewsData( el, type, create ) {
var jqData = $.data( el, jsvData ) || (create && $.data( el, jsvData, { view: [], link: {} }));
return jqData ? jqData[ type ] : {};
}
function inputAttrib( elem ) {
return elem.type === "checkbox" ? elem.checked : $( elem ).val();
}
function getTemplate( tmpl ) {
// Get nested templates from path
if ( "" + tmpl === tmpl ) {
var tokens = tmpl.split("[");
tmpl = templates[ tokens.shift() ];
while( tmpl && tokens.length ) {
tmpl = tmpl.tmpls[ tokens.shift().slice( 0, -1 )];
}
}
return tmpl;
}
//========================== Initialize ==========================
//=======================
// JsRender integration
//=======================
sub.onStoreItem = function( store, name, item, process ) {
if ( name && item && store === templates ) {
item.link = function( container, data, context, parentView ) {
$.link( container, data, context, parentView, item );
};
$.link[ name ] = function() {
return item.link.apply( item, arguments );
};
}
};
sub.onRenderItem = function( value, props ) {
return "" + value + "";
};
sub.onRenderItems = function( value, path, index, tmpl, props ) {
return "" + value + "";
};
//=======================
// Extend $.views namespace
//=======================
$.extend( jsv, {
linkAttr: "data-link",
merge: {
input: {
from: {
fromAttr: inputAttrib
},
to: {
toAttr: "value"
}
}
},
delimiters: function( openChars, closeChars ) {
oldJsvDelimiters( openChars, closeChars );
rTag = new RegExp( "(?:^|s*)([\\w-]*)(" + jsv.rTag + ")", "g" );
delimOpen0 = openChars.charAt( 0 );
delimOpen1 = openChars.charAt( 1 );
delimClose0 = closeChars.charAt( 0 );
delimClose1 = closeChars.charAt( 1 );
return this;
}
});
//=======================
// Extend jQuery namespace
//=======================
$.extend({
//=======================
// jQuery $.view() plugin
//=======================
view: function( node, inner ) {
// $.view() returns top node
// $.view( node ) returns view that contains node
// $.view( selector ) returns view that contains first selected element
node = ("" + node === node ? $( node )[0] : node);
var returnView, view, parentElViews, i, finish,
topNode = global.document.body,
startNode = node;
if ( inner ) {
// Treat supplied node as a container element, step through content, and return the first view encountered.
finish = node.nextSibling || node.parentNode;
while ( finish !== (node = node.firstChild || node.nextSibling || node.parentNode.nextSibling )) {
if ( node.nodeType === 8 && rStartTag.test( node.nodeValue )) {
view = $.view( node );
if ( view.prevNode === node ) {
return view;
}
}
}
return;
}
node = node || topNode;
if ( $.isEmptyObject( topView.views )) {
returnView = topView; // Perf optimization for common case
} else {
// Step up through parents to find an element which is a views container, or if none found, create the top-level view for the page
while( !(parentElViews = jsViewsData( finish = node.parentNode || topNode, viewStr )).length ) {
if ( !finish || node === topNode ) {
jsViewsData( topNode.parentNode, viewStr, TRUE ).push( returnView = topView );
break;
}
node = finish;
}
if ( !returnView && node === topNode ) {
returnView = topView; //parentElViews[0];
}
while ( !returnView && node ) {
// Step back through the nodes, until we find an item or tmpl open tag - in which case that is the view we want
if ( node === finish ) {
returnView = view;
break;
}
if ( node.nodeType === 8 ) {
if ( /^\/item|^\/tmpl$/.test( node.nodeValue )) {
// A tmpl or item close tag: or
i = parentElViews.length;
while ( i-- ) {
view = parentElViews[ i ];
if ( view.nextNode === node ) {
// If this was the node originally passed in, this is the view we want.
returnView = (node === startNode && view);
// If not, jump to the beginning of this item/tmpl and continue from there
node = view.prevNode;
break;
}
}
} else if ( rStartTag.test( node.nodeValue )) {
// A tmpl or item open tag: or
i = parentElViews.length;
while ( i-- ) {
view = parentElViews[ i ];
if ( view.prevNode === node ) {
returnView = view;
break;
}
}
}
}
node = node.previousSibling;
}
// If not within any of the views in the current parentElViews collection, move up through parent nodes to find next parentElViews collection
returnView = returnView || $.view( finish );
}
return returnView;
},
link: function( container, data, context, parentView, template ) {
// Bind elementChange on the root element, for links from elements within the content, to data;
function dataToElem() {
elemChangeHandler.apply({
tgt: data
}, arguments );
}
parentView = parentView || topView;
template = template && (templates[ template ] || (template.markup ? template : $.templates( template )));
context = context || parentView.ctx;
context.link = TRUE;
container = $( container )
.bind( "change", dataToElem );
if ( template ) {
// TODO/BUG Currently this will re-render if called a second time, and will leave stale views under the parentView.views.
// So TODO: make it smart about when to render and when to link on already rendered content
container.empty().append( template.render( data, context, parentView )); // Supply non-jQuery version of this...
// Using append, rather than html, as workaround for issues in IE compat mode. (Using innerHTML leads to initial comments being stripped)
}
linkViews( container[0], parentView, undefined, undefined, data, context );
},
//=======================
// override $.cleanData
//=======================
cleanData: function( elems ) {
var l, el, link, attr, parentView, view, srcs, linksAndViews, collData,
i = elems.length;
while ( i-- ) {
el = elems[ i ];
if ( linksAndViews = $.data( el, jsvData )) {
// Get links and unbind propertyChange
collData = linksAndViews.link;
for ( attr in collData) {
link = collData[ attr ];
srcs = link.srcs;
l = srcs.length;
while( l-- ) {
$( srcs[ l ] ).unbind( propertyChangeStr, link.hlr );
}
}
// Get views and remove from parent view
collData = linksAndViews.view;
if ( l = collData.length ) {
parentView = $.view( el );
while( l-- ) {
view = collData[ l ];
if ( view.parent === parentView ) {
parentView.removeViews( view.index ); // NO - ONLY remove view if its top-level nodes are all.. (TODO)
}
}
}
}
}
oldCleanData.call( $, elems );
}
});
// Initialize default delimiters
jsv.delimiters( "{{", "}}" );
topView._lnk = 0;
topView.links = [];
topView.ctx.link = TRUE; // Set this as the default, when JsViews is loaded
linkedView(topView);
})( this );