Portal Data Library

Below you can find the code for the library I wrote for the RUSH Portal frontend, as referenced here.

Feel free to try it out, but please note it is under copyright of RUSH Network, so contact them if you want to use it for a project.

/**
 * Portal Data Library
 * Copyright (c) 2019 RUSH Network. All rights reserved.
 * 
 * The Portal Data object is used for two-way data binding and dynamic display of DOM elements, similar to how Vue.js
 * works. However, it's very limited and doesn't create a virtual DOM. All display changes are handled by
 * adding/removing CSS classes via jQuery.
 * 
 * This library comprises of two global constants and various supporting classes.
 * Constants:
 * 1. const portalData Proxy    A global object that uses a Javascript Proxy to "trap" when its properties are changed
 *                              and its methods are called.
 * 2. const portalLoop          A global object that is used to buffer and process changes to portalData. It processes
 *                              the changes in chunks using a queue. It can also be used to queue other things, but
 *                              let's keep things simple for now...
 * 
 * Supporting Classes:
 * 1. PortalLoopClass           portalLoop is an instance of this class.
 * 2. PortalQueue               portalLoop.queue is an instance of this class.
 * 
 * FAQ:
 * Q:   Why did I use ES6 classes here?
 * A:   Because they solve the huge bloody headache of "this" referring to the Window object. In the class' constructor,
 *      we bind "this" to each of its methods, which makes it available inside the method, like you'd assume a decent
 *      programming language would do.
 * 
 *      @see https://ponyfoo.com/articles/binding-methods-to-class-instance-objects
 *         
 *      We could have used arrow functions for these methods, but this article made me decide otherwise:
 *      @see https://medium.com/@charpeni/arrow-functions-in-class-properties-might-not-be-as-great-as-we-think-3b3551c440b1
 * 
 * Q:   WTF is window.requestAnimationFrame?
 * A:   It's an accurate way of running things in a loop, as opposed to setInterval or setTimeout, which are incredibly
 *      inaccurate and can seriously impact performance. It's used in JS game engines - who can argue with that?
 * 
 *      @see https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
 */

/**
 * Processor object for portalData Proxy. This is the object that does the heavy lifting when a property of portalData
 * changes value. It searches the DOM for the property in data attributes and updates the DOM accordingly.
 */
