ractive
Version:
Next-generation DOM manipulation
2,077 lines (1,589 loc) • 59.1 kB
JavaScript
/*! Ractive - v0.2.0 - 2013-04-18
* Faster, easier, better interactive web development
* http://rich-harris.github.com/Ractive/
* Copyright (c) 2013 Rich Harris; Licensed MIT */
/*jslint eqeq: true, plusplus: true */
/*global document, HTMLElement */
(function ( global ) {
'use strict';
var Ractive;
(function () {
'use strict';
var getEl;
Ractive = function ( options ) {
var defaults, key;
// Options
// -------
if ( options ) {
for ( key in options ) {
if ( options.hasOwnProperty( key ) ) {
this[ key ] = options[ key ];
}
}
}
defaults = {
preserveWhitespace: false,
append: false,
twoway: true,
formatters: {},
modifyArrays: true
};
for ( key in defaults ) {
if ( defaults.hasOwnProperty( key ) && this[ key ] === undefined ) {
this[ key ] = defaults[ key ];
}
}
// Initialization
// --------------
if ( this.el !== undefined ) {
this.el = getEl( this.el ); // turn ID string into DOM element
}
// Set up event bus
this._subs = {};
// Set up cache
this._cache = {};
this._cacheMap = {};
// Set up observers
this.observers = {};
this.pendingResolution = [];
// Create an array for deferred attributes
this.deferredAttributes = [];
// Initialise (or update) viewmodel with data
if ( this.data ) {
this.set( this.data );
}
// If we were given uncompiled partials, compile them
if ( this.partials ) {
for ( key in this.partials ) {
if ( this.partials.hasOwnProperty( key ) ) {
if ( typeof this.partials[ key ] === 'string' ) {
if ( !Ractive.compile ) {
throw new Error( 'Missing Ractive.compile - cannot compile partial "' + key + '". Either precompile or use the version that includes the compiler' );
}
this.partials[ key ] = Ractive.compile( this.partials[ key ], this ); // all compiler options are present on `this`, so just passing `this`
}
}
}
}
// Compile template, if it hasn't been compiled already
if ( typeof this.template === 'string' ) {
if ( !Ractive.compile ) {
throw new Error( 'Missing Ractive.compile - cannot compile template. Either precompile or use the version that includes the compiler' );
}
this.template = Ractive.compile( this.template, this );
}
// If the template was an array with a single string member, that means
// we can use innerHTML - we just need to unpack it
if ( this.template && ( this.template.length === 1 ) && ( typeof this.template[0] === 'string' ) ) {
this.template = this.template[0];
}
// If passed an element, render immediately
if ( this.el ) {
this.render({ el: this.el, callback: this.callback, append: this.append });
}
};
// Prototype methods
// =================
Ractive.prototype = {
// Render instance to element specified here or at initialization
render: function ( options ) {
var el = ( options.el ? getEl( options.el ) : this.el );
if ( !el ) {
throw new Error( 'You must specify a DOM element to render to' );
}
// Clear the element, unless `append` is `true`
if ( !options.append ) {
el.innerHTML = '';
}
if ( options.callback ) {
this.callback = options.callback;
}
// Render our *root fragment*
this.rendered = new _private.DomFragment({
model: this.template,
root: this,
parentNode: el
});
el.appendChild( this.rendered.docFrag );
},
// Teardown. This goes through the root fragment and all its children, removing observers
// and generally cleaning up after itself
teardown: function () {
this.rendered.teardown();
},
set: function ( keypath, value ) {
if ( _private.isObject( keypath ) ) {
this._setMultiple( keypath );
} else {
this._setSingle( keypath, value );
}
while ( this.deferredAttributes.length ) {
this.deferredAttributes.pop().update().updateDeferred = false;
}
},
_setSingle: function ( keypath, value ) {
var keys, key, obj, normalised, i, resolved, unresolved;
if ( _private.isArray( keypath ) ) {
keys = keypath.slice();
} else {
keys = _private.splitKeypath( keypath );
}
normalised = keys.join( '.' );
// clear cache
this._clearCache( normalised );
// update data
obj = this.data;
while ( keys.length > 1 ) {
key = keys.shift();
// if this branch doesn't exist yet, create a new one - if the next
// key matches /^[0-9]+$/, assume we want an array branch rather
// than an object
if ( !obj[ key ] ) {
obj[ key ] = ( /^[0-9]+$/.test( keys[0] ) ? [] : {} );
}
obj = obj[ key ];
}
key = keys[0];
obj[ key ] = value;
// fire set event
if ( !this.setting ) {
this.setting = true; // short-circuit any potential infinite loops
this.fire( 'set', normalised, value );
this.fire( 'set:' + normalised, value );
this.setting = false;
}
// Trigger updates of views that observe `keypaths` or its descendants
this._notifyObservers( normalised, value );
// See if we can resolve any of the unresolved keypaths (if such there be)
i = this.pendingResolution.length;
while ( i-- ) { // Work backwards, so we don't go in circles!
unresolved = this.pendingResolution.splice( i, 1 )[0];
resolved = this.resolveRef( unresolved.view.model.ref, unresolved.view.contextStack );
// If we were able to find a keypath, initialise the view
if ( resolved ) {
unresolved.callback( resolved.keypath, resolved.value );
}
// Otherwise add to the back of the queue (this is why we're working backwards)
else {
this.registerUnresolvedKeypath( unresolved );
}
}
},
_setMultiple: function ( map ) {
var keypath;
for ( keypath in map ) {
if ( map.hasOwnProperty( keypath ) ) {
this._setSingle( keypath, map[ keypath ] );
}
}
},
_clearCache: function ( keypath ) {
var children = this._cacheMap[ keypath ];
delete this._cache[ keypath ];
if ( !children ) {
return;
}
while ( children.length ) {
this._clearCache( children.pop() );
}
},
get: function ( keypath ) {
var keys, normalised, lastDotIndex, formula, match, parentKeypath, parentValue, propertyName, unformatted, unformattedKeypath, value, formatters;
if ( _private.isArray( keypath ) ) {
keys = keypath.slice(); // clone
normalised = keys.join( '.' );
}
else {
// cache hit? great
if ( keypath in this._cache ) {
return this._cache[ keypath ];
}
keys = _private.splitKeypath( keypath );
normalised = keys.join( '.' );
}
// we may have a cache hit now that it's been normalised
if ( normalised in this._cache ) {
return this._cache[ normalised ];
}
// otherwise it looks like we need to do some work
if ( keys.length > 1 ) {
formula = keys.pop();
parentValue = this.get( keys );
} else {
formula = keys.pop();
parentValue = this.data;
}
// is this a set of formatters?
if ( match = /^⭆(.+)⭅$/.exec( formula ) ) {
formatters = _private.getFormattersFromString( match[1] );
value = this._format( parentValue, formatters );
}
else {
if ( typeof parentValue !== 'object' ) {
return;
}
value = parentValue[ formula ];
}
// update cacheMap
if ( keys.length ) {
parentKeypath = keys.join( '.' );
if ( !this._cacheMap[ parentKeypath ] ) {
this._cacheMap[ parentKeypath ] = [];
}
this._cacheMap[ parentKeypath ].push( normalised );
}
// allow functions as values
// TODO allow arguments, same as formatters?
if ( typeof value === 'function' ) {
value = value();
}
// update cache
this._cache[ normalised ] = value;
return value;
},
update: function () {
// TODO
throw new Error( 'not implemented yet!' );
return this;
},
link: function ( keypath ) {
var self = this;
return function ( value ) {
self.set( keypath, value );
};
},
registerView: function ( view ) {
var self = this, resolved, initialUpdate, value, index;
if ( view.parentFragment && ( view.parentFragment.indexRefs.hasOwnProperty( view.model.ref ) ) ) {
// this isn't a real keypath, it's an index reference
index = view.parentFragment.indexRefs[ view.model.ref ];
value = ( view.model.fmtrs ? this._format( index, view.model.fmtrs ) : index );
view.update( value );
return; // this value will never change, and doesn't have a keypath
}
initialUpdate = function ( keypath, value ) {
if ( view.model.fmtrs ) {
view.keypath = keypath + '.' + _private.stringifyFormatters( view.model.fmtrs );
} else {
view.keypath = keypath;
}
// create observers
view.observerRefs = self.observe( view.model.p || 0, view );
view.update( self.get( view.keypath ) );
};
resolved = this.resolveRef( view.model.ref, view.contextStack );
if ( !resolved ) {
// we may still need to do an update, if the view has formatters
// that e.g. offer an alternative to undefined
if ( view.model.fmtrs ) {
view.update( this._format( undefined, view.model.fmtrs ) );
}
this.registerUnresolvedKeypath({
view: view,
callback: initialUpdate
});
} else {
initialUpdate( resolved.keypath, resolved.value );
}
},
// Resolve a full keypath from `ref` within the given `contextStack` (e.g.
// `'bar.baz'` within the context stack `['foo']` might resolve to `'foo.bar.baz'`
resolveRef: function ( ref, contextStack ) {
var innerMost, keypath, value;
// Implicit iterators - i.e. {{.}} - are a special case
if ( ref === '.' ) {
keypath = contextStack[ contextStack.length - 1 ];
value = this.get( keypath );
return { keypath: keypath, value: value };
}
// Clone the context stack, so we don't mutate the original
contextStack = contextStack.concat();
// Take each context from the stack, working backwards from the innermost context
while ( contextStack.length ) {
innerMost = contextStack.pop();
keypath = innerMost + '.' + ref;
value = this.get( keypath );
if ( value !== undefined ) {
return { keypath: keypath, value: value };
}
}
value = this.get( ref );
if ( value !== undefined ) {
return { keypath: ref, value: value };
}
},
registerUnresolvedKeypath: function ( unresolved ) {
this.pendingResolution[ this.pendingResolution.length ] = unresolved;
},
// Internal method to format a value, using formatters passed in at initialization
_format: function ( value, formatters ) {
var i, numFormatters, formatter, name, args, fn;
// If there are no formatters, groovy - just return the value unchanged
if ( !formatters ) {
return value;
}
// Otherwise go through each in turn, applying sequentially
numFormatters = formatters.length;
for ( i=0; i<numFormatters; i+=1 ) {
formatter = formatters[i];
name = formatter.name;
args = formatter.args || [];
// If a formatter was passed in, use it, otherwise see if there's a default
// one with this name
fn = this.formatters[ name ] || Ractive.formatters[ name ];
if ( fn ) {
value = fn.apply( this, [ value ].concat( args ) );
}
}
return value;
},
_notifyObservers: function ( keypath, value ) {
var self = this, observersGroupedByPriority = this.observers[ keypath ] || [], i, j, priorityGroup, observer, actualValue;
for ( i=0; i<observersGroupedByPriority.length; i+=1 ) {
priorityGroup = observersGroupedByPriority[i];
if ( priorityGroup ) {
for ( j=0; j<priorityGroup.length; j+=1 ) {
observer = priorityGroup[j];
observer.update( self.get( observer.keypath ) );
}
}
}
},
observe: function ( priority, view ) {
var self = this, keypath, originalKeypath = view.keypath, observerRefs = [], observe, keys;
if ( !originalKeypath ) {
return undefined;
}
observe = function ( keypath ) {
var observers, observer;
observers = self.observers[ keypath ] = self.observers[ keypath ] || [];
observers = observers[ priority ] = observers[ priority ] || [];
observers[ observers.length ] = view;
observerRefs[ observerRefs.length ] = {
keypath: keypath,
priority: priority,
view: view
};
};
keys = _private.splitKeypath( view.keypath );
while ( keys.length > 1 ) {
observe( keys.join( '.' ) );
// remove the last item in the keypath, so that data.set( 'parent', { child: 'newValue' } ) affects views dependent on parent.child
keys.pop();
}
observe( keys[0] );
return observerRefs;
},
unobserve: function ( observerRef ) {
var priorities, observers, index, i, len;
priorities = this.observers[ observerRef.keypath ];
if ( !priorities ) {
// nothing to unobserve
return;
}
observers = priorities[ observerRef.priority ];
if ( !observers ) {
// nothing to unobserve
return;
}
if ( observers.indexOf ) {
index = observers.indexOf( observerRef.observer );
} else {
// fuck you IE
for ( i=0, len=observers.length; i<len; i+=1 ) {
if ( observers[i] === observerRef.view ) {
index = i;
break;
}
}
}
if ( index === -1 ) {
// nothing to unobserve
return;
}
// remove the observer from the list...
observers.splice( index, 1 );
// ...then tidy up if necessary
if ( observers.length === 0 ) {
delete priorities[ observerRef.priority ];
}
if ( priorities.length === 0 ) {
delete this.observers[ observerRef.keypath ];
}
},
unobserveAll: function ( observerRefs ) {
while ( observerRefs.length ) {
this.unobserve( observerRefs.shift() );
}
}
};
// helper functions
getEl = function ( input ) {
var output, doc;
if ( typeof window === 'undefined' ) {
return;
}
doc = window.document;
if ( !input ) {
throw new Error( 'No container element specified' );
}
// We already have a DOM node - no work to do
if ( input.tagName ) {
return input;
}
// Get node from string
if ( typeof input === 'string' ) {
// try ID first
output = doc.getElementById( input );
// then as selector, if possible
if ( !output && doc.querySelector ) {
output = doc.querySelector( input );
}
// did it work?
if ( output.tagName ) {
return output;
}
}
// If we've been given a collection (jQuery, Zepto etc), extract the first item
if ( input[0] && input[0].tagName ) {
return input[0];
}
throw new Error( 'Could not find container element' );
};
return Ractive;
}());
var _private;
(function () {
'use strict';
var formattersCache = {};
_private = {
// thanks, http://perfectionkills.com/instanceof-considered-harmful-or-how-to-write-a-robust-isarray/
isArray: function ( obj ) {
return Object.prototype.toString.call( obj ) === '[object Array]';
},
// TODO what about non-POJOs?
isObject: function ( obj ) {
return ( Object.prototype.toString.call( obj ) === '[object Object]' ) && ( typeof obj !== 'function' );
},
// http://stackoverflow.com/questions/18082/validate-numbers-in-javascript-isnumeric
isNumeric: function ( n ) {
return !isNaN( parseFloat( n ) ) && isFinite( n );
},
// TODO this is a bit regex-heavy... could be optimised maybe?
splitKeypath: function ( keypath ) {
var result, hasEscapedDots, hasFormatters, formatters, split, i, replacer, index, startIndex, key, keys, remaining, blanked, part;
// if this string contains no escaped dots or formatters,
// we can just split on dots, after converting from array notation
if ( !( hasEscapedDots = /\\\./.test( keypath ) ) && !( hasFormatters = /⭆.+⭅/.test( keypath ) ) ) {
return keypath.replace( /\[\s*([0-9]+)\s*\]/g, '.$1' ).split( '.' );
}
keys = [];
remaining = keypath;
// first, blank formatters in case they contain dots, but store them
// so we can reinstate them later
if ( hasFormatters ) {
formatters = [];
remaining = remaining.replace( /⭆(.+)⭅/g, function ( match, $1 ) {
var blanked, i;
formatters[ formatters.length ] = $1;
return '⭆x⭅';
});
}
startIndex = 0;
// split into keys
while ( remaining.length ) {
// find next dot
index = remaining.indexOf( '.', startIndex );
// final part?
if ( index === -1 ) {
// TODO tidy up!
part = remaining;
remaining = '';
}
else {
// if this dot is preceded by a backslash, which isn't
// itself preceded by a backslash, we consider it escaped
if ( remaining.charAt( index - 1) === '\\' && remaining.charAt( index - 2 ) !== '\\' ) {
// we don't want to keep this part, we want to keep looking
// for the separator
startIndex = index + 1;
continue;
}
// otherwise, we have our next part
part = remaining.substr( 0, index );
startIndex = 0;
}
if ( /\[/.test( part ) ) {
keys = keys.concat( part.replace( /\[\s*([0-9]+)\s*\]/g, '.$1' ).split( '.' ) );
} else {
keys[ keys.length ] = part;
}
remaining = remaining.substring( index + 1 );
}
// then, reinstate formatters
if ( hasFormatters ) {
replacer = function ( match ) {
return '⭆' + formatters.pop() + '⭅';
};
i = keys.length;
while ( i-- ) {
if ( keys[i] === '⭆x⭅' ) {
keys[i] = '⭆' + formatters.pop() + '⭅';
}
}
}
return keys;
},
getFormattersFromString: function ( str ) {
var formatters, raw, remaining;
if ( formattersCache[ str ] ) {
return formattersCache[ str ];
}
raw = str.split( '⤋' );
formatters = raw.map( function ( str ) {
var index;
index = str.indexOf( '[' );
if ( index === -1 ) {
return {
name: str,
args: []
};
}
return {
name: str.substr( 0, index ),
args: JSON.parse( str.substring( index ) )
};
});
formattersCache[ str ] = formatters;
return formatters;
},
stringifyFormatters: function ( formatters ) {
var stringified = formatters.map( function ( formatter ) {
if ( formatter.args && formatter.args.length ) {
return formatter.name + JSON.stringify( formatter.args );
}
return formatter.name;
});
return '⭆' + stringified.join( '⤋' ) + '⭅';
}
};
}());
_private.types = {
TEXT: 1,
INTERPOLATOR: 2,
TRIPLE: 3,
SECTION: 4,
INVERTED: 5,
CLOSING: 6,
ELEMENT: 7,
PARTIAL: 8,
COMMENT: 9,
DELIMCHANGE: 10,
MUSTACHE: 11,
TAG: 12,
ATTR_VALUE_TOKEN: 13
};
(function ( _private ) {
'use strict';
_private._Mustache = function ( options ) {
this.root = options.root;
this.model = options.model;
this.parent = options.parent;
this.parentFragment = options.parentFragment;
this.contextStack = options.contextStack || [];
this.index = options.index || 0;
// DOM only
if ( options.parentNode || options.anchor ) {
this.parentNode = options.parentNode;
this.anchor = options.anchor;
}
this.type = options.model.type;
this.root.registerView( this );
// if we have a failed keypath lookup, and this is an inverted section,
// we need to trigger this.update() so the contents are rendered
if ( !this.keypath && this.model.inv ) { // test both section-hood and inverticity in one go
this.update( false );
}
};
_private._Fragment = function ( options ) {
var numItems, i, itemOptions, parentRefs, ref;
this.parent = options.parent;
this.index = options.index;
this.items = [];
this.indexRefs = {};
if ( this.parent && this.parent.parentFragment ) {
parentRefs = this.parent.parentFragment.indexRefs;
for ( ref in parentRefs ) {
if ( parentRefs.hasOwnProperty( ref ) ) {
this.indexRefs[ ref ] = parentRefs[ ref ];
}
}
}
if ( options.indexRef ) {
this.indexRefs[ options.indexRef ] = options.index;
}
itemOptions = {
root: options.root,
parentFragment: this,
parent: this,
parentNode: options.parentNode,
contextStack: options.contextStack
};
numItems = ( options.model ? options.model.length : 0 );
for ( i=0; i<numItems; i+=1 ) {
itemOptions.model = options.model[i];
itemOptions.index = i;
// this.items[ this.items.length ] = createView( itemOptions );
this.items[ this.items.length ] = this.createItem( itemOptions );
}
};
_private._sectionUpdate = function ( value ) {
var fragmentOptions, valueIsArray, emptyArray, i, itemsToRemove;
fragmentOptions = {
model: this.model.frag,
root: this.root,
parentNode: this.parentNode,
parent: this
};
// TODO if DOM type, need to know anchor
if ( this.parentNode ) {
fragmentOptions.anchor = this.parentFragment.findNextNode( this );
}
valueIsArray = _private.isArray( value );
// modify the array to allow updates via push, pop etc
if ( valueIsArray && this.root.modifyArrays ) {
_private.modifyArray( value, this.keypath, this.root );
}
// treat empty arrays as false values
if ( valueIsArray && value.length === 0 ) {
emptyArray = true;
}
// if section is inverted, only check for truthiness/falsiness
if ( this.model.inv ) {
if ( value && !emptyArray ) {
if ( this.length ) {
this.unrender();
this.length = 0;
}
}
else {
if ( !this.length ) {
// no change to context stack in this situation
fragmentOptions.contextStack = this.contextStack;
fragmentOptions.index = 0;
this.fragments[0] = this.createFragment( fragmentOptions );
this.length = 1;
return;
}
}
if ( this.postUpdate ) {
this.postUpdate();
}
return;
}
// otherwise we need to work out what sort of section we're dealing with
// if value is an array, iterate through
if ( valueIsArray ) {
// if the array is shorter than it was previously, remove items
if ( value.length < this.length ) {
itemsToRemove = this.fragments.splice( value.length, this.length - value.length );
while ( itemsToRemove.length ) {
itemsToRemove.pop().teardown();
}
}
// otherwise...
else {
if ( value.length > this.length ) {
// add any new ones
for ( i=this.length; i<value.length; i+=1 ) {
// append list item to context stack
fragmentOptions.contextStack = this.contextStack.concat( this.keypath + '.' + i );
fragmentOptions.index = i;
if ( this.model.i ) {
fragmentOptions.indexRef = this.model.i;
}
this.fragments[i] = this.createFragment( fragmentOptions );
}
}
}
this.length = value.length;
}
// if value is a hash...
else if ( _private.isObject( value ) ) {
// ...then if it isn't rendered, render it, adding this.keypath to the context stack
// (if it is already rendered, then any children dependent on the context stack
// will update themselves without any prompting)
if ( !this.length ) {
// append this section to the context stack
fragmentOptions.contextStack = this.contextStack.concat( this.keypath );
fragmentOptions.index = 0;
this.fragments[0] = this.createFragment( fragmentOptions );
this.length = 1;
}
}
// otherwise render if value is truthy, unrender if falsy
else {
if ( value && !emptyArray ) {
if ( !this.length ) {
// no change to context stack
fragmentOptions.contextStack = this.contextStack;
fragmentOptions.index = 0;
this.fragments[0] = this.createFragment( fragmentOptions );
this.length = 1;
}
}
else {
if ( this.length ) {
this.unrender();
this.length = 0;
}
}
}
if ( this.postUpdate ) {
this.postUpdate();
}
};
}( _private ));
(function ( proto ) {
'use strict';
proto.on = function ( eventName, callback ) {
var self = this;
if ( !this._subs[ eventName ] ) {
this._subs[ eventName ] = [ callback ];
} else {
this._subs[ eventName ].push( callback );
}
return {
cancel: function () {
self.off( eventName, callback );
}
};
};
proto.off = function ( eventName, callback ) {
var subscribers, index;
// if no callback specified, remove all callbacks
if ( !callback ) {
// if no event name specified, remove all callbacks for all events
if ( !eventName ) {
this._subs = {};
} else {
this._subs[ eventName ] = [];
}
}
subscribers = this._subs[ eventName ];
if ( subscribers ) {
index = subscribers.indexOf( callback );
if ( index !== -1 ) {
subscribers.splice( index, 1 );
}
}
};
proto.fire = function ( eventName ) {
var args, i, len, subscribers = this._subs[ eventName ];
if ( !subscribers ) {
return;
}
args = Array.prototype.slice.call( arguments, 1 );
for ( i=0, len=subscribers.length; i<len; i+=1 ) {
subscribers[i].apply( this, args );
}
};
}( Ractive.prototype ));
// Default formatters
(function ( R ) {
'use strict';
R.formatters = {
equals: function ( a, b ) {
return a === b;
},
greaterThan: function ( a, b ) {
return a > b;
},
greaterThanEquals: function ( a, b ) {
return a >= b;
},
lessThan: function ( a, b ) {
return a < b;
},
lessThanEquals: function ( a, b ) {
return a <= b;
}
};
}( Ractive ));
(function ( R, _private ) {
'use strict';
var types, insertHtml, doc, propertyNames,
Text, Element, Partial, Attribute, Interpolator, Triple, Section;
types = _private.types;
// the property name equivalents for element attributes, where they differ
// from the lowercased attribute name
propertyNames = {
'accept-charset': 'acceptCharset',
accesskey: 'accessKey',
bgcolor: 'bgColor',
'class': 'className',
codebase: 'codeBase',
colspan: 'colSpan',
contenteditable: 'contentEditable',
datetime: 'dateTime',
dirname: 'dirName',
'for': 'htmlFor',
'http-equiv': 'httpEquiv',
ismap: 'isMap',
maxlength: 'maxLength',
novalidate: 'noValidate',
pubdate: 'pubDate',
readonly: 'readOnly',
rowspan: 'rowSpan',
tabindex: 'tabIndex',
usemap: 'useMap'
};
doc = ( typeof window !== 'undefined' ? window.document : null );
insertHtml = function ( html, docFrag ) {
var div, nodes = [];
div = doc.createElement( 'div' );
div.innerHTML = html;
while ( div.firstChild ) {
nodes[ nodes.length ] = div.firstChild;
docFrag.appendChild( div.firstChild );
}
return nodes;
};
_private.DomFragment = function ( options ) {
this.docFrag = doc.createDocumentFragment();
// if we have an HTML string, our job is easy.
if ( typeof options.model === 'string' ) {
this.nodes = insertHtml( options.model, this.docFrag );
return; // prevent the rest of the init sequence
}
// otherwise we need to make a proper fragment
_private._Fragment.call( this, options );
};
_private.DomFragment.prototype = {
createItem: function ( options ) {
if ( typeof options.model === 'string' ) {
return new Text( options, this.docFrag );
}
switch ( options.model.type ) {
case types.INTERPOLATOR: return new Interpolator( options, this.docFrag );
case types.SECTION: return new Section( options, this.docFrag );
case types.TRIPLE: return new Triple( options, this.docFrag );
case types.ELEMENT: return new Element( options, this.docFrag );
case types.PARTIAL: return new Partial( options, this.docFrag );
default: throw 'WTF? not sure what happened here...';
}
},
teardown: function () {
var node;
// if this was built from HTML, we just need to remove the nodes
if ( this.nodes ) {
while ( this.nodes.length ) {
node = this.nodes.pop();
node.parentNode.removeChild( node );
}
return;
}
// otherwise we need to do a proper teardown
while ( this.items.length ) {
this.items.pop().teardown();
}
},
firstNode: function () {
if ( this.items[0] ) {
return this.items[0].firstNode();
}
return null;
},
findNextNode: function ( item ) {
var index = item.index;
if ( this.items[ index + 1 ] ) {
return this.items[ index + 1 ].firstNode();
}
return null;
}
};
// Partials
Partial = function ( options, docFrag ) {
this.fragment = new _private.DomFragment({
model: options.root.partials[ options.model.ref ] || [],
root: options.root,
parentNode: options.parentNode,
contextStack: options.contextStack,
parent: this
});
docFrag.appendChild( this.fragment.docFrag );
};
Partial.prototype = {
teardown: function () {
this.fragment.teardown();
}
};
// Plain text
Text = function ( options, docFrag ) {
this.node = doc.createTextNode( options.model );
this.root = options.root;
this.parentNode = options.parentNode;
docFrag.appendChild( this.node );
};
Text.prototype = {
teardown: function () {
if ( this.root.el.contains( this.node ) ) {
this.parentNode.removeChild( this.node );
}
},
firstNode: function () {
return this.node;
}
};
// Element
Element = function ( options, docFrag ) {
var binding,
model,
namespace,
attr,
attrName,
attrValue,
bindable,
twowayNameAttr,
i;
// stuff we'll need later
model = this.model = options.model;
this.root = options.root;
this.parentFragment = options.parentFragment;
this.parentNode = options.parentNode;
this.index = options.index;
// get namespace
if ( model.attrs && model.attrs.xmlns ) {
namespace = model.attrs.xmlns;
// check it's a string!
if ( typeof namespace !== 'string' ) {
throw 'Namespace attribute cannot contain mustaches';
}
} else {
namespace = this.parentNode.namespaceURI;
}
// create the DOM node
this.node = doc.createElementNS( namespace, model.tag );
// append children, if there are any
if ( model.frag ) {
if ( typeof model.frag === 'string' ) {
// great! we can use innerHTML
this.node.innerHTML = model.frag;
}
else {
this.children = new _private.DomFragment({
model: model.frag,
root: options.root,
parentNode: this.node,
contextStack: options.contextStack,
parent: this
});
this.node.appendChild( this.children.docFrag );
}
}
// set attributes
this.attributes = [];
bindable = []; // save these till the end
for ( attrName in model.attrs ) {
if ( model.attrs.hasOwnProperty( attrName ) ) {
attrValue = model.attrs[ attrName ];
attr = new Attribute({
parent: this,
name: attrName,
value: ( attrValue === undefined ? null : attrValue ),
root: options.root,
parentNode: this.node,
contextStack: options.contextStack
});
this.attributes[ this.attributes.length ] = attr;
if ( attr.isBindable ) {
bindable.push( attr );
}
if ( attr.isTwowayNameAttr ) {
twowayNameAttr = attr;
} else {
attr.update();
}
}
}
while ( bindable.length ) {
bindable.pop().bind( this.root.lazy );
}
if ( twowayNameAttr ) {
twowayNameAttr.updateViewModel();
twowayNameAttr.update();
}
docFrag.appendChild( this.node );
};
Element.prototype = {
teardown: function () {
if ( this.root.el.contains( this.node ) ) {
this.parentNode.removeChild( this.node );
}
if ( this.children ) {
this.children.teardown();
}
while ( this.attributes.length ) {
this.attributes.pop().teardown();
}
},
firstNode: function () {
return this.node;
}
};
// Attribute
Attribute = function ( options ) {
var name, value, colonIndex, namespacePrefix, namespace, ancestor, tagName, bindingCandidate, lowerCaseName, propertyName;
name = options.name;
value = options.value;
this.parent = options.parent; // the element this belongs to
// are we dealing with a namespaced attribute, e.g. xlink:href?
colonIndex = name.indexOf( ':' );
if ( colonIndex !== -1 ) {
// looks like we are, yes...
namespacePrefix = name.substr( 0, colonIndex );
// ...unless it's a namespace *declaration*
if ( namespacePrefix !== 'xmlns' ) {
name = name.substring( colonIndex + 1 );
this.namespace = _private.namespaces[ namespacePrefix ];
if ( !this.namespace ) {
throw 'Unknown namespace ("' + namespacePrefix + '")';
}
}
}
// if it's an empty attribute, or just a straight key-value pair, with no
// mustache shenanigans, set the attribute accordingly
if ( value === null || typeof value === 'string' ) {
if ( this.namespace ) {
options.parentNode.setAttributeNS( this.namespace, name, value );
} else {
options.parentNode.setAttribute( name, value );
}
return;
}
// otherwise we need to do some work
this.root = options.root;
this.parentNode = options.parentNode;
this.name = name;
this.children = [];
// can we establish this attribute's property name equivalent?
if ( !this.namespace && options.parentNode.namespaceURI === _private.namespaces.html ) {
lowerCaseName = this.name.toLowerCase();
propertyName = ( propertyNames[ lowerCaseName ] ? propertyNames[ lowerCaseName ] : lowerCaseName );
if ( options.parentNode[ propertyName ] !== undefined ) {
this.propertyName = propertyName;
}
// is this a boolean attribute or 'value'? If so we're better off doing e.g.
// node.selected = true rather than node.setAttribute( 'selected', '' )
if ( typeof options.parentNode[ propertyName ] === 'boolean' || propertyName === 'value' ) {
this.useProperty = true;
}
}
// share parentFragment with parent element
this.parentFragment = this.parent.parentFragment;
this.fragment = new _private.TextFragment({
model: value,
root: this.root,
parent: this,
contextStack: options.contextStack
});
if ( this.fragment.items.length === 1 ) {
this.selfUpdating = true;
}
// if two-way binding is enabled, and we've got a dynamic `value` attribute, and this is an input or textarea, set up two-way binding
if ( this.root.twoway ) {
tagName = this.parent.model.tag.toLowerCase();
bindingCandidate = ( ( propertyName === 'name' || propertyName === 'value' || propertyName === 'checked' ) && ( tagName === 'input' || tagName === 'textarea' || tagName === 'select' ) );
}
if ( bindingCandidate ) {
this.isBindable = true;
// name attribute is a special case - it is the only two-way attribute that updates
// the viewmodel based on the value of another attribute. For that reason it must wait
// until the node has been initialised, and the viewmodel has had its first two-way
// update, before updating itself (otherwise it may disable a checkbox or radio that
// was enabled in the template)
if ( propertyName === 'name' ) {
this.isTwowayNameAttr = true;
}
}
// manually trigger first update
this.ready = true;
if ( !this.isTwowayNameAttr ) {
this.update();
}
};
Attribute.prototype = {
bind: function ( lazy ) {
// two-way binding logic should go here
var self = this, node = this.parentNode, setValue, keypath, index;
if ( !this.fragment ) {
return false; // report failure
}
// Check this is a suitable candidate for two-way binding - i.e. it is
// a single interpolator with no formatters
if (
this.fragment.items.length !== 1 ||
this.fragment.items[0].type !== _private.types.INTERPOLATOR
) {
throw 'Not a valid two-way data binding candidate - must be a single interpolator';
}
this.interpolator = this.fragment.items[0];
// Hmmm. Not sure if this is the best way to handle this ambiguity...
//
// Let's say we were given `value="{{bar}}"`. If the context stack was
// context stack was `["foo"]`, and `foo.bar` *wasn't* `undefined`, the
// keypath would be `foo.bar`. Then, any user input would result in
// `foo.bar` being updated.
//
// If, however, `foo.bar` *was* undefined, and so was `bar`, we would be
// left with an unresolved partial keypath - so we are forced to make an
// assumption. That assumption is that the input in question should
// be forced to resolve to `bar`, and any user input would affect `bar`
// and not `foo.bar`.
//
// Did that make any sense? No? Oh. Sorry. Well the moral of the story is
// be explicit when using two-way data-binding about what keypath you're
// updating. Using it in lists is probably a recipe for confusion...
keypath = this.interpolator.keypath || this.interpolator.model.ref;
// if there are any formatters, we want to disregard them when setting
if ( ( index = keypath.indexOf( '.⭆' ) ) !== -1 ) {
keypath = keypath.substr( 0, index );
}
// checkboxes and radio buttons
if ( node.type === 'checkbox' || node.type === 'radio' ) {
// We might have a situation like this:
//
// <input type='radio' name='{{colour}}' value='red'>
// <input type='radio' name='{{colour}}' value='blue'>
// <input type='radio' name='{{colour}}' value='green'>
//
// In this case we want to set `colour` to the value of whichever option
// is checked. (We assume that a value attribute has been supplied.)
if ( this.propertyName === 'name' ) {
// replace actual name attribute
node.name = '{{' + keypath + '}}';
this.updateViewModel = function () {
if ( node.checked ) {
self.root.set( keypath, node.value );
}
};
}
// Or, we might have a situation like this:
//
// <input type='checkbox' checked='{{active}}'>
//
// Here, we want to set `active` to true or false depending on whether
// the input is checked.
else if ( this.propertyName === 'checked' ) {
this.updateViewModel = function () {
self.root.set( keypath, node.checked );
};
}
}
else {
// Otherwise we've probably got a situation like this:
//
// <input value='{{name}}'>
//
// in which case we just want to set `name` whenever the user enters text.
// The same applies to selects and textareas
this.updateViewModel = function () {
var value;
if ( self.interpolator.model.fmtrs ) {
value = self.root._format( node.value, self.interpolator.model.fmtrs );
} else {
value = node.value;
}
// special cases
if ( value === '0' ) {
value = 0;
}
else if ( value !== '' ) {
value = +value || value;
}
// Note: we're counting on `this.root.set` recognising that `value` is
// already what it wants it to be, and short circuiting the process.
// Rather than triggering an infinite loop...
self.root.set( keypath, value );
};
}
// if we figured out how to bind changes to the viewmodel, add the event listeners
if ( this.updateViewModel ) {
this.twoway = true;
node.addEventListener( 'change', this.updateViewModel );
node.addEventListener( 'click', this.updateViewModel );
node.addEventListener( 'blur', this.updateViewModel );
if ( !lazy ) {
node.addEventListener( 'keyup', this.updateViewModel );
node.addEventListener( 'keydown', this.updateViewModel );
node.addEventListener( 'keypress', this.updateViewModel );
node.addEventListener( 'input', this.updateViewModel );
}
}
},
teardown: function () {
// remove the event listeners we added, if we added them
if ( this.updateViewModel ) {
this.node.removeEventListener( 'change', this.updateViewModel );
this.node.removeEventListener( 'click', this.updateViewModel );
this.node.removeEventListener( 'blur', this.updateViewModel );
this.node.removeEventListener( 'keyup', this.updateViewModel );
this.node.removeEventListener( 'keydown', this.updateViewModel );
this.node.removeEventListener( 'keypress', this.updateViewModel );
this.node.removeEventListener( 'input', this.updateViewModel );
}
// ignore non-dynamic attributes
if ( !this.children ) {
return;
}
while ( this.children.length ) {
this.children.pop().teardown();
}
},
bubble: function () {
if ( this.selfUpdating ) {
this.update();
}
else if ( !this.updateDeferred ) {
this.root.deferredAttributes[ this.root.deferredAttributes.length ] = this;
this.updateDeferred = true;
}
},
update: function () {
var value, lowerCaseName;
if ( !this.ready ) {
return this; // avoid items bubbling to the surface when we're still initialising
}
if ( this.twoway ) {
// TODO compare against previous?
lowerCaseName = this.name.toLowerCase();
this.value = this.interpolator.value;
// special case - if we have an element like this:
//
// <input type='radio' name='{{colour}}' value='red'>
//
// and `colour` has been set to 'red', we don't want to change the name attribute
// to red, we want to indicate that this is the selected option, by setting
// input.checked = true
if ( lowerCaseName === 'name' && ( this.parentNode.type === 'checkbox' || this.parentNode.type === 'radio' ) ) {
if ( this.value === this.parentNode.value ) {
this.parentNode.checked = true;
} else {
this.parentNode.checked = false;
}
return this;
}
// don't programmatically update focused element
if ( doc.activeElement === this.parentNode ) {
return this;
}
}
value = this.fragment.getValue();
if ( value === undefined ) {
value = '';
}
if ( this.useProperty ) {
this.parentNode[ this.propertyName ] = value;
return this;
}
if ( this.namespace ) {
this.parentNode.setAttributeNS( this.namespace, this.name, value );
return this;
}
this.parentNode.setAttribute( this.name, value );
return this;
}
};
// Interpolator
Interpolator = function ( options, docFrag ) {
this.node = doc.createTextNode( '' );
docFrag.appendChild( this.node );
// extend Mustache
_private._Mustache.call( this, options );
};
Interpolator.prototype = {
teardown: function () {
if ( !this.observerRefs ) {
this.root.cancelKeypathResolution( this );
} else {
this.root.unobserveAll( this.observerRefs );
}
if ( this.root.el.contains( this.node ) ) {
this.parentNode.removeChild( this.node );
}
},
update: function ( text ) {
if ( text !== this.text ) {
this.text = text;
this.node.data = text;
}
},
firstNode: function () {
return this.node;
}
};
// Triple
Triple = function ( options, docFrag ) {
this.nodes = [];
this.docFrag = doc.createDocumentFragment();
this.initialising = true;
_private._Mustache.call( this, options );
docFrag.appendChild( this.docFrag );
this.initialising = false;
};
Triple.prototype = {
teardown: function () {
// remove child nodes from DOM
if ( this.root.el.contains( this.parentNode ) ) {
while ( this.nodes.length ) {
this.parentNode.removeChild( this.nodes.pop() );
}
}
// kill observer(s)
if ( !this.observerRefs ) {
this.root.cancelKeypathResolution( this );
} else {
this.root.unobserveAll( this.observerRefs );
}
},
firstNode: function () {
if ( this.nodes[0] ) {
return this.nodes[0];
}
return this.parentFragment.findNextNode( this );
},
update: function ( html ) {
if ( html === this.html ) {
return;
}
this.html = html;
// remove existing nodes
while ( this.nodes.length ) {
this.parentNode.removeChild( this.nodes.pop() );
}
// get new nodes
this.nodes = insertHtml( html, this.docFrag );
if ( !this.initialising ) {
this.parentNode.insertBefore( this.docFrag, this.parentFragment.findNextNode( this ) );
}
}
};
// Section
Section = function ( options, docFrag ) {
this.fragments = [];
this.length = 0; // number of times this section is rendered
this.docFrag = doc.createDocumentFragment();
this.initialising = true;
_private._Mustache.call( this, options );
docFrag.appendChild( this.docFrag );
this.initialising = false;
};
Section.prototype = {
teardown: function () {
this.unrender();
if ( !this.observerRefs ) {
this.root.cancelKeypathResolution( this );
} else {
this.root.unobserveAll( this.observerRefs );
}
},
firstNode: function () {
if ( this.fragments[0] ) {
return this.fragments[0].firstNode();
}
return this.parentFragment.findNextNode( this );
},
findNextNode: function ( fragment ) {
if ( this.fragments[ fragment.index + 1 ] ) {
return this.fragments[ fragment.index + 1 ].firstNode();
}
return this.parentFragment.findNextNode( this );
},
unrender: function () {
while ( this.fragments.length ) {
this.fragments.shift().teardown();
}
},
update: function ( value ) {
_private._sectionUpdate.call( this, value );
if ( !this.initialising ) {
// we need to insert the contents of our document fragment into the correct place
this.parentNode.insertBefore( this.docFrag, this.parentFragment.findNextNode( this ) );
}
},
createFragment: function ( options ) {
var fragment = new _private.DomFragment( options );
this.docFrag.appendChild( fragment.docFrag );
return fragment;
}
};
}( Ractive, _private ));
(function ( _private ) {
'use strict';
var types,
Text, Interpolator, Triple, Section;
types = _private.types;
_private.TextFragment = function ( options ) {
_private._Fragment.call( this, options );
};
_private.TextFragment.prototype = {
createItem: function ( options ) {
if ( typeof options.model === 'string' ) {
return new Text( options.model );
}
switch ( options.model.type ) {
case types.INTERPOLATOR: return new Interpolator( options );
case types.TRIPLE: return new Triple( options );
case types.SECTION: return new Section( options );
default: throw 'Something went wrong in a rather interesting way';
}
},
bubble: function () {
this.value = this.getValue();
this.parent.bubble();
},
teardown: function () {
var numItems, i;
numItems = this.items.length;
for ( i=0; i<numItems; i+=1 ) {
this.items[i].teardown();
}
},
getValue: function () {
var value;
if ( this.items.length === 1 ) {
value = this.items[0].value;
if ( value !== undefined ) {
return value;
}
}
return this.toString();
},
toString: function () {
// TODO refactor this... value should already have been calculated? or maybe not. Top-level items skip the fragment and bubble straight to the attribute...
// argh, it's confusing me
return this.items.join( '' );
}
};
// Plain text
Text = function ( text ) {
this.text = text;
};
Text.prototype = {
toString: function () {
return this.text;
},
teardown: function () {} // no-op
};
// Mustaches
// Interpolator or Triple
Interpolator = function ( options ) {
_private._Mustache.call( this, options );
};
Interpolator.prototype = {
update: function ( value ) {
this.value = value;
this.parent.bubble();
},
teardown: function () {
if ( !this.observerRefs ) {
this.root.cancelKeypathResolution( this );
} else {
this.root.unobserveAll( this.observerRefs );
}
},
toString: function () {
return ( this.value === undefined ? '' : this.value );
}
};
// Triples are the same as Interpolators in this context
Triple = Interpolator;
// Section
Section = function ( options ) {
this.fragments = [];
this.length = 0;
_private._Mustache.call( this, options );
};
Section.prototype = {
teardown: function () {
this.unrender();
if ( !this.observerRefs ) {
this.root.cancelKeypathResolution( this );
} else {
this.root.unobserveAll( this.observerRefs );
}
},
unrender: function () {
while ( this.fragments.length ) {
this.fragments.shift().teardown();
}
this.length = 0;
},
bubble: function () {
this.value = this.fragments.join( '' );
this.parent.bubble();
},
update: function ( value ) {
_private._sectionUpdate.call( this, value );
},
createFragment: function ( options ) {
return new _private.TextFragment( options );
},
postUpdate: function () {
this.value = this.fragments.join( '' );
this.parent.bubble();
},
toString: function () {
return this.fragments.join( '' );
//return ( this.value === undefined ? '' : this.value );
}
};
}( _private ));
(function ( R ) {
'use strict';
R.extend = function ( childProps ) {
var Parent, Child, key;
Parent = this;
Child = function () {
R.apply( this, arguments );
if ( this.init ) {
this.init.apply( this, arguments );
}
};
// extend child with parent methods
for ( key in Parent.prototype ) {
if ( Parent.prototype.hasOwnProperty( key ) ) {
Child.prototype[ key ] = Parent.prototype[ key ];
}
}
// extend child with specified methods, as long as they don't override Ractive.prototype methods
for ( key in childProps ) {
if ( childProps.hasOwnProperty( key ) ) {
if ( R.prototype.hasOwnProperty( key ) ) {
throw new Error( 'Cannot override "' + key + '" method or property of Ractive prototype' );
}
Child.prototype[ key ] = childProps[ key ];
}
}
Child.extend = Parent.extend;
return Child;
};
}( Ractive ));
(function ( _private ) {
'use strict';
var wrapMethods;
_private.modifyArray = function ( array, keypath, root ) {
var roots, keypathsByIndex, rootIndex, keypaths;
if ( !array._ractive ) {
array._ractive = {
roots: [ root ],
keypathsByIndex: [ [ keypath ] ]
};
wrapMethods( array );
}
else {
roots = array._ractive.roots;
keypathsByIndex = array._ractive.keypathsByIndex;
// see if this root is currently associated with this array
rootIndex = roots.indexOf( root );
// if n