root / drupal7 / misc / states.js @ 76597ebf
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 |
$(e.target).closest('.form-item, .form-wrapper').find('label').append('<span class="form-required">*</span>'); |
497 |
} |
498 |
else {
|
499 |
$(e.target).closest('.form-item, .form-wrapper').find('label .form-required').remove(); |
500 |
} |
501 |
} |
502 |
}); |
503 |
|
504 |
$(document).bind('state:visible', function(e) { |
505 |
if (e.trigger) {
|
506 |
$(e.target).closest('.form-item, .form-submit, .form-wrapper').toggle(e.value); |
507 |
} |
508 |
}); |
509 |
|
510 |
$(document).bind('state:checked', function(e) { |
511 |
if (e.trigger) {
|
512 |
$(e.target).attr('checked', e.value); |
513 |
} |
514 |
}); |
515 |
|
516 |
$(document).bind('state:collapsed', function(e) { |
517 |
if (e.trigger) {
|
518 |
if ($(e.target).is('.collapsed') !== e.value) { |
519 |
$('> legend a', e.target).click(); |
520 |
} |
521 |
} |
522 |
}); |
523 |
|
524 |
/**
|
525 |
* These are helper functions implementing addition "operators" and don't
|
526 |
* implement any logic that is particular to states.
|
527 |
*/
|
528 |
|
529 |
// Bitwise AND with a third undefined state.
|
530 |
function ternary (a, b) { |
531 |
return typeof a === 'undefined' ? b : (typeof b === 'undefined' ? a : a && b); |
532 |
} |
533 |
|
534 |
// Inverts a (if it's not undefined) when invert is true.
|
535 |
function invert (a, invert) { |
536 |
return (invert && typeof a !== 'undefined') ? !a : a; |
537 |
} |
538 |
|
539 |
// Compares two values while ignoring undefined values.
|
540 |
function compare (a, b) { |
541 |
return (a === b) ? (typeof a === 'undefined' ? a : true) : (typeof a === 'undefined' || typeof b === 'undefined'); |
542 |
} |
543 |
|
544 |
})(jQuery); |