commiting uncommited changes on live site
[weblabels.fsf.org.git] / crm.fsf.org / 20131203 / files / 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 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);