cms/drupal/misc/states.js
changeset 541 e756a8c72c3d
equal deleted inserted replaced
540:07239de796bb 541:e756a8c72c3d
       
     1 (function ($) {
       
     2 
       
     3 /**
       
     4  * The base States namespace.
       
     5  *
       
     6  * Having the local states variable allows us to use the States namespace
       
     7  * without having to always declare "Drupal.states".
       
     8  */
       
     9 var states = Drupal.states = {
       
    10   // An array of functions that should be postponed.
       
    11   postponed: []
       
    12 };
       
    13 
       
    14 /**
       
    15  * Attaches the states.
       
    16  */
       
    17 Drupal.behaviors.states = {
       
    18   attach: function (context, settings) {
       
    19     var $context = $(context);
       
    20     for (var selector in settings.states) {
       
    21       for (var state in settings.states[selector]) {
       
    22         new states.Dependent({
       
    23           element: $context.find(selector),
       
    24           state: states.State.sanitize(state),
       
    25           constraints: settings.states[selector][state]
       
    26         });
       
    27       }
       
    28     }
       
    29 
       
    30     // Execute all postponed functions now.
       
    31     while (states.postponed.length) {
       
    32       (states.postponed.shift())();
       
    33     }
       
    34   }
       
    35 };
       
    36 
       
    37 /**
       
    38  * Object representing an element that depends on other elements.
       
    39  *
       
    40  * @param args
       
    41  *   Object with the following keys (all of which are required):
       
    42  *   - element: A jQuery object of the dependent element
       
    43  *   - state: A State object describing the state that is dependent
       
    44  *   - constraints: An object with dependency specifications. Lists all elements
       
    45  *     that this element depends on. It can be nested and can contain arbitrary
       
    46  *     AND and OR clauses.
       
    47  */
       
    48 states.Dependent = function (args) {
       
    49   $.extend(this, { values: {}, oldValue: null }, args);
       
    50 
       
    51   this.dependees = this.getDependees();
       
    52   for (var selector in this.dependees) {
       
    53     this.initializeDependee(selector, this.dependees[selector]);
       
    54   }
       
    55 };
       
    56 
       
    57 /**
       
    58  * Comparison functions for comparing the value of an element with the
       
    59  * specification from the dependency settings. If the object type can't be
       
    60  * found in this list, the === operator is used by default.
       
    61  */
       
    62 states.Dependent.comparisons = {
       
    63   'RegExp': function (reference, value) {
       
    64     return reference.test(value);
       
    65   },
       
    66   'Function': function (reference, value) {
       
    67     // The "reference" variable is a comparison function.
       
    68     return reference(value);
       
    69   },
       
    70   'Number': function (reference, value) {
       
    71     // If "reference" is a number and "value" is a string, then cast reference
       
    72     // as a string before applying the strict comparison in compare(). Otherwise
       
    73     // numeric keys in the form's #states array fail to match string values
       
    74     // returned from jQuery's val().
       
    75     return (typeof value === 'string') ? compare(reference.toString(), value) : compare(reference, value);
       
    76   }
       
    77 };
       
    78 
       
    79 states.Dependent.prototype = {
       
    80   /**
       
    81    * Initializes one of the elements this dependent depends on.
       
    82    *
       
    83    * @param selector
       
    84    *   The CSS selector describing the dependee.
       
    85    * @param dependeeStates
       
    86    *   The list of states that have to be monitored for tracking the
       
    87    *   dependee's compliance status.
       
    88    */
       
    89   initializeDependee: function (selector, dependeeStates) {
       
    90     var state;
       
    91 
       
    92     // Cache for the states of this dependee.
       
    93     this.values[selector] = {};
       
    94 
       
    95     for (var i in dependeeStates) {
       
    96       if (dependeeStates.hasOwnProperty(i)) {
       
    97         state = dependeeStates[i];
       
    98         // Make sure we're not initializing this selector/state combination twice.
       
    99         if ($.inArray(state, dependeeStates) === -1) {
       
   100           continue;
       
   101         }
       
   102 
       
   103         state = states.State.sanitize(state);
       
   104 
       
   105         // Initialize the value of this state.
       
   106         this.values[selector][state.name] = null;
       
   107 
       
   108         // Monitor state changes of the specified state for this dependee.
       
   109         $(selector).bind('state:' + state, $.proxy(function (e) {
       
   110           this.update(selector, state, e.value);
       
   111         }, this));
       
   112 
       
   113         // Make sure the event we just bound ourselves to is actually fired.
       
   114         new states.Trigger({ selector: selector, state: state });
       
   115       }
       
   116     }
       
   117   },
       
   118 
       
   119   /**
       
   120    * Compares a value with a reference value.
       
   121    *
       
   122    * @param reference
       
   123    *   The value used for reference.
       
   124    * @param selector
       
   125    *   CSS selector describing the dependee.
       
   126    * @param state
       
   127    *   A State object describing the dependee's updated state.
       
   128    *
       
   129    * @return
       
   130    *   true or false.
       
   131    */
       
   132   compare: function (reference, selector, state) {
       
   133     var value = this.values[selector][state.name];
       
   134     if (reference.constructor.name in states.Dependent.comparisons) {
       
   135       // Use a custom compare function for certain reference value types.
       
   136       return states.Dependent.comparisons[reference.constructor.name](reference, value);
       
   137     }
       
   138     else {
       
   139       // Do a plain comparison otherwise.
       
   140       return compare(reference, value);
       
   141     }
       
   142   },
       
   143 
       
   144   /**
       
   145    * Update the value of a dependee's state.
       
   146    *
       
   147    * @param selector
       
   148    *   CSS selector describing the dependee.
       
   149    * @param state
       
   150    *   A State object describing the dependee's updated state.
       
   151    * @param value
       
   152    *   The new value for the dependee's updated state.
       
   153    */
       
   154   update: function (selector, state, value) {
       
   155     // Only act when the 'new' value is actually new.
       
   156     if (value !== this.values[selector][state.name]) {
       
   157       this.values[selector][state.name] = value;
       
   158       this.reevaluate();
       
   159     }
       
   160   },
       
   161 
       
   162   /**
       
   163    * Triggers change events in case a state changed.
       
   164    */
       
   165   reevaluate: function () {
       
   166     // Check whether any constraint for this dependent state is satisifed.
       
   167     var value = this.verifyConstraints(this.constraints);
       
   168 
       
   169     // Only invoke a state change event when the value actually changed.
       
   170     if (value !== this.oldValue) {
       
   171       // Store the new value so that we can compare later whether the value
       
   172       // actually changed.
       
   173       this.oldValue = value;
       
   174 
       
   175       // Normalize the value to match the normalized state name.
       
   176       value = invert(value, this.state.invert);
       
   177 
       
   178       // By adding "trigger: true", we ensure that state changes don't go into
       
   179       // infinite loops.
       
   180       this.element.trigger({ type: 'state:' + this.state, value: value, trigger: true });
       
   181     }
       
   182   },
       
   183 
       
   184   /**
       
   185    * Evaluates child constraints to determine if a constraint is satisfied.
       
   186    *
       
   187    * @param constraints
       
   188    *   A constraint object or an array of constraints.
       
   189    * @param selector
       
   190    *   The selector for these constraints. If undefined, there isn't yet a
       
   191    *   selector that these constraints apply to. In that case, the keys of the
       
   192    *   object are interpreted as the selector if encountered.
       
   193    *
       
   194    * @return
       
   195    *   true or false, depending on whether these constraints are satisfied.
       
   196    */
       
   197   verifyConstraints: function(constraints, selector) {
       
   198     var result;
       
   199     if ($.isArray(constraints)) {
       
   200       // This constraint is an array (OR or XOR).
       
   201       var hasXor = $.inArray('xor', constraints) === -1;
       
   202       for (var i = 0, len = constraints.length; i < len; i++) {
       
   203         if (constraints[i] != 'xor') {
       
   204           var constraint = this.checkConstraints(constraints[i], selector, i);
       
   205           // Return if this is OR and we have a satisfied constraint or if this
       
   206           // is XOR and we have a second satisfied constraint.
       
   207           if (constraint && (hasXor || result)) {
       
   208             return hasXor;
       
   209           }
       
   210           result = result || constraint;
       
   211         }
       
   212       }
       
   213     }
       
   214     // Make sure we don't try to iterate over things other than objects. This
       
   215     // shouldn't normally occur, but in case the condition definition is bogus,
       
   216     // we don't want to end up with an infinite loop.
       
   217     else if ($.isPlainObject(constraints)) {
       
   218       // This constraint is an object (AND).
       
   219       for (var n in constraints) {
       
   220         if (constraints.hasOwnProperty(n)) {
       
   221           result = ternary(result, this.checkConstraints(constraints[n], selector, n));
       
   222           // False and anything else will evaluate to false, so return when any
       
   223           // false condition is found.
       
   224           if (result === false) { return false; }
       
   225         }
       
   226       }
       
   227     }
       
   228     return result;
       
   229   },
       
   230 
       
   231   /**
       
   232    * Checks whether the value matches the requirements for this constraint.
       
   233    *
       
   234    * @param value
       
   235    *   Either the value of a state or an array/object of constraints. In the
       
   236    *   latter case, resolving the constraint continues.
       
   237    * @param selector
       
   238    *   The selector for this constraint. If undefined, there isn't yet a
       
   239    *   selector that this constraint applies to. In that case, the state key is
       
   240    *   propagates to a selector and resolving continues.
       
   241    * @param state
       
   242    *   The state to check for this constraint. If undefined, resolving
       
   243    *   continues.
       
   244    *   If both selector and state aren't undefined and valid non-numeric
       
   245    *   strings, a lookup for the actual value of that selector's state is
       
   246    *   performed. This parameter is not a State object but a pristine state
       
   247    *   string.
       
   248    *
       
   249    * @return
       
   250    *   true or false, depending on whether this constraint is satisfied.
       
   251    */
       
   252   checkConstraints: function(value, selector, state) {
       
   253     // Normalize the last parameter. If it's non-numeric, we treat it either as
       
   254     // a selector (in case there isn't one yet) or as a trigger/state.
       
   255     if (typeof state !== 'string' || (/[0-9]/).test(state[0])) {
       
   256       state = null;
       
   257     }
       
   258     else if (typeof selector === 'undefined') {
       
   259       // Propagate the state to the selector when there isn't one yet.
       
   260       selector = state;
       
   261       state = null;
       
   262     }
       
   263 
       
   264     if (state !== null) {
       
   265       // constraints is the actual constraints of an element to check for.
       
   266       state = states.State.sanitize(state);
       
   267       return invert(this.compare(value, selector, state), state.invert);
       
   268     }
       
   269     else {
       
   270       // Resolve this constraint as an AND/OR operator.
       
   271       return this.verifyConstraints(value, selector);
       
   272     }
       
   273   },
       
   274 
       
   275   /**
       
   276    * Gathers information about all required triggers.
       
   277    */
       
   278   getDependees: function() {
       
   279     var cache = {};
       
   280     // Swivel the lookup function so that we can record all available selector-
       
   281     // state combinations for initialization.
       
   282     var _compare = this.compare;
       
   283     this.compare = function(reference, selector, state) {
       
   284       (cache[selector] || (cache[selector] = [])).push(state.name);
       
   285       // Return nothing (=== undefined) so that the constraint loops are not
       
   286       // broken.
       
   287     };
       
   288 
       
   289     // This call doesn't actually verify anything but uses the resolving
       
   290     // mechanism to go through the constraints array, trying to look up each
       
   291     // value. Since we swivelled the compare function, this comparison returns
       
   292     // undefined and lookup continues until the very end. Instead of lookup up
       
   293     // the value, we record that combination of selector and state so that we
       
   294     // can initialize all triggers.
       
   295     this.verifyConstraints(this.constraints);
       
   296     // Restore the original function.
       
   297     this.compare = _compare;
       
   298 
       
   299     return cache;
       
   300   }
       
   301 };
       
   302 
       
   303 states.Trigger = function (args) {
       
   304   $.extend(this, args);
       
   305 
       
   306   if (this.state in states.Trigger.states) {
       
   307     this.element = $(this.selector);
       
   308 
       
   309     // Only call the trigger initializer when it wasn't yet attached to this
       
   310     // element. Otherwise we'd end up with duplicate events.
       
   311     if (!this.element.data('trigger:' + this.state)) {
       
   312       this.initialize();
       
   313     }
       
   314   }
       
   315 };
       
   316 
       
   317 states.Trigger.prototype = {
       
   318   initialize: function () {
       
   319     var trigger = states.Trigger.states[this.state];
       
   320 
       
   321     if (typeof trigger == 'function') {
       
   322       // We have a custom trigger initialization function.
       
   323       trigger.call(window, this.element);
       
   324     }
       
   325     else {
       
   326       for (var event in trigger) {
       
   327         if (trigger.hasOwnProperty(event)) {
       
   328           this.defaultTrigger(event, trigger[event]);
       
   329         }
       
   330       }
       
   331     }
       
   332 
       
   333     // Mark this trigger as initialized for this element.
       
   334     this.element.data('trigger:' + this.state, true);
       
   335   },
       
   336 
       
   337   defaultTrigger: function (event, valueFn) {
       
   338     var oldValue = valueFn.call(this.element);
       
   339 
       
   340     // Attach the event callback.
       
   341     this.element.bind(event, $.proxy(function (e) {
       
   342       var value = valueFn.call(this.element, e);
       
   343       // Only trigger the event if the value has actually changed.
       
   344       if (oldValue !== value) {
       
   345         this.element.trigger({ type: 'state:' + this.state, value: value, oldValue: oldValue });
       
   346         oldValue = value;
       
   347       }
       
   348     }, this));
       
   349 
       
   350     states.postponed.push($.proxy(function () {
       
   351       // Trigger the event once for initialization purposes.
       
   352       this.element.trigger({ type: 'state:' + this.state, value: oldValue, oldValue: null });
       
   353     }, this));
       
   354   }
       
   355 };
       
   356 
       
   357 /**
       
   358  * This list of states contains functions that are used to monitor the state
       
   359  * of an element. Whenever an element depends on the state of another element,
       
   360  * one of these trigger functions is added to the dependee so that the
       
   361  * dependent element can be updated.
       
   362  */
       
   363 states.Trigger.states = {
       
   364   // 'empty' describes the state to be monitored
       
   365   empty: {
       
   366     // 'keyup' is the (native DOM) event that we watch for.
       
   367     'keyup': function () {
       
   368       // The function associated to that trigger returns the new value for the
       
   369       // state.
       
   370       return this.val() == '';
       
   371     }
       
   372   },
       
   373 
       
   374   checked: {
       
   375     'change': function () {
       
   376       return this.is(':checked');
       
   377     }
       
   378   },
       
   379 
       
   380   // For radio buttons, only return the value if the radio button is selected.
       
   381   value: {
       
   382     'keyup': function () {
       
   383       // Radio buttons share the same :input[name="key"] selector.
       
   384       if (this.length > 1) {
       
   385         // Initial checked value of radios is undefined, so we return false.
       
   386         return this.filter(':checked').val() || false;
       
   387       }
       
   388       return this.val();
       
   389     },
       
   390     'change': function () {
       
   391       // Radio buttons share the same :input[name="key"] selector.
       
   392       if (this.length > 1) {
       
   393         // Initial checked value of radios is undefined, so we return false.
       
   394         return this.filter(':checked').val() || false;
       
   395       }
       
   396       return this.val();
       
   397     }
       
   398   },
       
   399 
       
   400   collapsed: {
       
   401     'collapsed': function(e) {
       
   402       return (typeof e !== 'undefined' && 'value' in e) ? e.value : this.is('.collapsed');
       
   403     }
       
   404   }
       
   405 };
       
   406 
       
   407 
       
   408 /**
       
   409  * A state object is used for describing the state and performing aliasing.
       
   410  */
       
   411 states.State = function(state) {
       
   412   // We may need the original unresolved name later.
       
   413   this.pristine = this.name = state;
       
   414 
       
   415   // Normalize the state name.
       
   416   while (true) {
       
   417     // Iteratively remove exclamation marks and invert the value.
       
   418     while (this.name.charAt(0) == '!') {
       
   419       this.name = this.name.substring(1);
       
   420       this.invert = !this.invert;
       
   421     }
       
   422 
       
   423     // Replace the state with its normalized name.
       
   424     if (this.name in states.State.aliases) {
       
   425       this.name = states.State.aliases[this.name];
       
   426     }
       
   427     else {
       
   428       break;
       
   429     }
       
   430   }
       
   431 };
       
   432 
       
   433 /**
       
   434  * Creates a new State object by sanitizing the passed value.
       
   435  */
       
   436 states.State.sanitize = function (state) {
       
   437   if (state instanceof states.State) {
       
   438     return state;
       
   439   }
       
   440   else {
       
   441     return new states.State(state);
       
   442   }
       
   443 };
       
   444 
       
   445 /**
       
   446  * This list of aliases is used to normalize states and associates negated names
       
   447  * with their respective inverse state.
       
   448  */
       
   449 states.State.aliases = {
       
   450   'enabled': '!disabled',
       
   451   'invisible': '!visible',
       
   452   'invalid': '!valid',
       
   453   'untouched': '!touched',
       
   454   'optional': '!required',
       
   455   'filled': '!empty',
       
   456   'unchecked': '!checked',
       
   457   'irrelevant': '!relevant',
       
   458   'expanded': '!collapsed',
       
   459   'readwrite': '!readonly'
       
   460 };
       
   461 
       
   462 states.State.prototype = {
       
   463   invert: false,
       
   464 
       
   465   /**
       
   466    * Ensures that just using the state object returns the name.
       
   467    */
       
   468   toString: function() {
       
   469     return this.name;
       
   470   }
       
   471 };
       
   472 
       
   473 /**
       
   474  * Global state change handlers. These are bound to "document" to cover all
       
   475  * elements whose state changes. Events sent to elements within the page
       
   476  * bubble up to these handlers. We use this system so that themes and modules
       
   477  * can override these state change handlers for particular parts of a page.
       
   478  */
       
   479 $(document).bind('state:disabled', function(e) {
       
   480   // Only act when this change was triggered by a dependency and not by the
       
   481   // element monitoring itself.
       
   482   if (e.trigger) {
       
   483     $(e.target)
       
   484       .attr('disabled', e.value)
       
   485         .closest('.form-item, .form-submit, .form-wrapper').toggleClass('form-disabled', e.value)
       
   486         .find('select, input, textarea').attr('disabled', e.value);
       
   487 
       
   488     // Note: WebKit nightlies don't reflect that change correctly.
       
   489     // See https://bugs.webkit.org/show_bug.cgi?id=23789
       
   490   }
       
   491 });
       
   492 
       
   493 $(document).bind('state:required', function(e) {
       
   494   if (e.trigger) {
       
   495     if (e.value) {
       
   496       var $label = $(e.target).closest('.form-item, .form-wrapper').find('label');
       
   497       // Avoids duplicate required markers on initialization.
       
   498       if (!$label.find('.form-required').length) {
       
   499         $label.append('<span class="form-required">*</span>');
       
   500       }
       
   501     }
       
   502     else {
       
   503       $(e.target).closest('.form-item, .form-wrapper').find('label .form-required').remove();
       
   504     }
       
   505   }
       
   506 });
       
   507 
       
   508 $(document).bind('state:visible', function(e) {
       
   509   if (e.trigger) {
       
   510       $(e.target).closest('.form-item, .form-submit, .form-wrapper').toggle(e.value);
       
   511   }
       
   512 });
       
   513 
       
   514 $(document).bind('state:checked', function(e) {
       
   515   if (e.trigger) {
       
   516     $(e.target).attr('checked', e.value);
       
   517   }
       
   518 });
       
   519 
       
   520 $(document).bind('state:collapsed', function(e) {
       
   521   if (e.trigger) {
       
   522     if ($(e.target).is('.collapsed') !== e.value) {
       
   523       $('> legend a', e.target).click();
       
   524     }
       
   525   }
       
   526 });
       
   527 
       
   528 /**
       
   529  * These are helper functions implementing addition "operators" and don't
       
   530  * implement any logic that is particular to states.
       
   531  */
       
   532 
       
   533 // Bitwise AND with a third undefined state.
       
   534 function ternary (a, b) {
       
   535   return typeof a === 'undefined' ? b : (typeof b === 'undefined' ? a : a && b);
       
   536 }
       
   537 
       
   538 // Inverts a (if it's not undefined) when invert is true.
       
   539 function invert (a, invert) {
       
   540   return (invert && typeof a !== 'undefined') ? !a : a;
       
   541 }
       
   542 
       
   543 // Compares two values while ignoring undefined values.
       
   544 function compare (a, b) {
       
   545   return (a === b) ? (typeof a === 'undefined' ? a : true) : (typeof a === 'undefined' || typeof b === 'undefined');
       
   546 }
       
   547 
       
   548 })(jQuery);