UNPKG

ractive

Version:

Next-generation DOM manipulation

2,077 lines (1,589 loc) 59.1 kB
/*! 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