Projet

Général

Profil

Paste
Télécharger (16,9 ko) Statistiques
| Branche: | Révision:

root / htmltest / misc / states.js @ 85ad3d82

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