const portalDataProcessor = {
    /**
     * Keeps track of properties that are added/removed from object and need to be watched.
     */
    trackedProperties: [],
    
    /**
     * List of property names and their respective keys in sessionStorage. Used with portalData.sessionBind() method.
     */
    trackedSessionKeys: {},
    
    /**
     * Properties of the portalData object that shouldn't be overwritten.
     */
    protectedProperties: [
        'sessionBind', // portalData.sessionBind() is a shorthand for the equivalent method of this processor.
        'cast',
        'uncast',
        'bindModel'
   ],
    
    /**
     * List of property names and their respective data types for casting
     */
    casts: {},
    
    /**
     * Gets incremented every time set() is called.
     */
    setIndex: 0,
    
    /**
     * Increments setIndex and returns the new value.
     * @return  integer
     */
    newSetIndex: function() {
        // Don't let the index get larger than 1 million.
        if (this.setIndex >= 1000 * 1000) {
            this.setIndex = 0;
        }
        
        return (++this.setIndex);
    },
    
    /**
     * Add a property name to the tracked array if it doesn't exist already.
     * @param   string prop
     * @return  void
     */
    trackProperty: function(prop) {
        if (this.trackedProperties.indexOf(prop) === -1) {
            this.trackedProperties.push(prop);
        }
    },
    
    /**
     * Remove a property from the tracked array.
     * @param   string prop
     * @return  void
     */
    untrackProperty: function(prop) {
        let me = this;
        let filtered = this.trackedProperties.filter(function(key) {
            return key !== prop;
        });
        
        me.trackedProperties = filtered;
    },
    
    /**
     * Allow a property to be cast to a specific type, or via a custom function
     * @param   string prop         The property to cast
     * @param   string|function     Either one of the types in the Switch, or a callback function
     * @return  void
     */
    castProperty: function(prop, cast) {
        if (typeof cast === 'function') {
            this.casts[prop] = cast;
        } else {
            switch (cast) {
                case 'number':
                case 'Number':
                    this.casts[prop] = 'Number';
                break;
                case 'string':
                case 'String':
                    this.casts[prop] = 'String';
                break;
                case 'bool':
                case 'boolean':
                case 'Boolean':
                    this.casts[prop] = 'Boolean';
                break;
                default:
                    // Remove the cast completely
                    delete this.casts[prop];
                break;
            }
        }
        
        
        if (typeof this.casts[prop] !== 'undefined' && typeof portalDataTarget[prop] !== 'undefined') {
            val = portalDataTarget[prop];
            portalDataTarget[prop] = this.applyCast(prop, val); // Apply to the variable
        }
    },
    
    /**
     * Remove a cast
     * @param   string prop
     * @return  void
     */
    uncastProperty: function(prop) {
        if (typeof this.casts[prop] !== 'undefined') {
            delete this.casts[prop];
        }
    },
    
    /**
     * Get the cast for a property if it exists
     * @param   string prop
     * @return  String|null
     */
    getCast: function(prop) {
        return (typeof this.casts[prop] === 'undefined') ? null : this.casts[prop];
    },
    
    /**
     * Apply a cast to a value
     * @param   string prop
     * @return  String|null
     */
    applyCast: function(prop, val) {
        let cast = this.getCast(prop);
        
        if (! cast) {
            return val;
        }
        
        if (val === null && typeof cast !== 'function') {
            return null; // Always allow NULL values, unless we're using a custom cast function
        }
        
        // Cast the value
        try {
            if (typeof cast === 'function') {
                val = cast(val, prop);
            } else {
                switch (cast) {
                    case 'String':
                        val = String(val);
                    break;
                    case 'Number':
                        val = Number(val);
                    break;
                    case 'Boolean':
                        val = Boolean(val);
                    break;
                    default:
                        // Nothing to do
                    break;
                }
            }
        } catch (e) {
            // 
        }
        
        return val;
    },
    
    /**
     * Run trough a list of properties, updating each value in the portalData object while updating the DOM.
     *
     * @param   Array  props            Porperty names.
     * @param   comparison              (Optional) "and" or "or"
     *
     * @return  boolean                 True if successful, false on failure.
     */
    processProperties: function(target, props, comparison = 'and') {
        let me = portalDataProcessor;
        let answer = null;
        let currentProp = null;
        let val = false;
        let invert = false;
        
        if (! props || ! props.length) {
            return false;
        }
        
        for (let i = 0; i < props.length; i++) {
            currentProp = props[i];
            invert = currentProp.indexOf('!') === 0;
            
            if (invert) {
                currentProp = currentProp.replace(/^\!/, ''); // Remove exclamation mark prefix.
            }
            
            // Fetch the value.
            val = target[currentProp];
            
            if (typeof val === 'undefined') {
                val = false;
            }
            
            // Invert the value.
            if (invert) {
                val = ! val;
            }
            
            if (i === 0) {
                answer = val;
            }
            
            // We don't need to compare values if there's only one property.
            if (props.length === 1) {
                return answer;
            }
            
            if (comparison === 'and') {
                // AND comparison.
                answer = answer && val;
                
                if (! answer) {
                    break;
                }
            } else {
                // OR comparison.
                answer = answer || val;
            }
        }
        
        return answer;
    },
    
    /**
     * Process a data string and compare it to the current value of a property.
     * @param   string  data    String to be parsed and compared to current value
     * @return  boolean         True if val === parsed data.
     */
    processComputedProperty: function(target, prop, data = null) {
        let val;
        let start;
        let end;
        let comparison;
        let isString;
        let negate = false;
        
        // We need a value to compare.
        if (! prop || typeof target[prop] === 'undefined') {
            return false;
        }
                
        data = $.trim(data);
        
        // We at least need something to check.
        if (! data || ! data.length) {
            return false;
        }
        
        // Check for negation.
        if (data.indexOf('!') === 0) {
            negate = true;
            data = data.substring(1, data.length);
        }
        
        // We need parentheses at the start and end of the string.
        if (! data || ! data.length || data.indexOf('(') === -1 || data.indexOf(')') !== data.length -1) {
            return false;
        }
        
        start = data.indexOf('(');
        end = data.indexOf(')');
        comparison = data.substring(start + 1, end);
        isString = false;
        
        val = target[prop];
        
        // Do we have a number or a string?
        comparison = $.trim(comparison);
        
        if (! comparison.length) {
            comparison = null; // Default
        } else if (comparison.indexOf('\'') === 0) {
            // We have a string in single quotes.
            isString = true;
            comparison = comparison.replace(/(^'|'$)/g, ''); // Remove the quotes at the start and end.
        } else if (comparison.indexOf('"') === 0) {
            // We have a string in double quotes.
            isString = true;
            comparison = comparison.replace(/(^"|"$)/g, ''); // Remove the quotes at the start and end.
        }
        
        // Process numbers.
        if (! isString) {
            if (comparison === 'true') {
                comparison = true;
            } else if (comparison === 'false') {
                comparison = false;
            } else if (comparison === 'null') {
                comparison = null;
            } else if (comparison.indexOf('.') !== -1)  {
                comparison = parseFloat(comparison);
            } else {
                // Default to integer.
                comparison = parseInt(comparison);
            }
        }
        
        // After all of that, we can finally do a comparison.
        if (negate) {
            return val !== comparison;
        }
        
        return val === comparison;
    },
    
    /**
     * Applies a property change to the DOM.
     * 
     * @param   string prop         The portalData property that is being changed.
     * @prop    mixed val           (Optional) The new value of the property before it's assigned.
     * 
     * @return  boolean|string      True on success, string with error message on failure. Silently fails if the
     *                              property does not exist.
     */
    update: function(target, prop = null, val = null, oldVal = null, setIndex = null, event = null) {
        let me = portalDataProcessor;
        let eventData;

        if (typeof $ === 'undefined') {
            let error = 'Error setting up Portal Data. jQuery not defined.';
            console.error(error);
            return error;
        }
        
        prop = $.trim(prop); // Convert to string and remove surrounding spaces.
        
        // See if property actually exists.
        // If not, fail silently.
        if (! prop || ! me.trackedProperties.includes(prop)) {
            return true;
        }
        
        eventData = {
            type: 'portal-data-' + prop + '-processed',
            prop: prop,
            val: val,
            oldVal: oldVal,
            error: null
        };
        
        shorthandEventData = {
            type: '_pd.' + prop + '.processed',
            prop: prop,
            val: val,
            oldVal: oldVal,
            error: null
        };
        
        // Do the cool stuff.
        try {
            me.model(target, prop, val, oldVal, event);
            me.bindings(target, prop);
            me.display(target, prop);
            me.visible(target, prop);
            me.block(target, prop);
            me.mute(target, prop);
            me.highlight(target, prop);
            me.required(target, prop);
            me.disabled(target, prop);
            me.readonly(target, prop);
        } catch (e) {
            eventData.error = e;
        }

        $(document).trigger(eventData);
        $(document).trigger(shorthandEventData);

        return true;
    },
    
    /**
     * Process two-way bindings. This only applies to form elements and non-form elements will be skipped.
     * So, if you try something like <span data-model="foo"></span> if will not work.
     *
     * @param   object target       portalData target
     * @param   string prop         The portalData property that is being changed.
     *
     * @return  void
     */
    model: function(target, prop, val, oldVal, event = null) {
        let me = portalDataProcessor;
        let triggerEvent = null;
        let elements = $('[data-model="' + prop + '"]');
        let triggerElement = null; // DOM element that triggered this event.
        let trigger = null; // jQuery object for triggerElement.
        let toggleChecked = null;
        let toggleVal = val;
        let toggleFamily = null;
        let form = null;
        let files = null;
        
        // Check for model update.
        if (event && typeof event.type !== 'undefined' && event.type === 'portal-data-model-update') {
            triggerEvent = event.triggerEvent;
            triggerElement = triggerEvent.target;
        }
        
        if (event && typeof event.files !== 'undefined') {
            files = event.files;
        }
        
        // Checkboxes and radio buttons.
        if (triggerElement) {
            trigger = $(triggerElement);
        }
        
        if (trigger && trigger.is(':checkbox, :radio')) {
            toggleChecked = trigger.prop('checked');
            toggleVal = trigger.val();
            // console.log(toggleVal);
            
            // If the element isn't checked, reset the value for all affected elements.
            if (! toggleChecked) {
                val = '';
            }
            
            // For radios, toggle the elements within the input's form parent, if it exists
            if (trigger.is(':radio')) {
                form = trigger.closest('form');
                
                if (form.length) {
                    toggleFamily = form.find(':radio[name="' + trigger.attr('name') + '"]');
                    
                    if (toggleFamily.length) {
                        toggleFamily.each(function() {
                            let elm = $(this);
                            
                            if (this === triggerElement) {
                                return; // No need to update the element that already changed
                            }
                            
                            if (elm.val() !== '' && elm.val() == toggleVal) {
                                elm.prop('checked', toggleChecked);
                            }
                        });
                    }
                }
            }
        }
        
        // Filter out any non-form elements.
        if (elements.length > 0) {
            elements = elements.filter(':input, :checkbox, :radio');
        }
        
        if (elements.length > 0) {
            elements.each(function() {
                let elm = $(this);
                let options;
                
                // Don't process the value of the element that triggered the event
                if (triggerElement && this == triggerElement) {
                    if (elm.is(':radio')) {
                        elm.prop('checked', toggleChecked);
                    }
                    
                    return;
                }
                
                // Check for different input types.
                if (elm.is(':file')) {
                    if (files && files instanceof FileList) {
                        console.log(typeof files);
                        elm[0].files = Object.assign(files);
                    } else {
                        elm.val(''); // Resets the file list
                    }
                } else if (elm.is(':text')) {
                    // Standard inputs
                    elm.val(val);
                } else if (elm.is(':radio') || elm.is(':checkbox')) {
                    // Radio buttons and checkboxes:
                    // If this was triggered by a radio or checkbox, toggle according to the element's "checked"
                    // attribute. Otherwise, check if the value is the same to decide if this is checked or not (which
                    // will happen when the value of the actual portalData property is changed from somewhere else).
                    if (toggleChecked === null) {
                        // This was NOT triggered by a radio button or checkbox.
                        if (toggleVal === null) {
                            elm.prop('checked', false); // Should deselect all elements
                        } else if (elm.val() !== '' && elm.val() == toggleVal) {
                            elm.prop('checked', true);
                        } else {
                            elm.prop('checked', false);
                        }
                    } else {
                        // This was triggered by a radio button or checkbox.
                        if (elm.val() !== '' && elm.val() == toggleVal) {
                            if (toggleChecked !== null) {
                                elm.prop('checked', toggleChecked);
                            }
                        }
                    }
                } else if (elm.is('select')) {
                    // Selects
                    options = elm.find('option');
                    
                    options.each(function() {
                        if ($(this).val() !== '' && $(this).val() == val) {
                            $(this).prop('selected', true);
                        } else {
                            $(this).prop('selected', false);
                        }
                    });
                } else if (elm.is('textarea')) {
                    // Textareas
                    elm.val(val);
                } else if (elm.is(':hidden')) {
                    // Hidden inputs
                    elm.val(val);
                } else if (elm.is(
                    // Other input types
                    '[type="button"], [type="color"], [type="date"], [type="datetime-local"], [type="email"],'
                    + '[type="image"], [type="month"], [type="number"], [type="password"], [type="range"],'
                    + '[type="search"], [type="submit"], [type="tel"], [type="time"], [type="url"]'
               )) {
                    elm.val(val);
                } else {
                    // Don't do anything if we don't know what type of element it is.
                }
            });
        }
        
        if (elements.length) {
            $(document).trigger('portal-data-model-processed', {
                prop: prop,
                val: val,
                oldVal: oldVal,
                affectedElements: elements.length
            });
        }
    },
    
    /**
     * Process one-way binding.
     */
    bindings: function(target, prop) {
        let me = portalDataProcessor;
        let elements = $('[data-bind="' + prop + '"]');
        
        if (elements.length > 0) {
            elements.each(function() {
                let elm = $(this);
                elm.html(target[prop]);
            });
        }
    },
    
    /**
     * Helper method to remove focus from an element and its children.
     * @param   object  elm     jQuery element
     * @return  void
     */
    blur: function(elm) {
        let focussed;
        
        // Check for focus.
        if (elm.is(':focus')) {
            // Main element is focussed.
            focussed = elm;
        } else {
            // Are children focussed?
            focussed = elm.find(':focus');
        }
        
        // Remove focus if the element or any of its children are in focus.
        if (focussed.length) {
            focussed.blur();
        }
    },
    
    /**
     * Toggle display for all elements with data-show* attributes.
     *
     * @param   object  target  Proxy target
     * @param   string  prop    Property name
     *
     * @return  void
     */
    display: function(target, prop) {
        // Display
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Show If
        let displayAnd = $('[data-show-if~="' + $.escapeSelector(prop) + '"], [data-show-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let displayOr = $('[data-show-if-or~="' + $.escapeSelector(prop) + '"], [data-show-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let displayComputed = $('[data-show-if\^="' + $.escapeSelector(prop) + '\\("]');
        
        // Show Unless
        let displayUnlessAnd = $('[data-show-unless~="' + $.escapeSelector(prop) + '"], [data-show-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let displayUnlessOr = $('[data-show-unless-or~="' + $.escapeSelector(prop) + '"], [data-show-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let displayUnlessComputed = $('[data-show-unless\^="' + $.escapeSelector(prop) + '\\("]');
        
        if (displayAnd.length > 0) {
            displayAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-show-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');
                
                if (check) {
                    $(this).removeClass('portal-d-none d-none');
                } else {
                    $(this).addClass('portal-d-none d-none');
                }
            });
        }
        
        if (displayOr.length > 0) {
            displayOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-show-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).removeClass('portal-d-none d-none');
                } else {
                    $(this).addClass('portal-d-none d-none');
                }
            });
        }
        
        if (displayComputed.length) {
            displayComputed.each(function() {
                let elm = $(this);
                let data = elm.data().showIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    elm.removeClass('portal-d-none d-none');
                } else {
                    elm.addClass('portal-d-none d-none');
                }
            });
        }
        
        if (displayUnlessAnd.length > 0) {
            displayUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-show-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');
                
                if (!check) {
                    $(this).removeClass('portal-d-none d-none');
                } else {
                    $(this).addClass('portal-d-none d-none');
                }
            });
        }
        
        if (displayUnlessOr.length > 0) {
            displayUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-show-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (!check) {
                    $(this).removeClass('portal-d-none d-none');
                } else {
                    $(this).addClass('portal-d-none d-none');
                }
            });
        }
        
        if (displayUnlessComputed.length) {
            displayUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().showUnless;
                check = me.processComputedProperty(target, prop, data);

                if (!check) {
                    elm.removeClass('portal-d-none d-none');
                } else {
                    elm.addClass('portal-d-none d-none');
                }
            });
        }
    },
    
    /**
     * Toggle visibility for all elements with data-visible* attributes.
     *
     * @param   object  target      Proxy target
     * @param   string  prop        Property name
     *
     * @return  void
     */
    visible: function(target, prop) {
        // Visibility
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Visible If
        let visibleAnd = $('[data-visible-if~="' + $.escapeSelector(prop)  + '"], [data-visible-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let visibleOr = $('[data-visible-if-or~="' + $.escapeSelector(prop)  + '"], [data-visible-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let visibleComputed = $('[data-visible-if\^="' + $.escapeSelector(prop) + '\\("]');
        
        // Visible Unless
        let visibleUnlessAnd = $('[data-visible-unless~="' + $.escapeSelector(prop)  + '"], [data-visible-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let visibleUnlessOr = $('[data-visible-unless-or~="' + $.escapeSelector(prop)  + '"], [data-visible-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let visibleUnlessComputed = $('[data-visible-unless\^="' + $.escapeSelector(prop) + '\\("]');
        
        if (visibleAnd.length > 0) {
            visibleAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-visible-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (check) {
                    $(this).removeClass('portal-hidden');
                } else {
                    $(this).addClass('portal-hidden');
                }
            });
        }
        
        if (visibleOr.length > 0) {
            visibleOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-visible-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).removeClass('portal-hidden');
                } else {
                    $(this).addClass('portal-hidden');
                }
            });
        }
        
        if (visibleComputed.length) {
            visibleComputed.each(function() {
                let elm = $(this);
                let data = elm.data().visibleIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    elm.removeClass('portal-hidden');
                } else {
                    elm.addClass('portal-hidden');
                }
            });
        }
        
        if (visibleUnlessAnd.length > 0) {
            visibleUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-visible-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (! check) {
                    $(this).removeClass('portal-hidden');
                } else {
                    $(this).addClass('portal-hidden');
                }
            });
        }
        
        if (visibleUnlessOr.length > 0) {
            visibleUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-visible-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (! check) {
                    $(this).removeClass('portal-hidden');
                } else {
                    $(this).addClass('portal-hidden');
                }
            });
        }
        
        if (visibleUnlessComputed.length) {
            visibleUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().visibleUnless;
                check = me.processComputedProperty(target, prop, data);

                if (! check) {
                    elm.removeClass('portal-hidden');
                } else {
                    elm.addClass('portal-hidden');
                }
            });
        }
    },
    
    /**
     * Toggle highlighting for all elements with data-highlight* attributes.
     *
     * @param   object  target  Proxy target
     * @param   string  prop    Property name
     *
     * @return  void
     */
    highlight: function(target, prop) {
        // Visibility
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Highlight If
        let highlightAnd = $('[data-highlight-if~="' + $.escapeSelector(prop)  + '"], [data-highlight-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let highlightOr = $('[data-highlight-if-or~="' + $.escapeSelector(prop)  + '"], [data-highlight-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let highlightComputed = $('[data-highlight-if\^="' + $.escapeSelector(prop) + '\\("]');
        
        // Highlight Unless
        let highlightUnlessAnd = $('[data-highlight-unless~="' + $.escapeSelector(prop)  + '"], [data-highlight-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let highlightUnlessOr = $('[data-highlight-unless-or~="' + $.escapeSelector(prop)  + '"], [data-highlight-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let highlightUnlessComputed = $('[data-highlight-unless\^="' + $.escapeSelector(prop) + '\\("]');
        
        if (highlightAnd.length > 0) {
            highlightAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-highlight-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (check) {
                    $(this).addClass('portal-highlighted');
                } else {
                    $(this).removeClass('portal-highlighted');
                }
            });
        }
        
        if (highlightOr.length > 0) {
            highlightOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-highlight-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).addClass('portal-highlighted');
                } else {
                    $(this).removeClass('portal-highlighted');
                }
            });
        }
        
        if (highlightComputed.length) {
            highlightComputed.each(function() {
                let elm = $(this);
                let data = elm.data().highlightIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    elm.addClass('portal-highlighted');
                } else {
                    elm.removeClass('portal-highlighted');
                }
            });
        }
        
        if (highlightUnlessAnd.length > 0) {
            highlightUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-highlight-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (! check) {
                    $(this).addClass('portal-highlighted');
                } else {
                    $(this).removeClass('portal-highlighted');
                }
            });
        }
        
        if (highlightUnlessOr.length > 0) {
            highlightUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-highlight-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (! check) {
                    $(this).addClass('portal-highlighted');
                } else {
                    $(this).removeClass('portal-highlighted');
                }
            });
        }
        
        if (highlightUnlessComputed.length) {
            highlightUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().highlightUnless;
                check = me.processComputedProperty(target, prop, data);

                if (! check) {
                    elm.addClass('portal-highlighted');
                } else {
                    elm.removeClass('portal-highlighted');
                }
            });
        }
    },
    
    /**
     * Block all elements with data-block* attributes.
     *
     * @param   object  target  Reference to self
     * @param   string  prop    Property name
     *
     * @return  void
     */
    block: function(target, prop) {
        // Blocked elements
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Block If
        let blockAnd = $('[data-block-if~="' + $.escapeSelector(prop)  + '"], [data-block-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let blockOr = $('[data-block-if-or~="' + $.escapeSelector(prop)  + '"], [data-block-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let blockComputed = $('[data-block-if\^="' + $.escapeSelector(prop) + '\\("]');
        
        // Block Unless
        let blockUnlessAnd = $('[data-block-unless~="' + $.escapeSelector(prop)  + '"], [data-block-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let blockUnlessOr = $('[data-block-unless-or~="' + $.escapeSelector(prop)  + '"], [data-block-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let blockUnlessComputed = $('[data-block-unless\^="' + $.escapeSelector(prop) + '\\("]');
                
        if (blockAnd.length > 0) {
            blockAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-block-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (check) {
                    $(this).addClass('portal-blocked');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-blocked');
                }
            });
        }
        
        if (blockOr.length > 0) {
            blockOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-block-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).addClass('portal-blocked');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-blocked');
                }
            });
        }
        
        if (blockComputed.length) {
            blockComputed.each(function() {
                let elm = $(this);
                let data = elm.data().blockIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    eml.addClass('portal-blocked');
                    
                    // Remove focus.
                    me.blur(elm);
                } else {
                    elm.removeClass('portal-blocked');
                }
            });
        }
        
        if (blockUnlessAnd.length > 0) {
            blockUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-block-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (! check) {
                    $(this).addClass('portal-blocked');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-blocked');
                }
            });
        }
        
        if (blockUnlessOr.length > 0) {
            blockUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-block-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (! check) {
                    $(this).addClass('portal-blocked');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-blocked');
                }
            });
        }
        
        if (blockUnlessComputed.length) {
            blockUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().blockUnless;
                check = me.processComputedProperty(target, prop, data);

                if (! check) {
                    eml.addClass('portal-blocked');
                    
                    // Remove focus.
                    me.blur(elm);
                } else {
                    elm.removeClass('portal-blocked');
                }
            });
        }
    },
    
    /**
     * Mute all elements with data-mute* attributes.
     *
     * @param   object  target  Reference to self
     * @param   string  prop    Property name
     *
     * @return  void
     */
    mute: function(target, prop) {
        // Muted elements
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Mute If
        let muteAnd = $('[data-mute-if~="' + $.escapeSelector(prop)  + '"], [data-mute-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let muteOr = $('[data-mute-if-or~="' + $.escapeSelector(prop)  + '"], [data-mute-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let muteComputed = $('[data-mute-if\^="' + $.escapeSelector(prop) + '\\("], [data-mute-if\^="' + $.escapeSelector('!' + prop) + '\\("]');
        
        // Mute Unless
        let muteUnlessAnd = $('[data-mute-unless~="' + $.escapeSelector(prop)  + '"], [data-mute-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let muteUnlessOr = $('[data-mute-unless-or~="' + $.escapeSelector(prop)  + '"], [data-mute-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let muteUnlessComputed = $('[data-mute-unless\^="' + $.escapeSelector(prop) + '\\("], [data-mute-unless\^="' + $.escapeSelector('!' + prop) + '\\("]');
        
        if (muteAnd.length > 0) {
            muteAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-mute-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (check) {
                    $(this).addClass('portal-muted');
                    $(this).removeClass('portal-unmuted');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-muted');
                    $(this).addClass('portal-unmuted');
                }
            });
        }
        
        if (muteOr.length > 0) {
            muteOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-mute-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).addClass('portal-muted');
                    $(this).removeClass('portal-unmuted');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-muted');
                    $(this).addClass('portal-unmuted');
                }
            });
        }
        
        if (muteComputed.length) {
            muteComputed.each(function() {
                let elm = $(this);
                let data = elm.data().muteIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    elm.addClass('portal-muted');
                    elm.removeClass('portal-unmuted');
                    
                    // Remove focus.
                    me.blur(elm);
                } else {
                    elm.removeClass('portal-muted');
                    elm.addClass('portal-unmuted');
                }
            });
        }
        
        if (muteUnlessAnd.length > 0) {
            muteUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-mute-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (! check) {
                    $(this).addClass('portal-muted');
                    $(this).removeClass('portal-unmuted');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-muted');
                    $(this).addClass('portal-unmuted');
                }
            });
        }
        
        if (muteUnlessOr.length > 0) {
            muteUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-mute-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (! check) {
                    $(this).addClass('portal-muted');
                    $(this).removeClass('portal-unmuted');
                    
                    // Remove focus.
                    me.blur($(this));
                } else {
                    $(this).removeClass('portal-muted');
                    $(this).addClass('portal-unmuted');
                }
            });
        }
        
        if (muteUnlessComputed.length) {
            muteUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().muteUnless;
                check = me.processComputedProperty(target, prop, data);

                if (! check) {
                    elm.addClass('portal-muted');
                    elm.removeClass('portal-unmuted');
                    
                    // Remove focus.
                    me.blur(elm);
                } else {
                    elm.removeClass('portal-muted');
                    elm.addClass('portal-unmuted');
                }
            });
        }
    },
    
    /**
     * Toggle required attribute for all elements with data-required* attributes.
     *
     * @param   object  target  Proxy target
     * @param   string  prop    Property name
     *
     * @return  void
     */
    required: function(target, prop) {
        // Required
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Required If
        let requiredAnd = $('[data-required-if~="' + $.escapeSelector(prop)  + '"], [data-required-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let requiredOr = $('[data-required-if-or~="' + $.escapeSelector(prop)  + '"], [data-required-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let requiredComputed = $('[data-required-if\^="' + $.escapeSelector(prop) + '\\("]');
        
        // Required Unless
        let requiredUnlessAnd = $('[data-required-unless~="' + $.escapeSelector(prop)  + '"], [data-required-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let requiredUnlessOr = $('[data-required-unless-or~="' + $.escapeSelector(prop)  + '"], [data-required-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let requiredUnlessComputed = $('[data-required-unless\^="' + $.escapeSelector(prop) + '\\("]');
        
        if (requiredAnd.length > 0) {
            requiredAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-required-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (check) {
                    $(this).prop('required', true);
                } else {
                    $(this).prop('required', false);
                }
            });
        }
        
        if (requiredOr.length > 0) {
            requiredOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-required-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).prop('required', true);
                } else {
                    $(this).prop('required', false);
                }
            });
        }
        
        if (requiredComputed.length) {
            requiredComputed.each(function() {
                let elm = $(this);
                let data = elm.data().requiredIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    elm.prop('required', true);
                } else {
                    elm.prop('required', false);
                }
            });
        }
        
        if (requiredUnlessAnd.length > 0) {
            requiredUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-required-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (! check) {
                    $(this).prop('required', true);
                } else {
                    $(this).prop('required', false);
                }
            });
        }
        
        if (requiredUnlessOr.length > 0) {
            requiredUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-required-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (! check) {
                    $(this).prop('required', true);
                } else {
                    $(this).prop('required', false);
                }
            });
        }
        
        if (requiredUnlessComputed.length) {
            requiredUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().requiredUnless;
                check = me.processComputedProperty(target, prop, data);

                if (! check) {
                    elm.prop('required', true);
                } else {
                    elm.prop('required', false);
                }
            });
        }
    },
    
    /**
     * Toggle disabled attribute for all elements with data-disabled* attributes.
     *
     * @param   object  target  Proxy target
     * @param   string  prop    Property name
     *
     * @return  void
     */
    disabled: function(target, prop) {
        // Disabled
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Disabled If
        let disabledAnd = $('[data-disabled-if~="' + $.escapeSelector(prop)  + '"], [data-disabled-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let disabledOr = $('[data-disabled-if-or~="' + $.escapeSelector(prop)  + '"], [data-disabled-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let disabledComputed = $('[data-disabled-if\^="' + $.escapeSelector(prop) + '\\("]');
        
        // Disabled Unless
        let disabledUnlessAnd = $('[data-disabled-unless~="' + $.escapeSelector(prop)  + '"], [data-disabled-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let disabledUnlessOr = $('[data-disabled-unless-or~="' + $.escapeSelector(prop)  + '"], [data-disabled-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let disabledUnlessComputed = $('[data-disabled-unless\^="' + $.escapeSelector(prop) + '\\("]');
        
        if (disabledAnd.length > 0) {
            disabledAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-disabled-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (check) {
                    $(this).prop('disabled', true);
                } else {
                    $(this).prop('disabled', false);
                }
            });
        }
        
        if (disabledOr.length > 0) {
            disabledOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-disabled-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).prop('disabled', true);
                } else {
                    $(this).prop('disabled', false);
                }
            });
        }
        
        if (disabledComputed.length) {
            disabledComputed.each(function() {
                let elm = $(this);
                let data = elm.data().disabledIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    elm.prop('disabled', true);
                } else {
                    elm.prop('disabled', false);
                }
            });
        }
        
        if (disabledUnlessAnd.length > 0) {
            disabledUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-disabled-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (! check) {
                    $(this).prop('disabled', true);
                } else {
                    $(this).prop('disabled', false);
                }
            });
        }
        
        if (disabledUnlessOr.length > 0) {
            disabledUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-disabled-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (! check) {
                    $(this).prop('disabled', true);
                } else {
                    $(this).prop('disabled', false);
                }
            });
        }
        
        if (disabledUnlessComputed.length) {
            disabledUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().disabledUnless;
                check = me.processComputedProperty(target, prop, data);

                if (! check) {
                    elm.prop('disabled', true);
                } else {
                    elm.prop('disabled', false);
                }
            });
        }
    },
    
    /**
     * Toggle disabled attribute for all elements with data-disabled* attributes.
     *
     * @param   object  target  Proxy target
     * @param   string  prop    Property name
     *
     * @return  void
     */
    readonly: function(target, prop) {
        // Readonly
        let me = portalDataProcessor;
        let check;
        let attrProps;
        
        // Readonly If
        let readonlyAnd = $('[data-readonly-if~="' + $.escapeSelector(prop)  + '"], [data-readonly-if~="' + $.escapeSelector('!' + prop)  + '"]');
        let readonlyOr = $('[data-readonly-if-or~="' + $.escapeSelector(prop)  + '"], [data-readonly-if-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let readonlyComputed = $('[data-readonly-if\^="' + $.escapeSelector(prop) + '\\("]');
        
        // Readonly Unless
        let readonlyUnlessAnd = $('[data-readonly-unless~="' + $.escapeSelector(prop)  + '"], [data-readonly-unless~="' + $.escapeSelector('!' + prop)  + '"]');
        let readonlyUnlessOr = $('[data-readonly-unless-or~="' + $.escapeSelector(prop)  + '"], [data-readonly-unless-or~="' + $.escapeSelector('!' + prop)  + '"]');
        let readonlyUnlessComputed = $('[data-readonly-unless\^="' + $.escapeSelector(prop) + '\\("]');
        
        if (readonlyAnd.length > 0) {
            readonlyAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-readonly-if').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (check) {
                    $(this).prop('readonly', true);
                } else {
                    $(this).prop('readonly', false);
                }
            });
        }
        
        if (readonlyOr.length > 0) {
            readonlyOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-readonly-if-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (check) {
                    $(this).prop('readonly', true);
                } else {
                    $(this).prop('readonly', false);
                }
            });
        }
        
        if (readonlyComputed.length) {
            readonlyComputed.each(function() {
                let elm = $(this);
                let data = elm.data().readonlyIf;
                check = me.processComputedProperty(target, prop, data);

                if (check) {
                    elm.prop('readonly', true);
                } else {
                    elm.prop('readonly', false);
                }
            });
        }
        
        if (readonlyUnlessAnd.length > 0) {
            readonlyUnlessAnd.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-readonly-unless').split(' ');
                check = me.processProperties(target, attrProps, 'and');

                if (! check) {
                    $(this).prop('readonly', true);
                } else {
                    $(this).prop('readonly', false);
                }
            });
        }
        
        if (readonlyUnlessOr.length > 0) {
            readonlyUnlessOr.each(function() {
                // We'll run through all the checks in the attribute.
                attrProps = $(this).attr('data-readonly-unless-or').split(' ');
                check = me.processProperties(target, attrProps, 'or');

                if (! check) {
                    $(this).prop('readonly', true);
                } else {
                    $(this).prop('readonly', false);
                }
            });
        }
        
        if (readonlyUnlessComputed.length) {
            readonlyUnlessComputed.each(function() {
                let elm = $(this);
                let data = elm.data().readonlyUnless;
                check = me.processComputedProperty(target, prop, data);

                if (! check) {
                    elm.prop('readonly', true);
                } else {
                    elm.prop('readonly', false);
                }
            });
        }
    },
    
    /**
     * Bind a portalData property to a session key.
     *
     * @param   string  prop        Property name.
     * @param   mixed   prefix      (Optional) Prefix for session key. If this is set, an underscore will be placed
     *                              between the prefix and the property name to identify the property in the session.
     * @param   mixed   initialVal  (Optional) A value to set the session and the portalData property to. If no val is
     *                              provided, we'll attempt to find the value in the session and automatically set
     *                              portalData[prop] to that.
     *
     * @return  mixed               Value of prop in session.
     */
    sessionBind: function(prop, prefix, initialVal) {
        let me = portalDataProcessor;
        let val;
        let encoded;
        
        // If we've already bound this property, throw an error.
        if (prop in portalData && me.sessionHas(prop)) {
            throw new Error('Could not bind portalData.' + prop
                + ' to session, since it is already bound to session key ' + me.trackedSessionKeys[prop]);
        }
        
        if (typeof prefix === 'undefined') {
            prefix = '';
        }
        
        prefix = $.trim(prefix); // Cast to string and remove empty space.
        
        // Add underscore to separate prefix (if one is going to be used).
        if (prefix) {
            prefix += '_';
        }
                    
        // If no value is passed, assume we're going to attempt to retrieve the value from the session.
        // In either case, we'll create the sessionStorage item if it doesn't exist.
        // If a default value is passed, it will overwrite the value in the session.
        if (typeof initialVal !== 'undefined') {
            val = initialVal;
        } else {
            // Fetch the value from the session.
            encoded = sessionStorage.getItem(prefix + prop);
            val = me.sessionDecode(encoded);
        }
        
        if (val === null) {
            // If no value was set in the session, let's see if the property isn't already defined on portalData and
            // use that value.
            if (prop in portalData) {
                val = portalData[prop];
            }
        }
        
        // Once we have a prefix and a value, we can track the item in the session.
        me.trackSession(prop, prefix + prop);
        
        try {
            me.sessionSet(prop, val);
        } catch (e) {
            // This isn't a fatal error, but we need to tell someone.
            // At least display an error in the console.
            console.error(e.name + ': ' + e.message);
        }
        
        portalData[prop] = val;
        return val;
    },
    
    /**
     * Bind a session key with a property name.
     *
     * @param   string  prop    Property name.
     * @param   string  prefix  (Optional) Prefix for session key.
     *
     * @return  void
     */
    trackSession: function(prop, sessionKey = '') {
        let me = portalDataProcessor;
        me.trackedSessionKeys[prop] = sessionKey;
    },
    
    /**
     * Encode a value to be stored in the session. We JSON-encode an object that holds the value, in order to keep the
     * value's data type intact. We need to do this, since sessionStorage only stores strings.
     *
     * @param   mixed  val  The value to store in the session.
     *
     * @return  string
     */
    sessionEncode: function(val) {
        return JSON.stringify({
            val: val
        });
    },
    
    /**
     * Decode a value that was saved in the session. @see sessionEncode() for why we need to decode values in the
     * session.
     *
     * @param   string  jsonString  String to decode.
     *
     * @return  mixed               The value of the decoded session key, or null on failure.
     */
    sessionDecode: function(jsonString) {
        let decoded;
        let val;
        
        try {
            decoded = JSON.parse(jsonString);
            val = decoded.val;
        } catch (e) {
            val = null; // Default.
        }
        
        return val;
    },
    
    /**
     * Set a value in the session for a specific property name. If the property isn't tracked, we don't have a bound
     * session key for the property, so null is returned.
     *
     * @param   string  prop    Property name.
     * @param   mixed  val      Property value.
     *
     * @return  mixed           New value (val), null if property isn't tracked in the session.
     */
    sessionSet: function(prop, val) {
        let me = portalDataProcessor;
        let key;
        
        // Are we tracking the property?
        if (! prop in me.trackedSessionKeys) {
            throw new Error('Unable to set session value for untracked property. Property name: ' + prop
                + '. New value: ' + val);
        }
        
        // To preserve the data type of the property, we JSON-encode an object that contains the value.
        key = me.trackedSessionKeys[prop];
        sessionStorage.setItem(key, me.sessionEncode(val));
        
        return val;
    },
    
    /**
     * Get a property value from the session.
     *
     * @param   string  prop    Property name.
     *
     * @return  mixed           Property value, or null if the property isn't tracked in the session.
     */
    sessionGet: function(prop) {
        let me = portalDataProcessor;
        let key;
        let encoded;
        let val;
        
        // Are we tracking the property?
        if (! prop in me.trackedSessionKeys) {
            return null;
        }
        
        key = me.trackedSessionKeys[prop];
        
        // Decode the JSON object that holds the value.
        encoded = sessionStorage.getItem(key);
        val = me.sessionDecode(encoded);
        
        return val;
    },
    
    /**
     * Check if we're already tracking a property via the session.
     *
     * @param   string  prop    Property name
     *
     * @return  boolean
     */
    sessionHas: function(prop) {
        let me = portalDataProcessor;
        return prop in me.trackedSessionKeys;
    },
    
    bindModel: function(elm) {
        if (elm.length > 0) {
            elm.each(function() {
                if (! $(this).data('portal-data-model-bound')) {
                    $(this).attr('data-portal-data-model-bound', 1);
                    $(this).on('change keyup', function(event) {
                        let me = $(this);
                        let prop = me.data('model');
                        let val = me.val();
                        
                        let newEvent = $.Event('portal-data-model-update', {
                            prop: prop,
                            val: val,
                            triggerEvent: event // This can be used to target the triggering element.
                        });
                        
                        portalData[prop] = newEvent;
                    });
                }
            });
        }
    }
};

