diff --git a/src/data/UsStates.js b/src/data/UsStates.js
new file mode 100644
index 0000000..4221bf6
--- /dev/null
+++ b/src/data/UsStates.js
@@ -0,0 +1,101 @@
+/**
+ * List of US states and codes
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+
+/**
+ * List of states and their codes
+ * @var {Object}
+ */
+var states = {
+ 'AL': 'Alabama',
+ 'AK': 'Alaska',
+ 'AZ': 'Arizona',
+ 'AR': 'Arkansas',
+ 'CA': 'California',
+ 'CO': 'Colorado',
+ 'CT': 'Connecticut',
+ 'DE': 'Delaware',
+ 'FL': 'Florida',
+ 'GA': 'Georgia',
+ 'HI': 'Hawaii',
+ 'ID': 'Idaho',
+ 'IL': 'Illinois',
+ 'IN': 'Indiana',
+ 'IA': 'Iowa',
+ 'KS': 'Kansas',
+ 'KY': 'Kentucky',
+ 'LA': 'Louisiana',
+ 'ME': 'Maine',
+ 'MD': 'Maryland',
+ 'MA': 'Massachusetts',
+ 'MI': 'Michigan',
+ 'MN': 'Minnesota',
+ 'MS': 'Mississippi',
+ 'MO': 'Missouri',
+ 'MT': 'Montana',
+ 'NE': 'Nebraska',
+ 'NV': 'Nevada',
+ 'NH': 'New Hampshire',
+ 'NJ': 'New Jersey',
+ 'NM': 'New Mexico',
+ 'NY': 'New York',
+ 'NC': 'North Carolina',
+ 'ND': 'North Dakota',
+ 'OH': 'Ohio',
+ 'OK': 'Oklahoma',
+ 'OR': 'Oregon',
+ 'PA': 'Pennsylvania',
+ 'RI': 'Rhode Island',
+ 'SC': 'South Carolina',
+ 'SD': 'South Dakota',
+ 'TN': 'Tennessee',
+ 'TX': 'Texas',
+ 'UT': 'Utah',
+ 'VT': 'Vermont',
+ 'VA': 'Virginia',
+ 'WA': 'Washington',
+ 'WV': 'West Virginia',
+ 'WI': 'Wisconsin',
+ 'WY': 'Wyoming',
+
+ // nothing
+ '' : '',
+ 0: ''
+};
+
+
+/**
+ * Gets the name of the state associated with the given code
+ *
+ * @param {string} code state abbr
+ *
+ * @return {string} name of state
+ */
+exports.getName = function( code )
+{
+ var val = states[ code ];
+
+ // if the value was not found, return the code we were given (which likely
+ // makes no sense at all, since it's not a valid state)
+ return ( val === undefined )
+ ? code
+ : val;
+}
diff --git a/src/sort/MultiSort.js b/src/sort/MultiSort.js
index 37c7d35..943a981 100644
--- a/src/sort/MultiSort.js
+++ b/src/sort/MultiSort.js
@@ -22,7 +22,6 @@
* - References to "quote" should be replaced with generic terminology
* representing a document.
* - Dependencies need to be liberated:
- * - ElementStyler;
* - BucketDataValidator.
* - Global references (e.g. jQuery) must be removed.
* - Checkbox-specific logic must be extracted.
diff --git a/src/ui/ElementStyler.js b/src/ui/ElementStyler.js
new file mode 100644
index 0000000..1fd1675
--- /dev/null
+++ b/src/ui/ElementStyler.js
@@ -0,0 +1,1347 @@
+/**
+ * Archaic DOM element styler
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ * @needsLove
+ * - Everything! This class exists from when the framework was barely
+ * more than a few prototypes and has rotted ever since with little else
+ * but workarounds.
+ * @end needsLove
+ */
+
+var State = require( '../data/UsStates' ),
+ Class = require( 'easejs' ).Class;
+
+
+/**
+ * Styles DOM elements
+ *
+ * This class styles DOM elements with Dojo's Dijits (widgets).
+ *
+ * @return void
+ */
+module.exports = Class( 'ElementStyler',
+{
+ /**
+ * Stores element types as an associative array
+ * @type {Object.}
+ */
+ elementTypes: {},
+
+ 'private _answerRefs': {},
+
+ /**
+ * Stores help text for elements
+ * @type {Object.}
+ */
+ elementHelp: {},
+
+ /**
+ * Whether to show internal questions
+ * @type {boolean}
+ */
+ _showInternal: false,
+
+ /**
+ * Stores defaults for questions
+ * @type {Object}
+ */
+ 'private _defaults': {},
+
+ /**
+ * Stores defaults for display only
+ * @type {Object}
+ */
+ 'private _displayDefaults': {},
+
+ /**
+ * Contains data for select
+ * @type {Object.}
+ */
+ 'private _selectData': {},
+
+ /**
+ * Selector context
+ * @type {jQuery}
+ */
+ 'private _$context': null,
+
+
+ _answerStyles: {
+ 'currency': function( value )
+ {
+ var pre = '$';
+
+ if ( !value )
+ {
+ value = 0;
+ }
+ else if ( value < 0 )
+ {
+ value *= -1;
+ pre = '-$';
+ }
+
+ var formatter = this._getAnswerStyler( 'number' );
+
+ return pre + formatter.call( this, ( +value ).toFixed( 2 ) );
+ },
+
+ 'dollars': function( value )
+ {
+ var formatter = this._getAnswerStyler( 'currency' );
+
+ return formatter.call( this, ( +value ).toFixed( 0 ) );
+ },
+
+ 'float': function( value )
+ {
+ if ( !value )
+ {
+ value = 0;
+ }
+
+ var formatter = this._getAnswerStyler( 'number' );
+
+ return formatter.call( this, ( +value ).toFixed( 2 ) );
+ },
+
+ 'limit': function( value, _, default_val )
+ {
+ value = ( value + '' ).replace( ',', '' );
+
+
+ // if no value was given, be sure that we use the proper default
+ // value
+ if ( ( value === '' ) && default_val )
+ {
+ return default_val;
+ }
+
+ // if the value is simply a string, return it
+ if ( /^[a-z_ -]+$/i.test( value ) )
+ {
+ return value;
+ }
+
+ // split on multiple limits (this will work fine if there's only
+ // one)
+ var data = ( ''+( value ) ).split( '/' ),
+ i = data.length;
+
+ // simple comma addition (for thousands); XXX: this mess is an
+ // abomination
+ while ( i-- )
+ {
+ data[ i ] = data[ i ]
+ .replace( /^.*(?:[0-9]{3}|[1-9]0{2})$/, function( number )
+ {
+ var len = number.length,
+ ret = '';
+
+ // insert thousands separators into their proper
+ // positions
+ for ( var i = 0; i < len; i++ )
+ {
+ ret += number.substr( i, 1 );
+
+ if ( ( ( len - i ) % 3 ) === 1 )
+ {
+ ret += ',';
+ }
+ }
+
+ // could handle this in the above loop, but this is more
+ // clear
+ return ret.replace( /,$/, '' );
+ } )
+ .replace( /^,?([0-9]{2,})$/, '$1,000' )
+ .replace( /^,/, '' );
+ }
+
+ // re-join multiple limits
+ return data.join( '/' );
+ },
+
+ 'multilimit': function( values, _, default_val )
+ {
+ var limit = this._getAnswerStyler( 'limit' );
+
+ // if we're not an array, fall back to the normal limit styler
+ if ( typeof values !== 'object' )
+ {
+ return limit.apply( this, arguments );
+ }
+
+ var formatted = [],
+ same = true;
+
+ for ( var i in values )
+ {
+ formatted.push(
+ limit.call( this, values[ i ], _, default_val )
+ );
+
+ if ( i > 0 )
+ {
+ same = ( same && formatted[ i ] === formatted[ i - 1 ] );
+ }
+ }
+
+ return ( same )
+ ? formatted[ 0 ]
+ : formatted.join( '; ' );
+ },
+
+ 'multitext': function( values, _, default_val )
+ {
+ // for now
+ this._getAnswerStyler( 'multilimit' ).apply(
+ this, arguments
+ );
+ },
+
+ 'deductible': function( value, _, default_val )
+ {
+ // if no value was given, be sure that we use the proper default
+ // value
+ if ( ( value === '' ) && default_val )
+ {
+ return default_val;
+ }
+
+ return value + ' Deductible';
+ },
+
+ /*
+ * display as accepted, rejected or default if available
+ */
+ 'acceptReject': function( value, _, default_val )
+ {
+ // use the default if no value
+ if ( ( value === '' ) && default_val )
+ {
+ return default_val;
+ }
+
+ var ret = value;
+
+ if ( +value === 0 )
+ {
+ ret = 'Rejected';
+ }
+ else if ( +value === 1 )
+ {
+ ret = 'Accepted';
+ }
+
+ return ret;
+ },
+
+
+ 'includeExclude': function( value, _, default_val )
+ {
+ // use the default if no value
+ if ( ( value === '' ) && default_val )
+ {
+ return default_val;
+ }
+
+ return ( +value === 0 )
+ ? 'Excluded'
+ : 'Included';
+ },
+
+
+ /*
+ * display as a limit, rejected or default if available
+ */
+ 'limitReject': function( value, _, default_val )
+ {
+ // use the default if no value
+ if ( ( value === '' ) && default_val )
+ {
+ return default_val;
+ }
+
+ var limit = this._getAnswerStyler( 'number' );
+
+ if ( +value === 0 )
+ {
+ ret = 'Rejected';
+ }
+ else if ( +value === 1 )
+ {
+ ret = 'Accepted';
+ }
+ else
+ {
+ ret = limit.apply( this, arguments );
+ }
+
+ return ret;
+ },
+
+
+ /**
+ * format thousands
+ */
+ 'number': function( value )
+ {
+ var str = value.toString().split( '.' );
+ var len = str[0].length,
+ ret = '';
+
+ for ( var i = 0; i < len; i++ )
+ {
+ ret += str[0].charAt( i );
+
+ if ( ( ( len - i ) % 3 ) === 1 )
+ {
+ ret += ',';
+ }
+ }
+
+ str[0] = ret.replace( /,$/, '' );
+
+ return str.join( '.' )
+ },
+
+ 'state': function( value )
+ {
+ return State.getName( value );
+ },
+
+ /**
+ * Styles a no-yes answer
+ *
+ * A non-zero value is considered to be "Yes". An empty string (unless a
+ * default value is given) and "0" are considered to be "No". Default
+ * value is only returned if an empty string is provided.
+ *
+ * @param {string} value value to style
+ * @param {*} _ ignored
+ * @param {string_ default_val default value
+ *
+ * @return {string} styled answer
+ */
+ 'noyes': function( value, _, default_val )
+ {
+ // if a default value is provided, we will interpret an empty string
+ // as no value and return the default
+ if ( ( value === '' ) && default_val )
+ {
+ return default_val;
+ }
+
+ return ( value && ( value !== '0' ) )
+ ? 'Yes'
+ : 'No';
+ },
+
+ 'select': function( value, ref_id )
+ {
+ var val = this._selectData[ref_id][value];
+
+ // return the string associated with the given value
+ // (the text for the option), or the given value if it does not
+ // exist
+ return ( val === undefined )
+ ? value
+ : val;
+ },
+
+ 'manualDate': function( value )
+ {
+ if( value.replace )
+ {
+ return value.replace(
+ /^([0-9]{4})-([0-9]+)-([0-9]+)$/,
+ '$2/$3/$1'
+ );
+ }
+
+ return null;
+ },
+
+ 'date': function( value )
+ {
+ var data = value.split( '-' );
+
+ // m/d/y
+ return data[1] + '/' + data[2] + '/' + data[0];
+ },
+
+ 'dateTime': function( value )
+ {
+ var ret_val = new Date( ( +value ) * 1000 );
+
+ // do not attempt to format if invalid date
+ if ( isNaN( ret_val.getDate() ) || value === '' )
+ {
+ return '';
+ }
+
+ return ( ret_val.getMonth() + 1 ) + '/'
+ + ret_val.getDate() + '/'
+ + ret_val.getFullYear();
+ },
+
+ percent: function( value )
+ {
+ return value + '%';
+ },
+
+ waitable: function( value )
+ {
+ return value.replace(
+ /Please wait.../,
+ '$&
'
+ );
+ }
+ },
+
+
+ __construct: function( jquery )
+ {
+ this._$context = jquery;
+ },
+
+
+ /**
+ * Returns the function to be used for the widget jQuery selector
+ *
+ * @return Function selector function
+ */
+ getWidgetSelector: function()
+ {
+ return function( node, i, data )
+ {
+ // name of the widget we're searching for
+ var name = data[3],
+ $node = $( node );
+
+ // if it's not a widget, bail
+ if ( !( $node.hasClass( 'widget' ) ) )
+ {
+ return false;
+ }
+
+ // if no name was given, then don't check for it (they just want to
+ // know if this is a widget)
+ if ( !( name ) )
+ {
+ return true;
+ }
+
+ // quick name check on self
+ if ( $node.attr( 'name' ) === ( name + '[]' ) )
+ {
+ return true;
+ }
+
+ // attempt to locate the element with the name we're looking for
+ var $named = $node.find( '[name="' + name + '[]"]' );
+ if ( $named.length === 0 )
+ {
+ return false;
+ }
+
+ return true;
+ }
+ },
+
+
+ getWidgetIdSelector: function()
+ {
+ return function( node, i, data )
+ {
+ // name of the widget we're searching for
+ var id = data[3],
+ $node = $( node );
+
+ if ( !( $node.hasClass( 'widget' ) ) )
+ {
+ return false;
+ }
+
+ // if no name was given, then don't check for it (they just want to
+ // know if this is a widget)
+ if ( !( name ) )
+ {
+ return true;
+ }
+
+ // quick name check on self
+ if ( $node.attr( 'id' ) === id )
+ {
+ return true;
+ }
+
+ // attempt to locate the element with the name we're looking for
+ var $named = $node.find( '[id="' + id + '"]' );
+ if ( $named.length === 0 )
+ {
+ return false;
+ }
+
+ return true;
+ }
+ },
+
+
+ /**
+ * Applies the style to all DOM elements that are descendants of $content
+ *
+ * This method operates off of a very basic concept. It takes an array of
+ * data containing a jQuery selector and applies the associated attributes
+ * to the elements. These attributes are non-standard - that is, they are
+ * not valid HTML attributes. Dojo then parses out these attributes and
+ * generates the dijit HTML, replacing the existing element.
+ *
+ * @param jQuery $content parent element containing elements to style
+ *
+ * @return ElementStyler self to support method chaining
+ */
+ apply: function( $content, parse )
+ {
+ parse = ( parse === undefined ) ? true : !!parse;
+
+ // if we're internal, show internal questions
+ if ( this._showInternal )
+ {
+ $content.find( '.hidden.i' ).removeClass( 'hidden' );
+ }
+
+ return this;
+ },
+
+
+ /**
+ * Called after the content is appended to the DOM for the first time
+ *
+ * This is used to do final processing for display. In this case, performing
+ * the actual styling based off of the previously set attributes.
+ *
+ * @return ElementStyler self to allow for method chaining
+ */
+ postAppend: function( $content )
+ {
+ return this;
+ },
+
+
+ /**
+ * Retrieves the id associated with the given element
+ *
+ * The problem is that Dijits do not always use a single element. They'll
+ * often have multiple elements in order to achieve a certain effect. The id
+ * may be on a different element than the one that contains the correct name
+ * attribute.
+ *
+ * This method will attempt to find the id by checking first the given
+ * element, then its siblings, followed finally by its children. Having to
+ * check for siblings is slower than accessing directly, and having to go so
+ * far as to check the children is the slowest. It is uncommon to have to
+ * check the children, so that check is performed last.
+ *
+ * @param jQuery $element element to get id of
+ *
+ * @return String element id or undefined
+ */
+ getIdFromElement: function( $element )
+ {
+ // check to see if the given element has the id we're looking for
+ // first, otherwise the siblings/children most likely contain the id
+ // we're looking for
+ return $element.attr( 'widgetid' )
+ || $element.attr( 'id' )
+ || $element.siblings().filter( '[id]' ).attr( 'id' )
+ || $element.children().filter( '[id]' ).attr( 'id' );
+ },
+
+
+ /**
+ * Gets the name from a given element
+ *
+ * This method is needed because the name attribute may exist on a different
+ * element than the one provided.
+ *
+ * @return String|undefined
+ */
+ getNameFromElement: function( $element )
+ {
+ if ( !( $element instanceof jQuery ) )
+ {
+ // assume it's an id
+ $element = $( '#' + $element );
+ }
+
+ return $element.attr( 'name' )
+ || $element.siblings().filter( '[name]' ).attr( 'name' )
+ || $element.children().filter( '[name]' ).attr( 'name' )
+ || '';
+ },
+
+
+ /**
+ * Returns the element with the name attribute
+ *
+ * This allows referencing the element that should be posted
+ *
+ * @return jQuery named element
+ */
+ getNameElement: function( $element )
+ {
+ // if the passed element has a name attribute, then no searching is
+ // needed
+ if ( $element.attr( 'name' ) )
+ {
+ return $element;
+ }
+
+ // attempt to find from siblings and children
+ return $element.siblings().filter( '[name]' )
+ || $element.children().filter( '[name]' )
+ },
+
+
+ 'public setOptions': function( name, index, options, val, $context )
+ {
+ var $element = this.getElementByName( name, index, null, $context );
+
+ // if the provided question is not a select, then we cannot add options
+ // to it---use the first index instead
+ if ( this._getElementType( name ) !== 'select' )
+ {
+ $element.val( ( options[ 0 ] || { value: '' } ).value );
+ return;
+ }
+
+ // store the old value
+ val = val || $element.val();
+
+ $element.html('');
+
+ var answer_data = this._selectData[ name ] = {};
+ for ( var item in options )
+ {
+ var opt = options[ item ],
+ opt_value = opt.value === undefined || opt.value === null ? '' : opt.value;
+
+ answer_data[ opt_value ] = opt.label;
+
+ $element.append(
+ $( '' )
+ .attr( 'value', opt_value )
+ .text( opt.label )
+ );
+
+ // if we found our old value, re-select it (note that the string
+ // cast is important; bucket values are usually strings but the
+ // values we set may not be)
+ if ( ''+opt_value === ''+val )
+ {
+ $element.val( val );
+ }
+ }
+
+ return this;
+ },
+
+
+ 'public clearOptions': function( name, index )
+ {
+ return this.setOptions( name, index, [] );
+ },
+
+
+ /**
+ * Sets element value given a name and index
+ *
+ * @param {string} name element name
+ * @param {number} index index to set
+ * @param {string} value value to set
+ * @param {boolean} change_event whether to trigger change event
+ * @param {jQuery=} $context optional DOM element context
+ *
+ * @return {ElementStyler} self
+ */
+ setValueByName: function( name, index, value, change_event, $context )
+ {
+ change_event = ( change_event === undefined ) ? true : change_event;
+
+ var $element;
+
+ // set value
+ switch ( this._getElementType( name ) )
+ {
+ case 'noyes':
+ case 'radio':
+ case 'legacyradio':
+ var elements = [];
+ if ( $context && $context.singleIndex )
+ {
+ // get all elements
+ elements = this.getElementByName(
+ name, undefined, null, $context
+ );
+ }
+ else
+ {
+ var group_length = this.getElementLength( name, $context ),
+ current = index * group_length,
+ end = ( ( ( index + 1 ) * group_length ) - 1 );
+ while ( current <= end )
+ {
+ elements.push( this.getWidgetByName(
+ name, current, null, $context
+ ) );
+
+ current++;
+ }
+ }
+
+ var i = elements.length;
+ while ( i-- )
+ {
+ var $question = $( elements[ i ] );
+
+ if ( $question.attr( 'value' ) == value )
+ {
+ $question.attr( 'checked', true );
+ $element = $question;
+ }
+ else
+ {
+ $question.attr( 'checked', false );
+ }
+ }
+
+ break;
+
+ default:
+ $element = this.getElementByName(
+ name, index, null, $context
+ );
+ $element.val( ''+( value ) );
+ }
+
+ // the autochange propery signifies that we should trigger the
+ // change event
+ if ( $element !== undefined && ( change_event || ( $element.data( 'autochange' ) === true ) ) )
+ {
+ $element.trigger( 'change' );
+ }
+
+ return this;
+ },
+
+
+ styleAnswer: function( ref_id, value )
+ {
+ var type = this._getElementType( ref_id );
+ if ( !( type ) )
+ {
+ return value;
+ }
+
+ var format = this._getAnswerStyler( type ),
+ default_val = this._displayDefaults[ ref_id ]
+ || this._defaults[ ref_id ];
+
+ if ( format )
+ {
+ return format.call( this, value, ref_id, default_val );
+ }
+
+ return ( value === '' || typeof value === 'undefined' )
+ ? default_val || value
+ : value;
+ },
+
+
+ /**
+ * Determine field type
+ *
+ * This maintains BC: the old data format used a string to represent
+ * the type, whereas the new system uses an object describing additional
+ * details.
+ *
+ * @param {string} name type name
+ *
+ * @return {string|undefined} type of field NAME
+ */
+ 'private _getElementType': function( name )
+ {
+ var type_data = this.elementTypes[ name ];
+
+ return ( type_data )
+ ? type_data.type || type_data
+ : undefined;
+ },
+
+
+ 'private _getAnswerStyler': function( type )
+ {
+ return this._answerStyles[ type ];
+ },
+
+
+ setTypeData: function( data )
+ {
+ this.elementTypes = data;
+ return this;
+ },
+
+
+ 'public setAnswerRefs': function( data )
+ {
+ this._answerRefs = data;
+ return this;
+ },
+
+
+ showInternal: function()
+ {
+ this._showInternal = true;
+ return this;
+ },
+
+
+ addAnswerStyle: function( type, handler )
+ {
+ this._answerStyles[type] = handler;
+ return this;
+ },
+
+
+ /**
+ * Removes the element identified by the given id from the DOM
+ *
+ * This will ensure that styled elements are also removed from memory, so
+ * that the same id can later be styled if it is readded.
+ *
+ * If the element is not styled, this method will fall back on jQuery to
+ * attempt to locate and remove it.
+ *
+ * @return ElementStyler self to allow for method chaining
+ */
+ remove: function( id )
+ {
+ if ( !id )
+ {
+ return this;
+ }
+
+ var $element = ( id instanceof jQuery )
+ ? id
+ : $( '#' + id );
+
+ $element.remove();
+
+ return this;
+ },
+
+
+ /**
+ * Focuses on an element and optionally displays the tooltip
+ *
+ * @param jQuery $element element to focus on
+ * @param Boolean tooltip whether to display the tooltip
+ * @param String tooltip_text text to display on tooltip (optional)
+ *
+ * @return ElementStyler self to allow for method chaining
+ */
+ focus: function( $element, tooltip, tooltip_text )
+ {
+ tooltip = !!tooltip;
+
+ // place focus on the element
+ $element.focus();
+
+ return this;
+ },
+
+
+ /**
+ * Get the element with an id associated with the given name
+ *
+ * Styled elements may have separate DOM elements for the name and the id of
+ * the original element. In some cases, for example, the named element is a
+ * hidden field, whereas the id element is the actual entry textbox that
+ * should have focus. This method aims to return the element that should
+ * receive that focus.
+ *
+ * @param String name name of the element
+ * @param Integer index index of the element (0 by default)
+ *
+ * @return jQuery requested element
+ */
+ getIdElementFromName: function( name, index, $parent )
+ {
+ index = +index || 0;
+ $parent = $parent || $( 'body' );
+
+ var $element = $parent.find(
+ "[name='" + name + "[]']:nth(" + index + ")"
+ );
+ var id = this.getIdFromElement( $element );
+
+ return $( '#' + id );
+ },
+
+
+ /**
+ * Disable a field
+ *
+ * @param {string} id id of the field to disable
+ * @param {boolean} value whether to disable the element
+ *
+ * @return {ElementStyler} self
+ */
+ disable: function( name, value )
+ {
+ var $elements = ( name instanceof jQuery )
+ ? name
+ : this.getElementByName( name );
+
+ $elements.attr( 'disabled', !!value );
+
+ console.error( '[Deprecated] ElementStyler.disable()' );
+ return this;
+ },
+
+
+ /**
+ * Disable the given field
+ *
+ * @param {string} name field name
+ * @param {number} index field index
+ * @param {boolean=} disable whether to disable (default true)
+ *
+ * @return {ElementStyler} self
+ */
+ 'public disableField': function( name, index, disable, $context )
+ {
+ disable = ( disable === undefined ) ? true : !!disable;
+
+ var $e = this.getElementByName( name, index, null, $context );
+ if ( !disable && $e.hasClass( 'readonly' ) )
+ {
+ // do not enable read-only fields
+ return;
+ }
+
+ $e.attr( 'disabled', disable );
+ return this;
+ },
+
+
+ /**
+ * Enable the given field
+ *
+ * @param {string} name field name
+ * @param {number} index field index
+ *
+ * @return {ElementStyler} self
+ */
+ 'public enableField': function( name, index )
+ {
+ this.disableField( name, index, false );
+ },
+
+
+ /**
+ * Sets help data
+ *
+ * Should be provided as an associative array, with the question name as the
+ * key and the help text as the value.
+ *
+ * @param {Object} data help data
+ *
+ * @return {ElementStyler} self
+ */
+ setHelpData: function( data )
+ {
+ this.elementHelp = data;
+ return this;
+ },
+
+
+ /**
+ * Returns the help text associated with the given element id
+ *
+ * If no help text is available, this method will fall back on the invalid
+ * message.
+ *
+ * @param {jQuery} $element element
+ *
+ * @return {string} help message
+ */
+ getHelpMessage: function( $element )
+ {
+ var name = ( $element.attr( 'name' ) || '' ).replace( /\[\]$/, '' );
+ return this.elementHelp[ name ] || '';
+ },
+
+
+ setDefaults: function( defaults )
+ {
+ this._defaults = defaults;
+ return this;
+ },
+
+
+ 'public setDisplayDefaults': function( defaults )
+ {
+ this._displayDefaults = defaults;
+ return this;
+ },
+
+
+ getDefault: function( name )
+ {
+ return this._defaults[ name ];
+ },
+
+
+ 'public setSelectData': function( data )
+ {
+ this._selectData = data;
+ },
+
+
+ 'public setContext': function( $ )
+ {
+ this._$context = $;
+ },
+
+
+ /**
+ * Retrieve widgets by the given name and optional index
+ *
+ * This allows for a simple mapping from bucket to UI.
+ *
+ * @param {string} name element name (question name)
+ * @param {number=} index index of element to retrieve (bucket index)
+ * @param {string=} filter filter to apply to widgets
+ * @param {jQuery=} $context filtering context
+ *
+ * @return {jQuery} matches
+ */
+ 'public getWidgetByName': function( name, index, filter, $context )
+ {
+ $context = $context || this._$context;
+
+ // find the field; note that we *skip* the index selection if we have
+ // been notified---via a property on the context---that the content
+ // should contain only the index we are looking for
+ var $results = this._getWidgetByNameQuick( name, index, $context );
+
+ if ( filter )
+ {
+ return $results.filter( filter );
+ }
+
+ return $results;
+ },
+
+
+ /**
+ * Attempt to quickly locate an element by id
+ *
+ * Otherwise, we have to fall back to scanning the DOM. Note that, if we do
+ * not find a match on the id, this will be slower than if we hadn't
+ * performed the check to begin with, so the idea is to find the id for as
+ * many as possible.
+ */
+ 'private _getWidgetByNameQuick': function( name, index, $context )
+ {
+ var hasindex = ( ( index !== undefined ) && !$context.singleIndex );
+
+ if ( hasindex )
+ {
+ var id = this._getElementId( name, index, $context );
+
+ if ( id )
+ {
+ $element = $context.find( '#' + id );
+
+ // let's hope for the best
+ if ( $element.length )
+ {
+ return $element;
+ }
+ }
+ }
+
+ // damnit. Fallback to the painfully slow method.
+ return $context.find( '[data-field-name="' + name + '"]' +
+ ( ( hasindex )
+ ? ':nth(' + +index + ')'
+ : ''
+ )
+ );
+ },
+
+
+ /**
+ * Retrieve elements by the given name and optional index
+ *
+ * This allows for a simple mapping from bucket to UI.
+ *
+ * If multiple elements exist for a group of elements (e.g. radios), the
+ * first element in the group will be returned.
+ *
+ * @param {string} name element name (question name)
+ * @param {number=} index index of element to retrieve (bucket index)
+ * @param {string=} filter filter to apply to widgets
+ * @param {jQuery=} $context filtering context
+ *
+ * @return {jQuery} matches
+ */
+ 'public getElementByName': function( name, index, filter, $context )
+ {
+ var proper_index = ( index !== undefined )
+ ? this.getProperIndex( name, index, $context )
+ : undefined;
+
+ var oldflag;
+ if ( $context )
+ {
+ // let's avoid a perf hit that would arise from cloning and
+ // potential problems with jQuery from creating a prototype...
+ oldflag = $context.singleIndex;
+
+ // proper_index above takes into account the singleIndex flag, so we
+ // do not need it for the getWidgetByName() call
+ if ( +index !== +proper_index )
+ {
+ $context.singleIndex = false;
+ }
+ }
+
+ var result = this.getWidgetByName(
+ name, proper_index, filter, $context
+ );
+
+ // mutability sucks.
+ if ( $context )
+ {
+ $context.singleIndex = oldflag;
+ }
+
+ return result;
+ },
+
+
+ /**
+ * Attempt to retrieve DOM element by name, or id if not a field
+ *
+ * If NAME does not represent a known field, the element will be located
+ * using NAME as an element id; otherwise, this acts just as
+ * getElementByName.
+ *
+ * @param {string} name element name (question name)
+ * @param {number=} index index of element to retrieve (bucket index)
+ * @param {string=} filter filter to apply to widgets
+ * @param {jQuery=} $context filtering context
+ *
+ * @return {jQuery} matches
+ */
+ 'public getElementByNameLax': function(
+ name, index, filter, $context
+ )
+ {
+ $context = $context || this._$context;
+
+ if ( !( this.isAField( name ) ) )
+ {
+ return $context.find(
+ '#' + name + ':nth(' + index + ')'
+ );
+ }
+
+ return this.getElementByName(
+ name, index, filter, $context
+ );
+ },
+
+
+ /**
+ * Retrieve id of the element from the given name and index
+ *
+ * This is necessary both because we do not want id "guess" logic spread
+ * throughout the code and because the id indexes do not necessarily match
+ * the bucket indexes.
+ *
+ * @param {string} name element name (question name)
+ * @param {number} index index of element to retrieve (bucket index)
+ *
+ * @return {string} id or empty string if not found
+ */
+ 'public getElementIdFromName': function( name, index )
+ {
+ return this.getElementByName( name, index )
+ .attr( 'id' ) || '';
+ },
+
+
+ 'public getProperIndex': function ( name, index, $context )
+ {
+ var len = this.getElementLength( name, $context );
+
+ // if the context states that we have only a single index, and the
+ // element length is greater than 1, then we want to return a relative
+ // index
+ if ( $context && ( len > 1 ) && ( $context.singleIndex === true ) )
+ {
+ // the index passed to us, unadjusted, is the relative value we're
+ // looking for
+ return 0;
+ }
+
+ // otherwise, calculate the proper index for the lookup based on the
+ // element length
+ var proper_index = ( index !== undefined )
+ ? ( +index * len )
+ : undefined;
+
+ return proper_index;
+ },
+
+ /**
+ * Determines the number of elements in a group of the given element type
+ *
+ * @param {string} name element name
+ *
+ * @return {number} element length
+ */
+ 'public getElementLength': function( name, $context )
+ {
+ $context = $context || this._$context;
+
+ switch ( this._getElementType( name ) )
+ {
+ // TODO: use same method as radio
+ case 'noyes':
+ return 2;
+
+ case 'radio':
+ return this.getWidgetByName( name, 0, null, $context )
+ .attr( 'data-question-length' );
+
+ case 'legacyradio':
+ return $context.find( '[name="' + name + '[]"]' ).length;
+
+ default:
+ return 1;
+ }
+ },
+
+
+ 'public isAField': function( name )
+ {
+ // consider both elements and answers to be fields (note that displays
+ // are considered to be answers)
+ return ( ( this._getElementType( name ) !== undefined )
+ || ( this._answerRefs[ name ] !== undefined )
+ );
+ },
+
+
+ 'private _getElementId': function( name, index, $context )
+ {
+ switch ( this._getElementType( name ) )
+ {
+ case 'radio': return '';
+ case 'noyes':
+ // append yes/no depending on whether or not the given index is
+ // even/odd
+ name += ( index & 0x01 )
+ ? '_y'
+ : '_n';
+
+ index = index / 2;
+
+ /* fallthrough */
+
+ default:
+ return 'q_' + name + '_' + index;
+ }
+ },
+
+
+ 'public getAnswerElementByName': function( name, index, filter, $context )
+ {
+ var $results = ( $context || this._$context ).find(
+ '[data-answer-ref="' + name + '"]' +
+ ( ( index !== undefined )
+ ? '[data-index="' + index + '"]'
+ : ''
+ )
+ );
+
+ if ( filter )
+ {
+ return $results.filter( filter );
+ }
+
+ return $results;
+ },
+
+
+ /**
+ * Set the status of a particular field
+ *
+ * The status is simply an element appended after the element itself; the
+ * stylesheet is to determine how exactly it is displayed. The status will
+ * have a class of "sidebyside", which is taken from legacy code, in
+ * addition to "status" to uniquely identify this element.
+ *
+ * @param {string} name element name (question name)
+ * @param {number} index index of element to retrieve (bucket index)
+ * @param {string} value text to display
+ *
+ * @return {ElementStyler} self
+ */
+ 'public setStatus': function( name, index, value )
+ {
+ var $element = this.getElementByName( name, index ),
+ id = $element.attr( 'id' ) + '__status',
+ $status = $( '#' + id );
+
+ // create the element if it does not yet exist
+ if ( !( $status.length ) )
+ {
+ $status = $( '' )
+ .attr( 'id', id )
+ .addClass( 'sidebyside' )
+ .addClass( 'status' );
+
+ $element.after( $status );
+ }
+
+ // update the text
+ $status.text( value );
+
+ return this;
+ }
+});
diff --git a/src/ui/context/Context.js b/src/ui/context/Context.js
new file mode 100644
index 0000000..527a349
--- /dev/null
+++ b/src/ui/context/Context.js
@@ -0,0 +1,34 @@
+/**
+ * Field group context
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Interface = require( 'easejs' ).Interface;
+
+
+/**
+ * A subset of a larger collection of fields that can be used to restrict
+ * operations for both convenience and (moreso) performance
+ */
+module.exports = Interface( 'Context',
+{
+ 'public getFieldByName': [ 'name', 'index', 'filter' ],
+
+ 'public split': [ 'on' ]
+} );
diff --git a/src/ui/context/DomContext.js b/src/ui/context/DomContext.js
new file mode 100644
index 0000000..f43b135
--- /dev/null
+++ b/src/ui/context/DomContext.js
@@ -0,0 +1,350 @@
+/**
+ * DOM subset context
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ Context = require( './Context' ),
+
+ EventEmitter = require( 'events' ).EventEmitter;
+
+
+/**
+ * A subset of the DOM that can be used to restrict operations for both
+ * convenience and (moreso) performance
+ */
+module.exports = Class( 'DomContext' )
+ .implement( Context )
+ .extend( EventEmitter,
+{
+ /**
+ * Parent context, if any
+ * @type {DomContext}
+ */
+ 'private _pcontext': null,
+
+ /**
+ * DOM content for this particular context
+ * @type {HTMLElement}
+ */
+ 'private _content': null,
+
+ /**
+ * Parent to re-attach to
+ * @type {HtmlElement}
+ */
+ 'private _contentParent': null,
+
+ /**
+ * Factory used to produce DomFields
+ * @type {DomFieldFactory}
+ */
+ 'private _fieldFactory': null,
+
+ /**
+ * Cache of fields that have been looked up previously
+ * @type {Object}
+ */
+ 'private _fieldCache': {},
+
+ /**
+ * Continuations to be invoked once attached to the DOM
+ * @type {Array.}
+ */
+ 'private _attachq': [],
+
+ /**
+ * Continuations to be invoked once detached from the DOM
+ * @type {Array.}
+ */
+ 'private _detachq': [],
+
+
+ __construct: function( content, field_factory, pcontext, cache )
+ {
+ // older browsers do not support HTMLElement, but we still want the type
+ // check for newer ones
+ if ( window.HTMLElement && !( content instanceof HTMLElement ) )
+ {
+ throw TypeError( "Context content must be a valid HTMLElement" );
+ }
+ else if ( !( this.verifyParentContext( pcontext ) ) )
+ {
+ throw TypeError( "Invalid parent DomContext" );
+ }
+
+ this._content = content;
+ this._fieldFactory = field_factory;
+ this._pcontext = pcontext || null;
+ this._fieldCache = cache || {};
+ },
+
+
+ 'virtual protected verifyParentContext': function( context )
+ {
+ return Class.isA( module.exports, context );
+ },
+
+
+ 'public split': function( on_id, c )
+ {
+ var _self = this,
+ inst = _self.__inst;
+
+ this._getElementById( on_id, function( element )
+ {
+ // if the element could not be found, just return self
+ c( ( element )
+ ? module.exports(
+ element,
+ _self._fieldFactory,
+ inst,
+ _self._fieldCache
+ ).on( 'error', function( e )
+ {
+ // "bubble up" errors
+ _self.emit( 'error', e );
+ } )
+
+ : inst
+ );
+ } );
+
+ return this;
+ },
+
+
+ 'public getFieldByName': function( name, index, filter )
+ {
+ var result = this._fromCache( name, index );
+
+ if ( filter )
+ {
+ throw Error( "TODO: filter" );
+ }
+
+ return result;
+ },
+
+
+ 'private _getElementById': function( id, c )
+ {
+ id = ''+id;
+
+ if ( !id )
+ {
+ c( null );
+ return;
+ }
+
+ // we cannot perform the highly performant getElementById() unless we
+ // are attached to the DOM
+ this.whenAttached( function()
+ {
+ c( document.getElementById( id ) );
+ } );
+ },
+
+
+ 'private _fromCache': function( name, index, lookup )
+ {
+ var data = (
+ this._fieldCache[ name ] = this._fieldCache[ name ] || []
+ );
+
+ // if already present within the cache, simply return it
+ if ( data[ index ] )
+ {
+ return data[ index ];
+ }
+
+ // add to cache and return
+ var _self = this;
+ return data[ index ] = this._fieldFactory.create(
+ name, index,
+
+ // this is intended to defer request of the root element until this
+ // context is attached to the DOM; this ensures that the requester
+ // can take advantage of features of the attached DOM such as
+ // getElementById() and defers initial DOM operations until the
+ // element is actually available on the DOM
+ function( c )
+ {
+ // invoke the continuation as soon as we're attached to the DOM
+ _self.whenAttached( c );
+ }
+ ).on( 'error', function( e )
+ {
+ // forward errors
+ _self.emit( 'error', e );
+ } );
+ },
+
+
+ /**
+ * Determines whether this context is currently attached to the DOM
+ *
+ * @return {boolean} true if attached to the DOM, otherwise false
+ */
+ 'virtual public isAttached': function()
+ {
+ // we are attached if (a) our content node has a parent and (b) if our
+ // parent context is also attached
+ return !!this._content.parentElement && this._pcontext.isAttached();
+ },
+
+
+ /**
+ * Schedules a continunation to be invoked once the context becomes attached
+ * to the DOM
+ *
+ * If already attached, the continuation will be executed immediately
+ * (synchronously).
+ *
+ * @param {function()} c continuation to be invoked
+ *
+ * @return {DomContext} self
+ */
+ 'public whenAttached': function( c )
+ {
+ // invoke immediately if we're already attached
+ if ( this.isAttached() )
+ {
+ c();
+ return this;
+ }
+
+ // queue continuation
+ var _self = this;
+ this._attachq.push( function()
+ {
+ // ensure that we're still attached to the DOM by the time this
+ // continuation is actually invoked
+ if ( !( _self.isAttached() ) )
+ {
+ // tough luck; try again later
+ _self.whenAttached( c );
+ return;
+ }
+
+ c();
+ } );
+
+ return this;
+ },
+
+
+ 'public whenDetached': function( c )
+ {
+ // invoke immediately if we're not attached
+ if ( this.isAttached() === false )
+ {
+ c();
+ return this;
+ }
+
+ // queue the continuation
+ var _self = this;
+ this._detachc.push( function()
+ {
+ // ensure that we're still detached from the DOM
+ if ( _self.isAttached() )
+ {
+ // tough luck; try again later
+ _self.whenDetached( c );
+ return;
+ }
+
+ c();
+ } );
+
+ return this;
+ },
+
+
+ 'virtual public attach': function( to )
+ {
+ var _self = this;
+
+ // if we are already attached to the DOM, then do nothing (note that we
+ // check the parent element of our content node because something could
+ // have detached the node from the DOM without us knowing)
+ if ( this._content.parentElement )
+ {
+ return this;
+ }
+
+ // default to the stored parent if they did not provide anything
+ to = ( to || this._contentParent );
+ if ( !( Class.isA( HTMLElement, to ) ) )
+ {
+ throw TypeError( "Cannot attach context to " + to.toString() );
+ }
+
+ // re-attach ourselves to our parent and dequeue the continuations only
+ // once our parent is attached (will execute immediately if we are
+ // already attached)
+ to.appendChild( this._content );
+ this._pcontext.whenAttached( function()
+ {
+ _self._dequeue( _self._attachq );
+ } );
+
+ return this;
+ },
+
+
+ 'virtual public detach': function()
+ {
+ // do nothing if we are not attached to the DOM (note that we check the
+ // parent element of the content because something else could have
+ // re-attached our content node to the DOM without us knowing)
+ if ( !( this._content.parentElement ) )
+ {
+ return this;
+ }
+
+ // store the parent so that we know where to re-attach ourselves
+ this._contentParent = this._content.parentElement;
+
+ // detach from the DOM and dequeue the conintinuations (we don't care if
+ // our parent is detached since we're still detached regardless)
+ this._contentParent.removeChild( this._content );
+ this._dequeue( this._detachq );
+
+ return this;
+ },
+
+
+ /**
+ * @todo: rename me to unqueue; dequeue is a data structure
+ */
+ 'private _dequeue': function( q )
+ {
+ // transfer continuation queue onto the JS timeout stack
+ var c, _self = this;
+ while ( c = q.shift() )
+ {
+ // ensures that the continuations will be executed without locking
+ // up the browser; this is important, since these are DOM
+ // manipulations and therefore may be intensive!
+ setTimeout( c, 25 );
+ }
+ }
+} );
diff --git a/src/ui/context/DynamicContext.js b/src/ui/context/DynamicContext.js
new file mode 100644
index 0000000..8a517fb
--- /dev/null
+++ b/src/ui/context/DynamicContext.js
@@ -0,0 +1,64 @@
+/**
+ * Dynamic field context
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ Context = require( './Context' );
+
+
+/**
+ * Mutable Context
+ *
+ * This exists primarily to ease refactoring of old parts of the framework;
+ * it should not be preferred going forward.
+ */
+module.exports = Class( 'DynamicContext' )
+ .implement( Context )
+ .extend(
+{
+ /**
+ * Current context
+ * @type {Context}
+ */
+ 'private _context': null,
+
+
+ __construct: function( initial )
+ {
+ this.assign( initial );
+ },
+
+
+ 'public assign': function( context )
+ {
+ if ( !( Class.isA( Context, context ) ) )
+ {
+ throw TypeError( "Invalid context" );
+ }
+
+ this._context = context;
+ return this;
+ },
+
+
+ 'public proxy getFieldByName': '_context',
+
+ 'public proxy split': '_context'
+} );
diff --git a/src/ui/context/RootDomContext.js b/src/ui/context/RootDomContext.js
new file mode 100644
index 0000000..15574da
--- /dev/null
+++ b/src/ui/context/RootDomContext.js
@@ -0,0 +1,63 @@
+/**
+ * DOM context representing document root
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ DomContext = require( './DomContext' );
+
+
+/**
+ * Intended to serve as the topmost context in a context tree
+ *
+ * Since all other DomContexts besides this one must have a parent, it may
+ * be useful to create other DomContext objects by split()'ing an instance
+ * of this class.
+ *
+ * The root context cannot be detached from the DOM.
+ */
+module.exports = Class( 'RootDomContext' )
+ .extend( DomContext,
+{
+ 'override protected verifyParentContext': function( context )
+ {
+ // we have no parent... :(
+ // (this class has Mommy/Daddy issues)
+ return true;
+ },
+
+
+ 'override public isAttached': function()
+ {
+ // of course we are.
+ return true;
+ },
+
+
+ 'override public attach': function( to )
+ {
+ throw Error( "Cannot attach DOM root" );
+ },
+
+
+ 'override public detach': function( to )
+ {
+ throw Error( "Cannot detach DOM root" );
+ }
+} );
diff --git a/src/ui/field/DomField.js b/src/ui/field/DomField.js
index d252785..c9c23b4 100644
--- a/src/ui/field/DomField.js
+++ b/src/ui/field/DomField.js
@@ -37,13 +37,19 @@ module.exports = Class( 'DomField' )
'private _element': null,
+ /**
+ * Function used to query for element
+ * @type {function(function(HTMLElement))}
+ */
+ 'private _query': null,
+
'private _idPrefix': 'q_',
/**
- * Currently active styles
- * @type {Object}
+ * Cached immediate parent
+ * @type {HTMLElement}
*/
- 'private _styles': {},
+ 'private _parent': null,
__construct: function( field, element )
@@ -53,8 +59,8 @@ module.exports = Class( 'DomField' )
throw TypeError( "Invalid field provided" );
}
- this._field = field;
- this._element = element;
+ this._field = field;
+ this._query = element;
},
@@ -62,77 +68,117 @@ module.exports = Class( 'DomField' )
'public proxy getIndex': '_field',
+ /**
+ * Attempt to retrieve element associated with field
+ *
+ * CALLBACK will be invoked with the element, if found. The DOM is
+ * always queried in case the element associated with this field
+ * changes, but if the element is not found, then it is assumed to be
+ * detached and the last known element is returned.
+ *
+ * @param {function(HTMLElement)} callback element callback
+ *
+ * @return {undefined}
+ */
'private _getElement': function( callback )
{
// if the provided root is a function, then it should be lazily laoded
- if ( this._element === null )
+ if ( this._query === null )
{
// if the element is null, then we have some serious problems; do
// not even invoke the callback
return;
}
- else if ( typeof this._element === 'function' )
+
+ this.queryElement( callback );
+ },
+
+
+ /**
+ * Locate field element on the DOM, or return last known element
+ *
+ * @todo We used to cache the element in memory, period, but we have no
+ * reliable way to clear it from memory in older versions of
+ * browsers. For browsers that support DOM mutator events, we should
+ * use them.
+ *
+ * @param {function(HTMLElement)} callback element callback
+ *
+ * @return {undefined}
+ */
+ 'protected queryElement': function( callback )
+ {
+ var _self = this,
+ orig_query = this._query;
+
+ // any further requests for this element should be queued rather
+ // than resulting in a thundering herd toward the DOM (imporant: do
+ // this *before* invoking the function, since it may be synchronous)
+ var queue = [];
+ this._query = function( c )
{
- var _self = this,
- f = this._element;
+ queue.push( c );
+ };
- // any further requests for this element should be queued rather
- // than resulting in a thundering herd toward the DOM (imporant: do
- // this *before* invoking the function, since it may be synchronous)
- var queue = [];
- this._element = function( c )
+ // attempt to retrieve our element from the DOM
+ orig_query( function( element )
+ {
+ var new_element = element || _self._element;
+
+ if ( !new_element )
{
- queue.push( c );
- };
+ _self._element = null;
+ _self.emit( 'error', Error(
+ "Cannot locate DOM element for field " +
+ _self.getName() + "[" + _self.getIndex() + "]"
+ ) );
- // attempt to retrieve our element from the DOM
- f( function( element )
+ // do not even finish; this shit is for real.
+ return;
+ }
+
+ if ( new_element !== _self._element )
{
- if ( !element )
- {
- _self._element = null;
- _self.emit( 'error', Error(
- "Cannot locate DOM element for field " +
- _self.getName() + "[" + _self.getIndex() + "]"
- ) );
+ _self.updateElement( new_element );
+ }
- // do not even finish; this shit is for real.
- return;
- }
+ // restore original query
+ _self._query = orig_query;
- _self._element = element;
- callback( element );
+ callback( new_element );
- // if we have any queued requests, process them when we're not
- // busy
- var c;
- while ( c = queue.shift() )
+ // if we have any queued requests, process them when we're not
+ // busy
+ var c;
+ while ( c = queue.shift() )
+ {
+ ( function( c )
{
setTimeout( function()
{
// return the element to the queued callback
c( element );
}, 25 );
- }
- } );
-
- return;
- }
-
- // we already have the element; immediately return it
- callback( this._element );
+ } )( c );
+ }
+ } );
},
- 'private _hasStyle': function( style )
+ /**
+ * Update cached element
+ *
+ * The parent of NEW_ELEMENT is cached so that it can be reattached to
+ * the DOM after a detach.
+ *
+ * @param {HTMLElement} new_element new field element
+ *
+ * @return {undefined}
+ */
+ 'protected updateElement': function( new_element )
{
- return !!this._styles[ style.getId() ];
- },
-
-
- 'private _flagStyle': function( style, flag )
- {
- this._styles[ style.getId() ] = !!flag;
+ this._element = new_element;
+ this._parent = new_element.parentElement;
},
@@ -140,23 +186,19 @@ module.exports = Class( 'DomField' )
{
var _self = this;
- // if we already have this style applied, then ignore this request
- if ( this._hasStyle( style ) )
- {
- return this;
- }
-
// all remaining arguments should be passed to the style
var sargs = Array.prototype.slice.call( arguments, 1 );
- // flag style immediately to ensure we do not queue multiple application
- // requests
- this._flagStyle( style, true );
-
// wait for our element to become available on the DOM and perform the
// styling
this._getElement( function( root )
{
+ // if we already have this style applied, then ignore this request
+ if ( style.isApplied( _self.__inst, root ) )
+ {
+ return;
+ }
+
style.applyStyle.apply(
style,
[ _self.__inst, root, _self.getContainingRow() ].concat( sargs )
@@ -171,18 +213,14 @@ module.exports = Class( 'DomField' )
{
var _self = this;
- // if this style is not applied, then do nothing
- if ( !( this._hasStyle( style ) ) )
- {
- return this;
- }
-
- // immediately flag style to ensure that we do not queue multiple
- // revocation requests
- this._flagStyle( style, false );
-
this._getElement( function( root )
{
+ // if we already have this style applied, then ignore this request
+ if ( !style.isApplied( _self.__inst, root ) )
+ {
+ return;
+ }
+
style.revokeStyle( _self.__inst, root, _self.getContainingRow() );
} );
@@ -222,22 +260,46 @@ module.exports = Class( 'DomField' )
},
- // TODO: move me
+ // TODO: move me; too many odd exceptions; standardize
'protected getContainingRow': function()
{
- var dd = this.getParent( this._element, 'dd' ),
+ var node_name = this._element.nodeName.toUpperCase();
+
+ if ( ( node_name === 'DT' ) || ( node_name === 'DD' ) )
+ {
+ return [ this._element ];
+ }
+
+ var dd = this.getParent( 'dd' ),
dt = ( dd ) ? this.getPrecedingSibling( dd, 'dt' ) : null;
return ( dt )
? [ dd, dt ]
- : [ this.getParent( this._element ) ];
+ : [ this.getParent() ];
},
- 'protected getParent': function( element, type )
+ 'public getParent': function( type )
+ {
+ return this.getElementParent( this._element, type );
+ },
+
+
+ 'protected getElementParent': function( element, type )
{
var parent = element.parentElement;
+ if ( element === this._element )
+ {
+ parent = parent || this._parent;
+
+ // update parent reference if it's since changed
+ if ( this._parent !== parent )
+ {
+ this._parent = parent;
+ }
+ }
+
if ( parent === null )
{
return null;
@@ -247,14 +309,14 @@ module.exports = Class( 'DomField' )
return parent;
}
- // nodeName is in caps
- if ( type.toUpperCase() === parent.nodeName )
+ // nodeName might not be in caps
+ if ( type.toUpperCase() === parent.nodeName.toUpperCase() )
{
return parent;
}
// otherwise, keep looking
- return this.getParent( parent, type );
+ return this.getElementParent( parent, type );
},
diff --git a/src/ui/field/DomFieldFactory.js b/src/ui/field/DomFieldFactory.js
index 14718d5..be8b390 100644
--- a/src/ui/field/DomFieldFactory.js
+++ b/src/ui/field/DomFieldFactory.js
@@ -1,7 +1,7 @@
/**
* Creates DomField
*
- * Copyright (C) 2015 LoVullo Associates, Inc.
+ * Copyright (C) 2015, 2016 LoVullo Associates, Inc.
*
* This file is part of liza.
*
@@ -17,11 +17,6 @@
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
- *
- * @needsLove
- * - Dependencies need to be liberated:
- * - ElementStyler.
- * @end needsLove
*/
var Class = require( 'easejs' ).Class,
@@ -85,7 +80,7 @@ module.exports = Class( 'DomFieldFactory',
function c()
{
- callback( _self._elementStyler.getElementByName(
+ callback( _self._elementStyler.getElementByNameLax(
name, index, null, root
)[0] );
}
diff --git a/src/ui/group/GroupUi.js b/src/ui/group/GroupUi.js
index 986a860..d32cf9e 100644
--- a/src/ui/group/GroupUi.js
+++ b/src/ui/group/GroupUi.js
@@ -1,7 +1,7 @@
/**
* General UI logic for groups
*
- * Copyright (C) 2015 LoVullo Associates, Inc.
+ * Copyright (C) 2015, 2016 LoVullo Associates, Inc.
*
* This file is part of liza.
*
@@ -144,23 +144,45 @@ module.exports = Class( 'GroupUi' )
*/
'private _rawFieldCount': 0,
+ /**
+ * DOM group context
+ * @type {DomContext}
+ */
+ 'protected context': null,
+
+ /**
+ * Styler when fields are no longer applicable
+ * @type {FieldStyler}
+ */
+ 'private _naStyler': null,
+
/**
* Initializes GroupUi
*
- * @param Group group group to style
- * @param jQuery $content the group content
- * @param ElementStyler styler styler to use to style elements
- * @param jQuery jquery jQuery-compatible object
+ * @todo three of the below parameters might be able to be removed by
+ * using context instead; the separate context is transitional
+ * (refactoring).
+ *
+ * @param {Group} group group to style
+ * @param {jQuery} $content the group content
+ * @param {ElementStyler} styler styler to use to style elements
+ * @param {jQuery} jquery jQuery-compatible object
+ * @param {DomContext} context group context
+ * @param {FieldStyler} na_styler styler for fields that are N/A
*
* @return {undefined}
*/
- 'public __construct': function( group, $content, styler, jquery )
+ 'public __construct': function(
+ group, $content, styler, jquery, context, na_styler
+ )
{
- this.group = group;
- this.$content = $content;
- this.styler = styler;
- this._jquery = jquery;
+ this.group = group;
+ this.$content = $content;
+ this.styler = styler;
+ this._jquery = jquery;
+ this.context = context;
+ this._naStyler = na_styler;
},
@@ -787,14 +809,8 @@ module.exports = Class( 'GroupUi' )
'virtual protected doHideField': function( field, index )
{
- var $elements = this.getFieldElements( field, index );
-
- $elements.stop( true, true ).slideUp( 500, function()
- {
- // be sure to remove the display:none added by jQuery so that we can
- // perform our own handling of what it means to be "hidden"
- $elements.addClass( 'hidden' ).attr( 'style', '' );
- } );
+ this.context.getFieldByName( field, index )
+ .applyStyle( this._naStyler );
},
@@ -815,12 +831,8 @@ module.exports = Class( 'GroupUi' )
'virtual protected doShowField': function( field, index )
{
- var $elements = this.getFieldElements( field, index );
-
- $elements.find( '.hidden' ).andSelf()
- .stop( true, true )
- .removeClass( 'hidden' )
- .slideDown( 500 );
+ this.context.getFieldByName( field, index )
+ .revokeStyle( this._naStyler );
},
diff --git a/src/ui/group/TabbedGroupUi.js b/src/ui/group/TabbedGroupUi.js
index 4e80a96..d185799 100644
--- a/src/ui/group/TabbedGroupUi.js
+++ b/src/ui/group/TabbedGroupUi.js
@@ -1,7 +1,7 @@
/**
* Group tabbed UI
*
- * Copyright (C) 2015 LoVullo Associates, Inc.
+ * Copyright (C) 2015, 2016 LoVullo Associates, Inc.
*
* This file is part of liza.
*
@@ -373,23 +373,11 @@ module.exports = Class( 'TabbedGroupUi' )
{
_self.doHideField( field, index, true );
}, 25 );
+
+ return;
}
- var $elements = this.getFieldElements( field, index );
-
- $elements.stop( true, true );
-
- if ( this.isOnVisibleTab( field, index ) )
- {
- $elements.slideUp( 500, function()
- {
- $( this ).addClass( 'hidden' );
- } );
- }
- else
- {
- $elements.hide().addClass( 'hidden' );
- }
+ this.__super( field, index );
},
@@ -405,26 +393,11 @@ module.exports = Class( 'TabbedGroupUi' )
{
_self.doShowField( field, index, true );
}, 25 );
+
+ return;
}
- var $elements = this.getFieldElements( field, index );
-
- // it's important to stop animations *before* removing the hidden class,
- // since forcing its completion may add it
- $elements
- .stop( true, true )
- .find( '.hidden' )
- .andSelf()
- .removeClass( 'hidden' );
-
- if ( this.isOnVisibleTab( field, index ) )
- {
- $elements.slideDown( 500 );
- }
- else
- {
- $elements.show();
- }
+ this.__super( field, index );
},
diff --git a/src/ui/step/GeneralStepUi.js b/src/ui/step/GeneralStepUi.js
index 5ce6280..bb9eb76 100644
--- a/src/ui/step/GeneralStepUi.js
+++ b/src/ui/step/GeneralStepUi.js
@@ -22,7 +22,6 @@
* - References to "quote" should be replaced with generic terminology
* representing a document.
* - Dependencies need to be liberated:
- * - ElementStyler;
* - BucketDataValidator.
* - Global references (e.g. jQuery) must be removed.
* - jQuery must be eliminated.
diff --git a/src/ui/step/StepUiBuilder.js b/src/ui/step/StepUiBuilder.js
index 4d7770f..a107d28 100644
--- a/src/ui/step/StepUiBuilder.js
+++ b/src/ui/step/StepUiBuilder.js
@@ -21,7 +21,6 @@
* @needsLove
* - Global references to jQuery must be removed.
* - Dependencies need to be liberated:
- * - ElementStyler;
* - UI.
* - This may not be needed, may be able to be handled differently, and
* really should load from data rather than a pre-generated template (?)
diff --git a/src/ui/styler/ErrorFieldStyler.js b/src/ui/styler/ErrorFieldStyler.js
new file mode 100644
index 0000000..9f44016
--- /dev/null
+++ b/src/ui/styler/ErrorFieldStyler.js
@@ -0,0 +1,150 @@
+/**
+ * Error condition field styler
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ FieldStyler = require( './FieldStyler' );
+
+
+/**
+ * Style field to indicate an error and displays an error message
+ */
+module.exports = Class( 'ErrorFieldStyler' )
+ .extend( FieldStyler,
+{
+ 'public getId': function()
+ {
+ return 'error';
+ },
+
+
+ /**
+ * Determines whether the field has been styled
+ *
+ * Having this predicate on the styler rather than the field ensures
+ * that, even if the two somehow get out of sync (or styles are applied
+ * elsewhere), application/revocation will function sanely.
+ *
+ * @param {DomField} field field to style
+ * @param {HTMLElement} element DOM element to style
+ *
+ * @return {boolean} whether FIELD has been styled by this styler
+ */
+ 'public isApplied': function( field, element )
+ {
+ return /\binvalid_field\b/.test( element.className );
+ },
+
+
+ 'public applyStyle': function( field, element, row, msg )
+ {
+ var _self = this;
+
+ // style the row containing the element
+ for ( var i in row )
+ {
+ this.addClass( row[ i ], 'invalid' );
+ }
+
+ // TODO: legacy; remove
+ this.addClass( element, 'invalid_field' );
+
+ // display the error message
+ this._createMessage( field.getName(), msg, row[ 0 ], row[ 1 ] );
+
+ return this;
+ },
+
+
+ 'public revokeStyle': function( field, element, row )
+ {
+ var _self = this;
+
+ // un-style the row containing the element
+ // style the row containing the element
+ for ( var i in row )
+ {
+ this.removeClass( row[ i ], 'invalid' );
+ }
+
+ // TODO: legacy; remove
+ this.removeClass( element, 'invalid_field' );
+
+ this._destroyMessage( row[ 0 ], row[ 1 ] );
+
+ return this;
+ },
+
+
+ 'private _createMessage': function( name, message, dd, dt )
+ {
+ // we can only generate the message if the parent row is available
+ if ( !( dd && dt ) )
+ {
+ return;
+ }
+
+ var msg = document.createElement( 'div' );
+ msg.className = 'errmsg';
+ msg.innerHTML = message;
+
+ // append to dd
+ dd.appendChild( msg );
+
+ var height = ( msg.offsetTop + msg.offsetHeight );
+
+ // element does not have height until added to DOM
+ // set a default to ensure it appears to user
+ height = ( height === 0 )
+ ? 45 + 'px'
+ : ( height + 10 ) + 'px';
+
+ dd.style.height = height;
+ dt.style.height = height;
+ },
+
+
+ 'private _destroyMessage': function( dd, dt )
+ {
+ if ( !dd )
+ {
+ return;
+ }
+
+ dd.style.height = '';
+
+ // note that dt may not actually exist (in fact, dd may not even be a
+ // dd; we should rename these variables)
+ dt && ( dt.style.height = '' );
+
+ var node;
+
+ // search for the message node, starting with the last element (since
+ // the error message was appended, we're likely to find it on our first
+ // try)
+ for ( node = dd.lastChild;
+ node && node.className !== 'errmsg';
+ node = node.previousSibling
+ );
+
+ // if we found it, then remove it
+ node && dd.removeChild( node );
+ }
+} );
diff --git a/src/ui/styler/ErrorStyler.js b/src/ui/styler/ErrorStyler.js
new file mode 100644
index 0000000..714aaf6
--- /dev/null
+++ b/src/ui/styler/ErrorStyler.js
@@ -0,0 +1,108 @@
+/**
+ * Error condition field styler
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ Styler = require( './Styler' );
+
+
+/**
+ * Handle error generation and defer styling to supertype
+ */
+module.exports = Class( 'ErrorStyler' )
+ .implement( Styler )
+ .extend(
+{
+ /**
+ * Hash of error messages by field name
+ * @type {Object}
+ */
+ 'private _msgs': {},
+
+
+ /**
+ * Initialize error styler with a hash of error messages by field name
+ *
+ * @param {Object} msgs hash of error messages by field name
+ */
+ 'virtual __construct': function( msgs )
+ {
+ this._msgs = msgs;
+ },
+
+
+ 'public getHooks': function( uistyler )
+ {
+ var _self = this;
+
+ return {
+ fieldError: function( context, failures, msgs )
+ {
+ msgs = msgs || {};
+
+ for ( var name in failures )
+ {
+ var msgset = ( msgs[ name ] || [] );
+
+ for ( var index in failures[ name ] )
+ {
+ // if no error message was provided, fall back to one of
+ // the defaults
+ var msg = (
+ msgset[ index ]
+ || _self._msgs[ name ]
+ || "Field is invalid"
+ );
+
+ _self.onFieldError(
+ context.getFieldByName( name, index ),
+ msg
+ );
+ }
+ }
+ },
+
+ fieldFixed: function( context, fixed )
+ {
+ for ( var name in fixed )
+ {
+ for ( var index in fixed[ name ] )
+ {
+ _self.onFieldFixed(
+ context.getFieldByName( name, index )
+ );
+ }
+ }
+ }
+ };
+ },
+
+
+ 'virtual protected onFieldError': function( field, msg )
+ {
+ // do nothing by default
+ },
+
+
+ 'virtual protected onFieldFixed': function( field )
+ {
+ // do nothing by default
+ }
+} );
diff --git a/src/ui/styler/FieldStyler.js b/src/ui/styler/FieldStyler.js
new file mode 100644
index 0000000..3d62cd9
--- /dev/null
+++ b/src/ui/styler/FieldStyler.js
@@ -0,0 +1,152 @@
+/**
+ * Style fields using CSS
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var AbstractClass = require( 'easejs' ).AbstractClass;
+
+
+/**
+ * Style DOM fields
+ *
+ * @todo perhaps this should be called DomFieldStyler
+ */
+module.exports = AbstractClass( 'FieldStyler',
+{
+ /**
+ * Retrieve unique identifier
+ *
+ * @return {string} unique identifier
+ */
+ 'abstract public getId': [],
+
+ /**
+ * Determines whether the field has been styled
+ *
+ * Having this predicate on the styler rather than the field ensures
+ * that, even if the two somehow get out of sync (or styles are applied
+ * elsewhere), application/revocation will function sanely.
+ *
+ * @param {DomField} field field to style
+ * @param {HTMLElement} element DOM element to style
+ *
+ * @return {boolean} whether FIELD has been styled by this styler
+ */
+ 'abstract public isApplied': [ 'field', 'element' ],
+
+ /**
+ * Apply style to field
+ *
+ * @param {DomField} field field to style
+ * @param {HTMLElement} element DOM element to style
+ * @param {Array.} row DOM elements of containing row
+ *
+ * @return {FieldStyler} self
+ */
+ 'abstract public applyStyle': [ 'field', 'element', 'row' ],
+
+ /**
+ * Remove style from field
+ *
+ * @param {DomField} field field to unstyle
+ * @param {HTMLElement} element DOM element to unstyle
+ * @param {Array.} row DOM elements of containing row
+ *
+ * @return {FieldStyler} self
+ */
+ 'abstract public revokeStyle': [ 'field', 'element', 'row' ],
+
+
+ /**
+ * Add CSS class CLS to element ELEMENT
+ *
+ * This method is needed until support is dropped for browsers that do
+ * not support classList.
+ *
+ * @param {HTMLElement} element DOM element to style
+ * @param {string} cls class name
+ *
+ * @return {FieldStyler} self
+ */
+ 'protected addClass': function( element, cls )
+ {
+ if ( !element )
+ {
+ return this;
+ }
+
+ // if we are given an array, then recurse
+ if ( Array.isArray( element ) )
+ {
+ for ( var i in element )
+ {
+ this.addClass( element[ i ], cls );
+ }
+
+ return;
+ }
+ else if ( typeof element.className === 'string' )
+ {
+ element.className += ' ' + cls;
+ }
+
+ return this;
+ },
+
+
+ /**
+ * Add CSS class CLS to element ELEMENT
+ *
+ * This method is needed until support is dropped for browsers that do
+ * not support classList.
+ *
+ * @param {HTMLElement} element DOM element to style
+ * @param {string} cls class name
+ *
+ * @return {FieldStyler} self
+ */
+ 'protected removeClass': function( element, cls )
+ {
+ if ( !element )
+ {
+ return this;
+ }
+
+ // if we are given an array, then recurse
+ if ( Array.isArray( element ) )
+ {
+ for ( var i in element )
+ {
+ this.removeClass( element[ i ], cls );
+ }
+
+ return;
+ }
+ else if ( typeof element.className === 'string' )
+ {
+ // note that we use a space instead of a boundary for the character
+ // preceding the match due to the implementation of addClass()
+ element.className = element.className.replace(
+ new RegExp( ( ' ' + cls + '\\b' ), 'g' ), ''
+ );
+ }
+
+ return this;
+ }
+} );
diff --git a/src/ui/styler/NaFieldStyler.js b/src/ui/styler/NaFieldStyler.js
new file mode 100644
index 0000000..8eba322
--- /dev/null
+++ b/src/ui/styler/NaFieldStyler.js
@@ -0,0 +1,158 @@
+/**
+ * N/A field styler
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ FieldStyler = require( './FieldStyler' );
+
+
+/**
+ * Style fields that are not applicable (and so do not need to collect data
+ * from the user)
+ *
+ * @todo Detaching should be done by DomField
+ */
+module.exports = Class( 'NaFieldStyler' )
+ .extend( FieldStyler,
+{
+ /**
+ * Retrieve unique identifier
+ *
+ * @return {string} unique identifier
+ */
+ 'public getId': function()
+ {
+ return 'na';
+ },
+
+
+ /**
+ * Determines whether the field has been styled
+ *
+ * Having this predicate on the styler rather than the field ensures
+ * that, even if the two somehow get out of sync (or styles are applied
+ * elsewhere), application/revocation will function sanely.
+ *
+ * @param {DomField} field field to style
+ * @param {HTMLElement} element DOM element to style
+ *
+ * @return {boolean} whether FIELD has been styled by this styler
+ */
+ 'public isApplied': function( field, element )
+ {
+ return /\bhidden\b/.test( element.className );
+ },
+
+
+ /**
+ * Apply style to field
+ *
+ * @param {DomField} field field to style
+ * @param {HTMLElement} element DOM element to style
+ * @param {Array.} row DOM elements of containing row
+ *
+ * @return {FieldStyler} self
+ */
+ 'public applyStyle': function( field, element, row )
+ {
+
+ if ( this.isSubField( field ) )
+ {
+ this.hideField( element, [] );
+ field.getParent().removeChild( element );
+
+ // this is a child of another field; don't consider it a
+ // containing row, since we don't want our operations affecting
+ // it
+ return;
+ }
+
+ this.hideField( element, row );
+ },
+
+
+ /**
+ * Remove style from field
+ *
+ * @param {DomField} field field to unstyle
+ * @param {HTMLElement} element DOM element to unstyle
+ * @param {Array.} row DOM elements of containing row
+ *
+ * @return {FieldStyler} self
+ */
+ 'public revokeStyle': function( field, element, row )
+ {
+ if ( this.isSubField( field ) )
+ {
+ this.showField( element, [] );
+ field.getParent().appendChild( element );
+
+ return;
+ }
+
+ this.showField( element, row );
+ },
+
+
+ /**
+ * Determine whether element ELEMENT represents a sub-field
+ *
+ * A sub-field is a field within a field; the distinction is important
+ * because we probably don't want operations on a sub-field affecting
+ * its parent.
+ *
+ * @todo: move somewhere else (Field perhaps?)
+ *
+ * @param {HTMLElement} element DOM element associated with field
+ *
+ * @return {boolean} whether ELEMENT represents a sub-field
+ */
+ 'protected isSubField': function( field )
+ {
+ var parent = field.getParent();
+
+ // ES3-compatible (don't use classList)
+ return !!( parent && /\bwidget\b/.test( parent.className ) );
+ },
+
+
+ 'virtual protected hideField': function( element, row )
+ {
+ this.addClass( element, 'hidden' );
+
+ // this is a workaround from the old days where jQuery would add
+ // styles to hide elements, which we wanted to override; this can be
+ // removed once jQuery is eradicated from the framework
+ element.style = '';
+
+ for ( var i in row )
+ {
+ this.addClass( row[ i ], 'hidden' );
+ row[ i ].style = '';
+ }
+ },
+
+
+ 'virtual protected showField': function( element, row )
+ {
+ this.removeClass( element, 'hidden' );
+ this.removeClass( row, 'hidden' );
+ }
+} );
diff --git a/src/ui/styler/NaFieldStylerAnimation.js b/src/ui/styler/NaFieldStylerAnimation.js
new file mode 100644
index 0000000..75d1eb8
--- /dev/null
+++ b/src/ui/styler/NaFieldStylerAnimation.js
@@ -0,0 +1,97 @@
+/**
+ * Animated N/A field styler
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Trait = require( 'easejs' ).Trait,
+ NaFieldStyler = require( './NaFieldStyler' );
+
+
+/**
+ * Sliding animations for field show/hide
+ *
+ * @todo Use CSS3 once we can drop support for IE<10
+ */
+module.exports = Trait.extend( NaFieldStyler,
+{
+ /**
+ * jQuery instance
+ * @type {jQuery}
+ */
+ 'private _jquery': null,
+
+
+ /**
+ * Prepare mixin with jQuery instance
+ *
+ * @param {jQuery} jquery jQuery instance
+ */
+ __mixin: function( jquery )
+ {
+ this._jquery = jquery;
+ },
+
+
+ /**
+ * Animate field display
+ *
+ * When a field becomes applicable, progressively increase its height
+ * ("slide down").
+ *
+ * @param {HTMLElement} element field DOM element
+ * @param {Array. .
+ */
+
+var Class = require( 'easejs' ).Class,
+ ErrorStyler = require( './ErrorStyler' );
+
+
+/**
+ * Displays errors in the sidebar
+ *
+ * TODO: This is an adapter around the old system; it could use some
+ * refactoring.
+ */
+module.exports = Class( 'SidebarErrorStyler' )
+ .extend( ErrorStyler,
+{
+ /**
+ * Error box in which to display errors
+ * @type {FormErrorBox}
+ */
+ 'private _errbox': null,
+
+ /**
+ * Ui instance
+ * @type {Ui}
+ */
+ 'private _ui': null,
+
+
+ 'override __construct': function( msgs, error_box, ui )
+ {
+ this._errbox = error_box;
+ this._ui = ui;
+ this.__super( msgs );
+ },
+
+
+ 'override protected onFieldError': function( field, msg )
+ {
+ this._errbox.show( field.getName(), field.getIndex(), msg );
+ },
+
+
+ 'override protected onFieldFixed': function( field )
+ {
+ this._errbox.removeError( field.getName(), field.getIndex() );
+ }
+} );
diff --git a/src/ui/styler/StepErrorStyler.js b/src/ui/styler/StepErrorStyler.js
new file mode 100644
index 0000000..83b344c
--- /dev/null
+++ b/src/ui/styler/StepErrorStyler.js
@@ -0,0 +1,52 @@
+/**
+ * Styles errors on steps
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Class = require( 'easejs' ).Class,
+ ErrorStyler = require( './ErrorStyler' );
+
+
+/**
+ * Trigger field styling for errors on the parent step itself
+ */
+module.exports = Class( 'StepErrorStyler' )
+ .extend( ErrorStyler,
+{
+ 'private _style': null,
+
+
+ 'override __construct': function( msgs, field_style )
+ {
+ this._style = field_style;
+ this.__super( msgs );
+ },
+
+
+ 'override protected onFieldError': function( field, msg )
+ {
+ field.applyStyle( this._style, msg );
+ },
+
+
+ 'override protected onFieldFixed': function( field )
+ {
+ field.revokeStyle( this._style );
+ }
+} );
diff --git a/src/ui/styler/Styler.js b/src/ui/styler/Styler.js
new file mode 100644
index 0000000..bfb144e
--- /dev/null
+++ b/src/ui/styler/Styler.js
@@ -0,0 +1,30 @@
+/**
+ * Styler interface
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of liza.
+ *
+ * liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var Interface = require( 'easejs' ).Interface;
+
+
+/**
+ * @todo Can be used for type hinting, but we need an actual API!
+ */
+module.exports = Interface( 'Styler',
+{
+} );
diff --git a/test/ui/styler/NaFieldStylerTest.js b/test/ui/styler/NaFieldStylerTest.js
new file mode 100644
index 0000000..ae046aa
--- /dev/null
+++ b/test/ui/styler/NaFieldStylerTest.js
@@ -0,0 +1,322 @@
+/**
+ * Test case for NaFieldStyler
+ *
+ * Copyright (C) 2016 LoVullo Associates, Inc.
+ *
+ * This file is part of the Liza Data Collection Framework
+ *
+ * Liza is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+var styler = require( '../../../' ).ui.styler,
+ expect = require( 'chai' ).expect,
+ Class = require( 'easejs' ).Class,
+ Sut = styler.NaFieldStyler;
+
+
+describe( 'ui.styler.NaFieldStyler', function()
+{
+ function testApplyHidden()
+ {
+ var element = { className: '' },
+ r1 = { className: '' },
+ r2 = { className: '' },
+ row = [ r1, r2 ];
+
+ Sut().applyStyle( getStubField( element ), element, row );
+
+ [ element, r1, r2 ].forEach( function( ele )
+ {
+ expect( ele.className ).to.match( /\bhidden\b/ );
+ } );
+ }
+
+
+ function testApplyClear()
+ {
+ var element = { style: 'foo' },
+ r1 = { style: 'foo' },
+ r2 = { style: 'foo' },
+ row = [ r1, r2 ];
+
+ Sut().applyStyle( getStubField( element ), element, row );
+
+ [ element, r1, r2 ].forEach( function( ele )
+ {
+ expect( ele.style ).to.equal( '' );
+ } );
+ }
+
+
+ function testRevokeHidden()
+ {
+ var element = { className: 'foo hidden' },
+ r1 = { className: 'foo hidden' },
+ r2 = { className: 'foo hidden' },
+ row = [ r1, r2 ];
+
+ Sut().revokeStyle( getStubField( element ), element, row );
+
+ [ element, r1, r2 ].forEach( function( ele )
+ {
+ expect( ele.className ).to.not.match( /\bhidden\b/ );
+ expect( ele.className ).to.match( /foo/ );
+ } );
+ }
+
+
+ function testRevokeStyle()
+ {
+ var element = { style: 'foo' },
+ r1 = { style: 'foo' },
+ r2 = { style: 'foo' },
+ row = [ r1, r2 ];
+
+ Sut().revokeStyle( getStubField( element ), element, row );
+
+ [ element, r1, r2 ].forEach( function( ele )
+ {
+ expect( ele.style ).to.equal( 'foo' );
+ } );
+ }
+
+
+ describe( '#getId', function()
+ {
+ it( 'returns unique identifier', function()
+ {
+ expect( Sut().getId() ).to.equal( 'na' );
+ } );
+ } );
+
+
+ describe( '#applyStyle', function()
+ {
+ it( 'sets hidden class on all elements', testApplyHidden );
+ it( 'clears style on all elements', testApplyClear );
+
+
+ it( 'does not set class on subfield parents', function()
+ {
+ var element = {
+ className: '',
+ parentElement: {
+ className: 'widget',
+ removeChild: function() {},
+ }
+ };
+
+ var r1 = { className: '' },
+ r2 = { className: '' },
+ row = [ r1, r2 ];
+
+ Sut().applyStyle( getStubField( element ), element, row );
+
+ expect( element.className ).to.match( /\bhidden\b/ );
+
+ [ r1, r2 ].forEach( function( ele )
+ {
+ expect( ele.className ).to.equal( '' );
+ } );
+ } );
+
+
+ it( 'does not clears style subfield parents', function()
+ {
+ var element = {
+ style: 'foo',
+ parentElement: {
+ className: 'widget',
+ removeChild: function() {},
+ }
+ };
+
+ var r1 = { style: 'foo' },
+ r2 = { style: 'foo' },
+ row = [ r1, r2 ];
+
+ Sut().applyStyle( getStubField( element ), element, row );
+
+ expect( element.style ).to.equal( '' );
+
+ [ r1, r2 ].forEach( function( ele )
+ {
+ expect( ele.style ).to.equal( 'foo' );
+ } );
+ } );
+
+
+ // f@#(& IE
+ it( 'removes subfield from DOM', function( done )
+ {
+ var element = {
+ style: '',
+ parentElement: {
+ className: 'widget',
+ removeChild: function( ele )
+ {
+ expect( ele ).to.equal( element );
+ done();
+ },
+ }
+ };
+
+ Sut().applyStyle( getStubField( element ), element, [] );
+ } );
+ } );
+
+
+ describe( '#revokeStyle', function()
+ {
+ it( 'removes hidden class on all elements', testRevokeHidden );
+ it( 'does not clear style on all elements', testRevokeStyle );
+
+
+ it( 'does not remove hidden class on subfield parents', function()
+ {
+ var element = {
+ className: 'foo hidden',
+ parentElement: {
+ className: 'widget',
+ appendChild: function() {},
+ }
+ };
+
+ var r1 = { className: 'foo hidden' },
+ r2 = { className: 'foo hidden' },
+ row = [ r1, r2 ];
+
+ Sut().revokeStyle( getStubField( element ), element, row );
+
+ expect( element.className ).to.not.match( /\bhidden\b/ );
+ expect( element.className ).to.match( /foo/ );
+
+ [ r1, r2 ].forEach( function( ele )
+ {
+ expect( ele.className ).to.equal( 'foo hidden' );
+ } );
+ } );
+
+
+ // we eventually need to care about where it's re-attached
+ it( 're-attaches subfield to DOM', function( done )
+ {
+ var element = {
+ className: '',
+ parentElement: {
+ className: 'widget',
+ appendChild: function( ele )
+ {
+ expect( ele ).to.equal( element );
+ done();
+ },
+ }
+ };
+
+ Sut().revokeStyle( getStubField( element ), element, [] );
+ } );
+ } );
+
+
+ describe( '#isApplied', function()
+ {
+ it( 'recognizes when applied', function()
+ {
+ var element = {
+ className: '',
+ };
+
+ var sut = Sut(),
+ field = getStubField( element );
+
+ sut.applyStyle( field, element, [] );
+
+ expect( sut.isApplied( field, element ) )
+ .to.be.true;
+
+ sut.revokeStyle( field, element, [] );
+
+ expect( sut.isApplied( field, element ) )
+ .to.be.false;
+ } );
+ } );
+
+
+ describe( 'protected API', function()
+ {
+ describe( '#isSubField', function()
+ {
+ it( 'recognizes parent widget class as subfield', function()
+ {
+ var element = {
+ className: '',
+ parentElement: {
+ className: 'widget',
+ removeChild: function() {},
+ }
+ };
+
+ expect( protSut().protIsSubField( getStubField( element ) ) )
+ .to.be.true;
+ } );
+
+
+ it( 'missing parent widget class is non-subfield', function()
+ {
+ var element = {
+ className: '',
+ };
+
+ expect( protSut().protIsSubField( getStubField( element ) ) )
+ .to.be.false;
+ } );
+ } );
+
+
+ describe( '#hideField', function()
+ {
+ it( 'sets hidden class on all elements', testApplyHidden );
+ it( 'clears style on all elements', testApplyClear );
+ } );
+
+
+ describe( '#showField', function()
+ {
+ it( 'removes hidden class on all elements', testRevokeHidden );
+ it( 'does not clear style on all elements', testRevokeStyle );
+ } );
+ } );
+} );
+
+
+function getStubField( element )
+{
+ return {
+ getParent: function()
+ {
+ return element.parentElement;
+ }
+ };
+}
+
+
+function protSut()
+{
+ return Class.extend( Sut, {
+ protIsSubField: function( element )
+ {
+ return this.isSubField( element );
+ }
+ } )();
+}