|
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); |