/**
 * Handler for portalData Proxy.
 * Traps property changes and adds their processing the the portalQueue.
 */
const portalDataHandler = {
    /**
     * Trap method that runs as a property gets added to the portalData object, but before the property gets assigned
     * a value.
     *
     * @param   object  target          Proxy object
     * @param   string  key             Property name
     * @param   mixed   descriptions    No idea what this is... check out the Mozilla docs.
     *
     * @return  mixed                   True on success, void on failure.
     */
    defineProperty: function(target, key, descriptor) {
        // Enable tracking for this property.
        portalDataProcessor.trackProperty(key);
        return true;
    },
    
    /**
     * Trap method that runs as a property gets deleted from the portalData object.
     *
     * @param   object  target      Proxy object
     * @param   string prop         Property name
     *
     * @return  boolean             True on success, false on failure.
     */
    deleteProperty: function(target, prop) {
        // Delete the property in portalData.
        if (prop in target) {
            delete target[prop];
        }
        
        // Stop tracking the property.
        portalDataProcessor.untrackProperty(prop);
        return true;
    },
    
    /**
     * Trap method that runs as a property gets assigned a value, but before the value is applied.
     * Processing gets moved to the portalQueue to be run later in the loop.
     * 
     * @param object target     Proxy target
     * @param string prop       Property name
     * @param mixed val         The value to assign to the property. Defaults to null if not defined.
     * @param mixed receiver    No idea what this is. See the Mozilla docs.
     * 
     * @return boolean          True if successful, false on failure.
     */
    set: function(target, prop, val, receiver) {
        // Don't overwrite protected properties.
        if (portalDataProcessor.protectedProperties.indexOf(prop) !== -1) {
            console.error('Portal Data Error: _pd.' + prop + ' is a protected property');
            return true; // Fail without changing the value.
        }
        
        let success = false;
        // let item = null; // This will hold the created item in the queue buffer.
        let event = null;
        let oldVal = undefined;
        let setIndex = portalDataProcessor.newSetIndex();
        let toggleChecked = true; // Used for checkboxes and radio buttons.
        let triggerElement = null; // The element that triggered this change (if this was caused by DOM).
        
        // If this change is from a data binding, extract the real value and pass the event along to the Queue.
        if (
            val
            && typeof val === 'object'
            && typeof val.type === 'string'
            && val.type === 'portal-data-model-update'
       ) {
            event = val;
            val = event.val;
            triggerElement = $(event.triggerEvent.target);
        }
        
        // See if we have a previous value and whether the value is actually changing.
        if (typeof target[prop] !== 'undefined') {
            oldVal = target[prop];
            
            if (target[prop] === val) {
                success = true;
            }
        }
        
        // For checkboxes and radio buttons, reset the property value if it's not checked, but still pass the
        // original value to be processed.
        if (
            event
            && triggerElement
            && triggerElement.is(':radio, :checkbox')
       ) {
            // Force us to continue even if the value didn't change (because a checkbox's value never changes).
            success = false;
            toggleChecked = $(event.triggerEvent.target).prop('checked');
        }
        
        portalDataProcessor.trackProperty(prop);
        
        if (! toggleChecked) {
            val = ''; // Reset value if the target element is unchecked.
        }
        
        // Add value to session if it is being tracked.
        if (portalDataProcessor.sessionHas(prop)) {
            try {
                portalDataProcessor.sessionSet(prop, portalDataProcessor.applyCast(prop, val));
            } catch (e) {
                // We won't give up just yet...
                success = false;
                console.log(e.name + ': ' + e.message);
            }
        }
        
        if (! success) {
            val = portalDataProcessor.applyCast(prop, val);
            target[prop] = val;
            
            // Add update function to the queue so it can be processed later.
            item = portalLoop.queue.add(
                portalDataProcessor.update, // Function call to queue.
                // Arguments for queue'd function.
                [target, prop, val, oldVal, setIndex, event],
                
                // Success callback function.
                function(item, target, prop, val, oldVal, setIndex, event) {
                    let newEventData = {
                        type: 'portal-data-queue-set-success',
                        prop: prop,
                        val: val,
                        oldVal: oldVal,
                        item: item,
                        setIndex: setIndex
                    };
                    
                    if (event && typeof event === 'object' && typeof event.type === 'string') {
                        newEventData.queueEvent = event;
                    }
                    
                    $(document).trigger(newEventData);
                },
                
                // Error callback function.
                function(item, target, prop, val, oldVal, setIndex, event) {
                    let newEventData = {
                        type: 'portal-data-queue-set-fail',
                        prop: prop,
                        val: val,
                        oldVal: oldVal,
                        item: item,
                        setIndex: setIndex
                    };
                    
                    if (event && typeof event === 'object' && typeof event.type === 'string') {
                        newEventData.queueEvent = event;
                    }
                    
                    $(document).trigger(newEventData);
                }
           );
        }
        
        // Let everyone know something has (or hasn't) changed.
        $(document).trigger({
            type: 'portal-data-' + prop + '-changed',
            prop: prop,
            val: val,
            oldVal: oldVal,
            setIndex: setIndex
        });
        
        $(document).trigger({
            type: '_pd.' + prop + '.changed', // shorthand
            prop: prop,
            val: val,
            oldVal: oldVal,
            setIndex: setIndex
        });
        
        return true; // Don't throw an error in strict mode.
    },
    
    /**
     * Trap method that runs before a property's value is returned.
     * 
     * @param object target     Proxy target
     * @param string prop       Property name
     * @param mixed receiver    No idea what this is. See the Mozilla docs.
     * 
     * @return mixed            The value of the property.
     */
    get: function(target, prop, receiver) {        
        // Check for property in session.
        if (portalDataProcessor.sessionHas(prop)) {
            return portalDataProcessor.sessionGet(prop);
        }
        
        return target[prop];
    }
};

