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