(function ($) { /** * The base States namespace. * * Having the local states variable allows us to use the States namespace * without having to always declare "Drupal.states". */ var states = Drupal.states = { // An array of functions that should be postponed. postponed: [] }; /** * Attaches the states. */ Drupal.behaviors.states = { attach: function (context, settings) { for (var selector in settings.states) { for (var state in settings.states[selector]) { new states.Dependent({ element: $(selector), state: states.State.sanitize(state), dependees: settings.states[selector][state] }); } } // Execute all postponed functions now. while (states.postponed.length) { (states.postponed.shift())(); } } }; /** * Object representing an element that depends on other elements. * * @param args * Object with the following keys (all of which are required): * - element: A jQuery object of the dependent element * - state: A State object describing the state that is dependent * - dependees: An object with dependency specifications. Lists all elements * that this element depends on. */ states.Dependent = function (args) { $.extend(this, { values: {}, oldValue: undefined }, args); for (var selector in this.dependees) { this.initializeDependee(selector, this.dependees[selector]); } }; /** * Comparison functions for comparing the value of an element with the * specification from the dependency settings. If the object type can't be * found in this list, the === operator is used by default. */ states.Dependent.comparisons = { 'RegExp': function (reference, value) { return reference.test(value); }, 'Function': function (reference, value) { // The "reference" variable is a comparison function. return reference(value); } }; states.Dependent.prototype = { /** * Initializes one of the elements this dependent depends on. * * @param selector * The CSS selector describing the dependee. * @param dependeeStates * The list of states that have to be monitored for tracking the * dependee's compliance status. */ initializeDependee: function (selector, dependeeStates) { var self = this; // Cache for the states of this dependee. self.values[selector] = {}; $.each(dependeeStates, function (state, value) { state = states.State.sanitize(state); // Initialize the value of this state. self.values[selector][state.pristine] = undefined; // Monitor state changes of the specified state for this dependee. $(selector).bind('state:' + state, function (e) { var complies = self.compare(value, e.value); self.update(selector, state, complies); }); // Make sure the event we just bound ourselves to is actually fired. new states.Trigger({ selector: selector, state: state }); }); }, /** * Compares a value with a reference value. * * @param reference * The value used for reference. * @param value * The value to compare with the reference value. * @return * true, undefined or false. */ compare: function (reference, value) { if (reference.constructor.name in states.Dependent.comparisons) { // Use a custom compare function for certain reference value types. return states.Dependent.comparisons[reference.constructor.name](reference, value); } else { // Do a plain comparison otherwise. return compare(reference, value); } }, /** * Update the value of a dependee's state. * * @param selector * CSS selector describing the dependee. * @param state * A State object describing the dependee's updated state. * @param value * The new value for the dependee's updated state. */ update: function (selector, state, value) { // Only act when the 'new' value is actually new. if (value !== this.values[selector][state.pristine]) { this.values[selector][state.pristine] = value; this.reevaluate(); } }, /** * Triggers change events in case a state changed. */ reevaluate: function () { var value = undefined; // Merge all individual values to find out whether this dependee complies. for (var selector in this.values) { for (var state in this.values[selector]) { state = states.State.sanitize(state); var complies = this.values[selector][state.pristine]; value = ternary(value, invert(complies, state.invert)); } } // Only invoke a state change event when the value actually changed. if (value !== this.oldValue) { // Store the new value so that we can compare later whether the value // actually changed. this.oldValue = value; // Normalize the value to match the normalized state name. value = invert(value, this.state.invert); // By adding "trigger: true", we ensure that state changes don't go into // infinite loops. this.element.trigger({ type: 'state:' + this.state, value: value, trigger: true }); } } }; states.Trigger = function (args) { $.extend(this, args); if (this.state in states.Trigger.states) { this.element = $(this.selector); // Only call the trigger initializer when it wasn't yet attached to this // element. Otherwise we'd end up with duplicate events. if (!this.element.data('trigger:' + this.state)) { this.initialize(); } } }; states.Trigger.prototype = { initialize: function () { var self = this; var trigger = states.Trigger.states[this.state]; if (typeof trigger == 'function') { // We have a custom trigger initialization function. trigger.call(window, this.element); } else { $.each(trigger, function (event, valueFn) { self.defaultTrigger(event, valueFn); }); } // Mark this trigger as initialized for this element. this.element.data('trigger:' + this.state, true); }, defaultTrigger: function (event, valueFn) { var self = this; var oldValue = valueFn.call(this.element); // Attach the event callback. this.element.bind(event, function (e) { var value = valueFn.call(self.element, e); // Only trigger the event if the value has actually changed. if (oldValue !== value) { self.element.trigger({ type: 'state:' + self.state, value: value, oldValue: oldValue }); oldValue = value; } }); states.postponed.push(function () { // Trigger the event once for initialization purposes. self.element.trigger({ type: 'state:' + self.state, value: oldValue, oldValue: undefined }); }); } }; /** * This list of states contains functions that are used to monitor the state * of an element. Whenever an element depends on the state of another element, * one of these trigger functions is added to the dependee so that the * dependent element can be updated. */ states.Trigger.states = { // 'empty' describes the state to be monitored empty: { // 'keyup' is the (native DOM) event that we watch for. 'keyup': function () { // The function associated to that trigger returns the new value for the // state. return this.val() == ''; } }, checked: { 'change': function () { return this.attr('checked'); } }, // For radio buttons, only return the value if the radio button is selected. value: { 'keyup': function () { // Radio buttons share the same :input[name="key"] selector. if (this.length > 1) { // Initial checked value of radios is undefined, so we return false. return this.filter(':checked').val() || false; } return this.val(); }, 'change': function () { // Radio buttons share the same :input[name="key"] selector. if (this.length > 1) { // Initial checked value of radios is undefined, so we return false. return this.filter(':checked').val() || false; } return this.val(); } }, collapsed: { 'collapsed': function(e) { return (e !== undefined && 'value' in e) ? e.value : this.is('.collapsed'); } } }; /** * A state object is used for describing the state and performing aliasing. */ states.State = function(state) { // We may need the original unresolved name later. this.pristine = this.name = state; // Normalize the state name. while (true) { // Iteratively remove exclamation marks and invert the value. while (this.name.charAt(0) == '!') { this.name = this.name.substring(1); this.invert = !this.invert; } // Replace the state with its normalized name. if (this.name in states.State.aliases) { this.name = states.State.aliases[this.name]; } else { break; } } }; /** * Create a new State object by sanitizing the passed value. */ states.State.sanitize = function (state) { if (state instanceof states.State) { return state; } else { return new states.State(state); } }; /** * This list of aliases is used to normalize states and associates negated names * with their respective inverse state. */ states.State.aliases = { 'enabled': '!disabled', 'invisible': '!visible', 'invalid': '!valid', 'untouched': '!touched', 'optional': '!required', 'filled': '!empty', 'unchecked': '!checked', 'irrelevant': '!relevant', 'expanded': '!collapsed', 'readwrite': '!readonly' }; states.State.prototype = { invert: false, /** * Ensures that just using the state object returns the name. */ toString: function() { return this.name; } }; /** * Global state change handlers. These are bound to "document" to cover all * elements whose state changes. Events sent to elements within the page * bubble up to these handlers. We use this system so that themes and modules * can override these state change handlers for particular parts of a page. */ { $(document).bind('state:disabled', function(e) { // Only act when this change was triggered by a dependency and not by the // element monitoring itself. if (e.trigger) { $(e.target) .attr('disabled', e.value) .filter('.form-element') .closest('.form-item, .form-submit, .form-wrapper')[e.value ? 'addClass' : 'removeClass']('form-disabled'); // Note: WebKit nightlies don't reflect that change correctly. // See https://bugs.webkit.org/show_bug.cgi?id=23789 } }); $(document).bind('state:required', function(e) { if (e.trigger) { if (e.value) { $(e.target).closest('.form-item, .form-wrapper').find('label').append('*'); } else { $(e.target).closest('.form-item, .form-wrapper').find('label .form-required').remove(); } } }); $(document).bind('state:visible', function(e) { if (e.trigger) { $(e.target).closest('.form-item, .form-submit, .form-wrapper')[e.value ? 'show' : 'hide'](); } }); $(document).bind('state:checked', function(e) { if (e.trigger) { $(e.target).attr('checked', e.value); } }); $(document).bind('state:collapsed', function(e) { if (e.trigger) { if ($(e.target).is('.collapsed') !== e.value) { $('> legend a', e.target).click(); } } }); } /** * These are helper functions implementing addition "operators" and don't * implement any logic that is particular to states. */ { // Bitwise AND with a third undefined state. function ternary (a, b) { return a === undefined ? b : (b === undefined ? a : a && b); }; // Inverts a (if it's not undefined) when invert is true. function invert (a, invert) { return (invert && a !== undefined) ? !a : a; }; // Compares two values while ignoring undefined values. function compare (a, b) { return (a === b) ? (a === undefined ? a : true) : (a === undefined || b === undefined); } } })(jQuery);