/**
 * Target object used for portalData Proxy.
 */
const portalDataTarget = {
    /**
     * Proxy for portalDataProcessor.sessionBind() method.
     * 
     * @see portalDataProcessor.sessionBind()
     * @usage portalData.sessionBind(prop, prefix, initialVal);
     */
    sessionBind: function(...args) {
        return portalDataProcessor.sessionBind(...args);
    },
    
    /**
     * Proxy for portalDataProcessor.castProperty() method.
     * 
     * @see portalDataProcessor.castProperty()
     * @usage portalData.cast(prop, type);
     */
    cast: function(...args) {
        return portalDataProcessor.castProperty(...args);
    },
    
    /**
     * Proxy for portalDataProcessor.uncastProperty() method.
     * 
     * @see portalDataProcessor.uncastProperty()
     * @usage portalData.uncast(prop);
     */
    uncast: function(...args) {
        return portalDataProcessor.uncastProperty(...args);
    },
    
    /**
     * Proxy for portalDataProcessor.bindModel() method.
     * 
     * @see portalDataProcessor.bindModel()
     * @usage portalData.bindModel(elm);
     */
    bindModel: function(...args) {
        return portalDataProcessor.bindModel(...args);
    }
}

/**
 * Portal Queue Class
 */
class PortalQueue {
    constructor() {
        // Make "this" available to all our methods.
        // @see https://medium.com/@charpeni/arrow-functions-in-class-properties-might-not-be-as-great-as-we-think-3b3551c440b1
        this.refreshStack = this.refreshStack.bind(this);
        this.add = this.add.bind(this);
        this.update = this.next.bind(this);
        
        // Instance properties.
        this.index = 0; // An index that increments every time an item is added.
        this.buffer = []; // All items to process.
        this.stack = []; // Subset of the buffer to process, in reversed order, so we can use Array.pop().
        this.stackSize = 10; // Maximum amount of items allowed in the stack at any given time.
        this.busy = false; // True when busy processing events.
    }
    
