Valid HTML test
[weblabels.fsf.org.git] / defectivebydesign.org / 20120615 / files / misc / states.js
... / ...
CommitLineData
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 */
9var states = Drupal.states = {
10 // An array of functions that should be postponed.
11 postponed: []
12};
13
14/**
15 * Attaches the states.
16 */
17Drupal.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 dependees: 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 * - dependees: An object with dependency specifications. Lists all elements
44 * that this element depends on.
45 */
46states.Dependent = function (args) {
47 $.extend(this, { values: {}, oldValue: undefined }, args);
48
49 for (var selector in this.dependees) {
50 this.initializeDependee(selector, this.dependees[selector]);
51 }
52};
53
54/**
55 * Comparison functions for comparing the value of an element with the
56 * specification from the dependency settings. If the object type can't be
57 * found in this list, the === operator is used by default.
58 */
59states.Dependent.comparisons = {
60 'RegExp': function (reference, value) {
61 return reference.test(value);
62 },
63 'Function': function (reference, value) {
64 // The "reference" variable is a comparison function.
65 return reference(value);
66 },
67 'Number': function (reference, value) {
68 // If "reference" is a number and "value" is a string, then cast reference
69 // as a string before applying the strict comparison in compare(). Otherwise
70 // numeric keys in the form's #states array fail to match string values
71 // returned from jQuery's val().
72 return (value.constructor.name === 'String') ? compare(String(reference), value) : compare(reference, value);
73 }
74};
75
76states.Dependent.prototype = {
77 /**
78 * Initializes one of the elements this dependent depends on.
79 *
80 * @param selector
81 * The CSS selector describing the dependee.
82 * @param dependeeStates
83 * The list of states that have to be monitored for tracking the
84 * dependee's compliance status.
85 */
86 initializeDependee: function (selector, dependeeStates) {
87 var self = this;
88
89 // Cache for the states of this dependee.
90 self.values[selector] = {};
91
92 $.each(dependeeStates, function (state, value) {
93 state = states.State.sanitize(state);
94
95 // Initialize the value of this state.
96 self.values[selector][state.pristine] = undefined;
97
98 // Monitor state changes of the specified state for this dependee.
99 $(selector).bind('state:' + state, function (e) {
100 var complies = self.compare(value, e.value);
101 self.update(selector, state, complies);
102 });
103
104 // Make sure the event we just bound ourselves to is actually fired.
105 new states.Trigger({ selector: selector, state: state });
106 });
107 },
108
109 /**
110 * Compares a value with a reference value.
111 *
112 * @param reference
113 * The value used for reference.
114 * @param value
115 * The value to compare with the reference value.
116 * @return
117 * true, undefined or false.
118 */
119 compare: function (reference, value) {
120 if (reference.constructor.name in states.Dependent.comparisons) {
121 // Use a custom compare function for certain reference value types.
122 return states.Dependent.comparisons[reference.constructor.name](reference, value);
123 }
124 else {
125 // Do a plain comparison otherwise.
126 return compare(reference, value);
127 }
128 },
129
130 /**
131 * Update the value of a dependee's state.
132 *
133 * @param selector
134 * CSS selector describing the dependee.
135 * @param state
136 * A State object describing the dependee's updated state.
137 * @param value
138 * The new value for the dependee's updated state.
139 */
140 update: function (selector, state, value) {
141 // Only act when the 'new' value is actually new.
142 if (value !== this.values[selector][state.pristine]) {
143 this.values[selector][state.pristine] = value;
144 this.reevaluate();
145 }
146 },
147
148 /**
149 * Triggers change events in case a state changed.
150 */
151 reevaluate: function () {
152 var value = undefined;
153
154 // Merge all individual values to find out whether this dependee complies.
155 for (var selector in this.values) {
156 for (var state in this.values[selector]) {
157 state = states.State.sanitize(state);
158 var complies = this.values[selector][state.pristine];
159 value = ternary(value, invert(complies, state.invert));
160 }
161 }
162
163 // Only invoke a state change event when the value actually changed.
164 if (value !== this.oldValue) {
165 // Store the new value so that we can compare later whether the value
166 // actually changed.
167 this.oldValue = value;
168
169 // Normalize the value to match the normalized state name.
170 value = invert(value, this.state.invert);
171
172 // By adding "trigger: true", we ensure that state changes don't go into
173 // infinite loops.
174 this.element.trigger({ type: 'state:' + this.state, value: value, trigger: true });
175 }
176 }
177};
178
179states.Trigger = function (args) {
180 $.extend(this, args);
181
182 if (this.state in states.Trigger.states) {
183 this.element = $(this.selector);
184
185 // Only call the trigger initializer when it wasn't yet attached to this
186 // element. Otherwise we'd end up with duplicate events.
187 if (!this.element.data('trigger:' + this.state)) {
188 this.initialize();
189 }
190 }
191};
192
193states.Trigger.prototype = {
194 initialize: function () {
195 var self = this;
196 var trigger = states.Trigger.states[this.state];
197
198 if (typeof trigger == 'function') {
199 // We have a custom trigger initialization function.
200 trigger.call(window, this.element);
201 }
202 else {
203 $.each(trigger, function (event, valueFn) {
204 self.defaultTrigger(event, valueFn);
205 });
206 }
207
208 // Mark this trigger as initialized for this element.
209 this.element.data('trigger:' + this.state, true);
210 },
211
212 defaultTrigger: function (event, valueFn) {
213 var self = this;
214 var oldValue = valueFn.call(this.element);
215
216 // Attach the event callback.
217 this.element.bind(event, function (e) {
218 var value = valueFn.call(self.element, e);
219 // Only trigger the event if the value has actually changed.
220 if (oldValue !== value) {
221 self.element.trigger({ type: 'state:' + self.state, value: value, oldValue: oldValue });
222 oldValue = value;
223 }
224 });
225
226 states.postponed.push(function () {
227 // Trigger the event once for initialization purposes.
228 self.element.trigger({ type: 'state:' + self.state, value: oldValue, oldValue: undefined });
229 });
230 }
231};
232
233/**
234 * This list of states contains functions that are used to monitor the state
235 * of an element. Whenever an element depends on the state of another element,
236 * one of these trigger functions is added to the dependee so that the
237 * dependent element can be updated.
238 */
239states.Trigger.states = {
240 // 'empty' describes the state to be monitored
241 empty: {
242 // 'keyup' is the (native DOM) event that we watch for.
243 'keyup': function () {
244 // The function associated to that trigger returns the new value for the
245 // state.
246 return this.val() == '';
247 }
248 },
249
250 checked: {
251 'change': function () {
252 return this.attr('checked');
253 }
254 },
255
256 // For radio buttons, only return the value if the radio button is selected.
257 value: {
258 'keyup': function () {
259 // Radio buttons share the same :input[name="key"] selector.
260 if (this.length > 1) {
261 // Initial checked value of radios is undefined, so we return false.
262 return this.filter(':checked').val() || false;
263 }
264 return this.val();
265 },
266 'change': function () {
267 // Radio buttons share the same :input[name="key"] selector.
268 if (this.length > 1) {
269 // Initial checked value of radios is undefined, so we return false.
270 return this.filter(':checked').val() || false;
271 }
272 return this.val();
273 }
274 },
275
276 collapsed: {
277 'collapsed': function(e) {
278 return (e !== undefined && 'value' in e) ? e.value : this.is('.collapsed');
279 }
280 }
281};
282
283
284/**
285 * A state object is used for describing the state and performing aliasing.
286 */
287states.State = function(state) {
288 // We may need the original unresolved name later.
289 this.pristine = this.name = state;
290
291 // Normalize the state name.
292 while (true) {
293 // Iteratively remove exclamation marks and invert the value.
294 while (this.name.charAt(0) == '!') {
295 this.name = this.name.substring(1);
296 this.invert = !this.invert;
297 }
298
299 // Replace the state with its normalized name.
300 if (this.name in states.State.aliases) {
301 this.name = states.State.aliases[this.name];
302 }
303 else {
304 break;
305 }
306 }
307};
308
309/**
310 * Create a new State object by sanitizing the passed value.
311 */
312states.State.sanitize = function (state) {
313 if (state instanceof states.State) {
314 return state;
315 }
316 else {
317 return new states.State(state);
318 }
319};
320
321/**
322 * This list of aliases is used to normalize states and associates negated names
323 * with their respective inverse state.
324 */
325states.State.aliases = {
326 'enabled': '!disabled',
327 'invisible': '!visible',
328 'invalid': '!valid',
329 'untouched': '!touched',
330 'optional': '!required',
331 'filled': '!empty',
332 'unchecked': '!checked',
333 'irrelevant': '!relevant',
334 'expanded': '!collapsed',
335 'readwrite': '!readonly'
336};
337
338states.State.prototype = {
339 invert: false,
340
341 /**
342 * Ensures that just using the state object returns the name.
343 */
344 toString: function() {
345 return this.name;
346 }
347};
348
349/**
350 * Global state change handlers. These are bound to "document" to cover all
351 * elements whose state changes. Events sent to elements within the page
352 * bubble up to these handlers. We use this system so that themes and modules
353 * can override these state change handlers for particular parts of a page.
354 */
355{
356 $(document).bind('state:disabled', function(e) {
357 // Only act when this change was triggered by a dependency and not by the
358 // element monitoring itself.
359 if (e.trigger) {
360 $(e.target)
361 .attr('disabled', e.value)
362 .filter('.form-element')
363 .closest('.form-item, .form-submit, .form-wrapper')[e.value ? 'addClass' : 'removeClass']('form-disabled');
364
365 // Note: WebKit nightlies don't reflect that change correctly.
366 // See https://bugs.webkit.org/show_bug.cgi?id=23789
367 }
368 });
369
370 $(document).bind('state:required', function(e) {
371 if (e.trigger) {
372 if (e.value) {
373 $(e.target).closest('.form-item, .form-wrapper').find('label').append('<span class="form-required">*</span>');
374 }
375 else {
376 $(e.target).closest('.form-item, .form-wrapper').find('label .form-required').remove();
377 }
378 }
379 });
380
381 $(document).bind('state:visible', function(e) {
382 if (e.trigger) {
383 $(e.target).closest('.form-item, .form-submit, .form-wrapper')[e.value ? 'show' : 'hide']();
384 }
385 });
386
387 $(document).bind('state:checked', function(e) {
388 if (e.trigger) {
389 $(e.target).attr('checked', e.value);
390 }
391 });
392
393 $(document).bind('state:collapsed', function(e) {
394 if (e.trigger) {
395 if ($(e.target).is('.collapsed') !== e.value) {
396 $('> legend a', e.target).click();
397 }
398 }
399 });
400}
401
402/**
403 * These are helper functions implementing addition "operators" and don't
404 * implement any logic that is particular to states.
405 */
406{
407 // Bitwise AND with a third undefined state.
408 function ternary (a, b) {
409 return a === undefined ? b : (b === undefined ? a : a && b);
410 };
411
412 // Inverts a (if it's not undefined) when invert is true.
413 function invert (a, invert) {
414 return (invert && a !== undefined) ? !a : a;
415 };
416
417 // Compares two values while ignoring undefined values.
418 function compare (a, b) {
419 return (a === b) ? (a === undefined ? a : true) : (a === undefined || b === undefined);
420 }
421}
422
423})(jQuery);