Commit | Line | Data |
---|---|---|
5a920362 | 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 () { | |
7f254ad8 | 376 | return this.is(':checked'); |
5a920362 | 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) { | |
7f254ad8 AE |
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 | } | |
5a920362 | 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); |