    /**
     * Copies items from the buffer to the stack.
     * @return  void
     */
    refreshStack(){
        if (! this.stack.length && this.buffer.length) {
            this.stack = this.buffer.splice(0, this.stackSize);
            this.stack.reverse();
        }
    }
    
    /**
     * Add an element to the buffer.
     *
     * @param   functon func        A function to be called to process the item.
     * @param   Array args          (Optional) Arguments for func.
     * @param   function success    (Optional) Callback function if processing succeeds.
     * @param   function fail       (Optional) Callback function if processing fails.
     *
     * @return  object              The actual item in the buffer.
     */
    add(func, args = [], success = null, fail = null){
        
        // Reset the index after 1 million iterations.
        // It will probably never get this high, but if it does, don't let it increase infinitely.
        if (this.index > 1000 * 1000) {
            this.index = 0;
        }
        
        this.index++;
        
        let item = {
            index: this.index,
            func: func,
            args: args,
            success: success,
            fail: fail,
        };
        
        this.buffer.push(item);
        return item;
    }
    
    /**
     * Attempts to process the Queue's stack.
     * @param   float  timestamp    Timestamp from window.requestAnimationFrame()
     * @return  boolean             True on success, false on failure.
     */
    next(timestamp){
        let stackLength;
        let item = null;
        let result = null;
        let error = null;
        
        // Don't run this method if we're already busy running it...
        if (this.busy) {
            return false;
        }
        
        // Start processing the stack.
        if (this.stack.length) {
            this.busy = true;
            stackLength = this.stack.length;
            
            for (let i = 0; i < stackLength; i++) {
                item = this.stack.pop();
                
                if (item) {
                    try {
                        result = item.func(...item.args);
                    } catch (error) {
                        result = 'QUEUE_PROCESS_FAILED';
                    }
                    
                    if (result !== 'QUEUE_PROCESS_FAILED') {
                        if (typeof item.success === 'function') {
                            try {
                                item.success($.extend({}, item), ...item.args);
                            } catch (e) {
                                error = e;
                            }
                        }
                    } else {
                        if (typeof item.fail === 'function') {
                            try {
                                item.fail($.extend({}, item), ...item.args);
                            } catch (e) {
                                error = e;
                            }
                        }
                    }
                }
            }
        }
        
        // TODO: Better error handling.
        if (error !== null) {
            console.error(error);
        }
        
        // We're done processing.
        this.busy = false;
        return error === null;
    }
}

class PortalLoopClass {
    constructor() {
        this.queue = new PortalQueue; // The portal queue.
        this.maxAge = 100; // Maximum allowed time for busy state.
        this.startTime = null; // Start time of iteration.
        this.currentTime = null; // The current timestamp of window.requestAnimationFrame() callback.
        
        // Make "this" available to all our methods.
        // @see https://medium.com/@charpeni/arrow-functions-in-class-properties-might-not-be-as-great-as-we-think-3b3551c440b1
        this.loop = this.loop.bind(this);
    }
    
    /**
     * Find out how long the queue has been busy processing.
     * @return  float
     */
    get age() {
        return this.currentTime - this.startTime;
    }
    
    /**
     * Process the queue if it's not busy or expired.
     * @param   float timestamp     Timestamp from requestAnimationFrame()
     * @return  boolean             True on success, false when an error occurs.
     */
    loop(timestamp){
        let q = this.queue;
        let error = null;
                
        // Set the start time.
        if (! this.startTime) {
            this.startTime = timestamp;
        }
        
        // Set the current iteration time.
        this.currentTime = timestamp;
        
        // If the queue is busy and it hasn't timed out yet, line up the next loop and return.
        if (q.busy && this.age < this.maxAge) {
            window.requestAnimationFrame(portalProgressLoop);
            return true;
        }
        
        // If we get this far, we're at the start of a new loop.
        this.startTime = timestamp;
        
        // Attempt to fill queue buffer.
        if (! q.stack.length) {
            try {
                q.refreshStack();
            } catch (e) {
                // If the buffering didn't work, we'll still move onto the next iteration.
                error = e;
            }
        }
        
        // If we have something in the buffer, setup the new loop and start processing the queue (which will set it
        // to busy state).
        if (q.stack.length) {
            try {
                window.requestAnimationFrame(portalProgressLoop);
                q.next();
                return true;
            } catch (e) {
                // If the buffering didn't work, we'll still move onto the next iteration.
                error = e;
            }
        }
        
        if (error) {
            console.error(error);
        }
        
        // Setup the next iteration.
        window.requestAnimationFrame(portalProgressLoop);
        
        // Return true on success, false if an error occurred.
        return error === null;
    }
}

/**
 * Trigger a special event for two-way binding.
 */
$(document).ready(function() {
    _pd.bindModel($('[data-model]'));
});



/**
 * This function is in the global scope and is used whenever we need to run an iteration of the loop via
 * requestAnimationFrame(), because the callback must be in the global scope.
 * 
 * @param   float  timestamp    Timestamp for requestAnimationFrame()
 * @return  void
 */
function portalProgressLoop(timestamp) {
    let result = portalLoop.loop(timestamp);
}

// Instantiate the portalLoop constant.
const portalLoop = new PortalLoopClass();

// Instantiate the portalData constant.
const portalData = new Proxy(portalDataTarget, portalDataHandler);
const _pd = portalData; // Shorthand for portalData.

// Start the loop.
window.requestAnimationFrame(portalProgressLoop);

/**
 * Test if our portal is ready to be used. jQuery must be loaded first.
 * @see portalReadyCheck
 * @return  boolean
 */
window.portalReady = function() {
    if (typeof $ !== 'undefined' && typeof portalData !== 'undefined') {
        return true;
    }
    
    return false;
};

// Allow document.ready to fire after our portal scripts are loaded.
// This fires every 100 milliseconds until jQuery and the portal are both loaded.
const portalReadyCheck = setInterval(function() {
    if (window.portalReady()) {
        
        // Event will only be dispatched after the document is ready.
        $(document).ready(function() {            
            // Dispatch event.
            document.dispatchEvent(new Event('portal-ready'));
        });
        
        $.holdReady(false); // Allow $(document).ready() to fire.
        clearInterval(portalReadyCheck); // Stop this loop.
    }
}, 100);