811a5112d1746cbc3bd65608cf54e56f12dc0e2d
[yaffs-website] / web / core / misc / states.es6.js
1 /**
2  * @file
3  * Drupal's states library.
4  */
5
6 (function($, Drupal) {
7   /**
8    * The base States namespace.
9    *
10    * Having the local states variable allows us to use the States namespace
11    * without having to always declare "Drupal.states".
12    *
13    * @namespace Drupal.states
14    */
15   const states = {
16     /**
17      * An array of functions that should be postponed.
18      */
19     postponed: [],
20   };
21
22   Drupal.states = states;
23
24   /**
25    * Inverts a (if it's not undefined) when invertState is true.
26    *
27    * @function Drupal.states~invert
28    *
29    * @param {*} a
30    *   The value to maybe invert.
31    * @param {bool} invertState
32    *   Whether to invert state or not.
33    *
34    * @return {bool}
35    *   The result.
36    */
37   function invert(a, invertState) {
38     return invertState && typeof a !== 'undefined' ? !a : a;
39   }
40
41   /**
42    * Compares two values while ignoring undefined values.
43    *
44    * @function Drupal.states~compare
45    *
46    * @param {*} a
47    *   Value a.
48    * @param {*} b
49    *   Value b.
50    *
51    * @return {bool}
52    *   The comparison result.
53    */
54   function compare(a, b) {
55     if (a === b) {
56       return typeof a === 'undefined' ? a : true;
57     }
58
59     return typeof a === 'undefined' || typeof b === 'undefined';
60   }
61
62   /**
63    * Bitwise AND with a third undefined state.
64    *
65    * @function Drupal.states~ternary
66    *
67    * @param {*} a
68    *   Value a.
69    * @param {*} b
70    *   Value b
71    *
72    * @return {bool}
73    *   The result.
74    */
75   function ternary(a, b) {
76     if (typeof a === 'undefined') {
77       return b;
78     }
79     if (typeof b === 'undefined') {
80       return a;
81     }
82
83     return a && b;
84   }
85
86   /**
87    * Attaches the states.
88    *
89    * @type {Drupal~behavior}
90    *
91    * @prop {Drupal~behaviorAttach} attach
92    *   Attaches states behaviors.
93    */
94   Drupal.behaviors.states = {
95     attach(context, settings) {
96       const $states = $(context).find('[data-drupal-states]');
97       const il = $states.length;
98       for (let i = 0; i < il; i++) {
99         const config = JSON.parse(
100           $states[i].getAttribute('data-drupal-states'),
101         );
102         Object.keys(config || {}).forEach(state => {
103           new states.Dependent({
104             element: $($states[i]),
105             state: states.State.sanitize(state),
106             constraints: config[state],
107           });
108         });
109       }
110
111       // Execute all postponed functions now.
112       while (states.postponed.length) {
113         states.postponed.shift()();
114       }
115     },
116   };
117
118   /**
119    * Object representing an element that depends on other elements.
120    *
121    * @constructor Drupal.states.Dependent
122    *
123    * @param {object} args
124    *   Object with the following keys (all of which are required)
125    * @param {jQuery} args.element
126    *   A jQuery object of the dependent element
127    * @param {Drupal.states.State} args.state
128    *   A State object describing the state that is dependent
129    * @param {object} args.constraints
130    *   An object with dependency specifications. Lists all elements that this
131    *   element depends on. It can be nested and can contain
132    *   arbitrary AND and OR clauses.
133    */
134   states.Dependent = function(args) {
135     $.extend(this, { values: {}, oldValue: null }, args);
136
137     this.dependees = this.getDependees();
138     Object.keys(this.dependees || {}).forEach(selector => {
139       this.initializeDependee(selector, this.dependees[selector]);
140     });
141   };
142
143   /**
144    * Comparison functions for comparing the value of an element with the
145    * specification from the dependency settings. If the object type can't be
146    * found in this list, the === operator is used by default.
147    *
148    * @name Drupal.states.Dependent.comparisons
149    *
150    * @prop {function} RegExp
151    * @prop {function} Function
152    * @prop {function} Number
153    */
154   states.Dependent.comparisons = {
155     RegExp(reference, value) {
156       return reference.test(value);
157     },
158     Function(reference, value) {
159       // The "reference" variable is a comparison function.
160       return reference(value);
161     },
162     Number(reference, value) {
163       // If "reference" is a number and "value" is a string, then cast
164       // reference as a string before applying the strict comparison in
165       // compare().
166       // Otherwise numeric keys in the form's #states array fail to match
167       // string values returned from jQuery's val().
168       return typeof value === 'string'
169         ? compare(reference.toString(), value)
170         : compare(reference, value);
171     },
172   };
173
174   states.Dependent.prototype = {
175     /**
176      * Initializes one of the elements this dependent depends on.
177      *
178      * @memberof Drupal.states.Dependent#
179      *
180      * @param {string} selector
181      *   The CSS selector describing the dependee.
182      * @param {object} dependeeStates
183      *   The list of states that have to be monitored for tracking the
184      *   dependee's compliance status.
185      */
186     initializeDependee(selector, dependeeStates) {
187       // Cache for the states of this dependee.
188       this.values[selector] = {};
189
190       Object.keys(dependeeStates).forEach(i => {
191         let state = dependeeStates[i];
192         // Make sure we're not initializing this selector/state combination
193         // twice.
194         if ($.inArray(state, dependeeStates) === -1) {
195           return;
196         }
197
198         state = states.State.sanitize(state);
199
200         // Initialize the value of this state.
201         this.values[selector][state.name] = null;
202
203         // Monitor state changes of the specified state for this dependee.
204         $(selector).on(`state:${state}`, { selector, state }, e => {
205           this.update(e.data.selector, e.data.state, e.value);
206         });
207
208         // Make sure the event we just bound ourselves to is actually fired.
209         new states.Trigger({ selector, state });
210       });
211     },
212
213     /**
214      * Compares a value with a reference value.
215      *
216      * @memberof Drupal.states.Dependent#
217      *
218      * @param {object} reference
219      *   The value used for reference.
220      * @param {string} selector
221      *   CSS selector describing the dependee.
222      * @param {Drupal.states.State} state
223      *   A State object describing the dependee's updated state.
224      *
225      * @return {bool}
226      *   true or false.
227      */
228     compare(reference, selector, state) {
229       const value = this.values[selector][state.name];
230       if (reference.constructor.name in states.Dependent.comparisons) {
231         // Use a custom compare function for certain reference value types.
232         return states.Dependent.comparisons[reference.constructor.name](
233           reference,
234           value,
235         );
236       }
237
238       // Do a plain comparison otherwise.
239       return compare(reference, value);
240     },
241
242     /**
243      * Update the value of a dependee's state.
244      *
245      * @memberof Drupal.states.Dependent#
246      *
247      * @param {string} selector
248      *   CSS selector describing the dependee.
249      * @param {Drupal.states.state} state
250      *   A State object describing the dependee's updated state.
251      * @param {string} value
252      *   The new value for the dependee's updated state.
253      */
254     update(selector, state, value) {
255       // Only act when the 'new' value is actually new.
256       if (value !== this.values[selector][state.name]) {
257         this.values[selector][state.name] = value;
258         this.reevaluate();
259       }
260     },
261
262     /**
263      * Triggers change events in case a state changed.
264      *
265      * @memberof Drupal.states.Dependent#
266      */
267     reevaluate() {
268       // Check whether any constraint for this dependent state is satisfied.
269       let value = this.verifyConstraints(this.constraints);
270
271       // Only invoke a state change event when the value actually changed.
272       if (value !== this.oldValue) {
273         // Store the new value so that we can compare later whether the value
274         // actually changed.
275         this.oldValue = value;
276
277         // Normalize the value to match the normalized state name.
278         value = invert(value, this.state.invert);
279
280         // By adding "trigger: true", we ensure that state changes don't go into
281         // infinite loops.
282         this.element.trigger({
283           type: `state:${this.state}`,
284           value,
285           trigger: true,
286         });
287       }
288     },
289
290     /**
291      * Evaluates child constraints to determine if a constraint is satisfied.
292      *
293      * @memberof Drupal.states.Dependent#
294      *
295      * @param {object|Array} constraints
296      *   A constraint object or an array of constraints.
297      * @param {string} selector
298      *   The selector for these constraints. If undefined, there isn't yet a
299      *   selector that these constraints apply to. In that case, the keys of the
300      *   object are interpreted as the selector if encountered.
301      *
302      * @return {bool}
303      *   true or false, depending on whether these constraints are satisfied.
304      */
305     verifyConstraints(constraints, selector) {
306       let result;
307       if ($.isArray(constraints)) {
308         // This constraint is an array (OR or XOR).
309         const hasXor = $.inArray('xor', constraints) === -1;
310         const len = constraints.length;
311         for (let i = 0; i < len; i++) {
312           if (constraints[i] !== 'xor') {
313             const constraint = this.checkConstraints(
314               constraints[i],
315               selector,
316               i,
317             );
318             // Return if this is OR and we have a satisfied constraint or if
319             // this is XOR and we have a second satisfied constraint.
320             if (constraint && (hasXor || result)) {
321               return hasXor;
322             }
323             result = result || constraint;
324           }
325         }
326       }
327       // Make sure we don't try to iterate over things other than objects. This
328       // shouldn't normally occur, but in case the condition definition is
329       // bogus, we don't want to end up with an infinite loop.
330       else if ($.isPlainObject(constraints)) {
331         // This constraint is an object (AND).
332         // eslint-disable-next-line no-restricted-syntax
333         for (const n in constraints) {
334           if (constraints.hasOwnProperty(n)) {
335             result = ternary(
336               result,
337               this.checkConstraints(constraints[n], selector, n),
338             );
339             // False and anything else will evaluate to false, so return when
340             // any false condition is found.
341             if (result === false) {
342               return false;
343             }
344           }
345         }
346       }
347       return result;
348     },
349
350     /**
351      * Checks whether the value matches the requirements for this constraint.
352      *
353      * @memberof Drupal.states.Dependent#
354      *
355      * @param {string|Array|object} value
356      *   Either the value of a state or an array/object of constraints. In the
357      *   latter case, resolving the constraint continues.
358      * @param {string} [selector]
359      *   The selector for this constraint. If undefined, there isn't yet a
360      *   selector that this constraint applies to. In that case, the state key
361      *   is propagates to a selector and resolving continues.
362      * @param {Drupal.states.State} [state]
363      *   The state to check for this constraint. If undefined, resolving
364      *   continues. If both selector and state aren't undefined and valid
365      *   non-numeric strings, a lookup for the actual value of that selector's
366      *   state is performed. This parameter is not a State object but a pristine
367      *   state string.
368      *
369      * @return {bool}
370      *   true or false, depending on whether this constraint is satisfied.
371      */
372     checkConstraints(value, selector, state) {
373       // Normalize the last parameter. If it's non-numeric, we treat it either
374       // as a selector (in case there isn't one yet) or as a trigger/state.
375       if (typeof state !== 'string' || /[0-9]/.test(state[0])) {
376         state = null;
377       } else if (typeof selector === 'undefined') {
378         // Propagate the state to the selector when there isn't one yet.
379         selector = state;
380         state = null;
381       }
382
383       if (state !== null) {
384         // Constraints is the actual constraints of an element to check for.
385         state = states.State.sanitize(state);
386         return invert(this.compare(value, selector, state), state.invert);
387       }
388
389       // Resolve this constraint as an AND/OR operator.
390       return this.verifyConstraints(value, selector);
391     },
392
393     /**
394      * Gathers information about all required triggers.
395      *
396      * @memberof Drupal.states.Dependent#
397      *
398      * @return {object}
399      *   An object describing the required triggers.
400      */
401     getDependees() {
402       const cache = {};
403       // Swivel the lookup function so that we can record all available
404       // selector- state combinations for initialization.
405       const _compare = this.compare;
406       this.compare = function(reference, selector, state) {
407         (cache[selector] || (cache[selector] = [])).push(state.name);
408         // Return nothing (=== undefined) so that the constraint loops are not
409         // broken.
410       };
411
412       // This call doesn't actually verify anything but uses the resolving
413       // mechanism to go through the constraints array, trying to look up each
414       // value. Since we swivelled the compare function, this comparison returns
415       // undefined and lookup continues until the very end. Instead of lookup up
416       // the value, we record that combination of selector and state so that we
417       // can initialize all triggers.
418       this.verifyConstraints(this.constraints);
419       // Restore the original function.
420       this.compare = _compare;
421
422       return cache;
423     },
424   };
425
426   /**
427    * @constructor Drupal.states.Trigger
428    *
429    * @param {object} args
430    *   Trigger arguments.
431    */
432   states.Trigger = function(args) {
433     $.extend(this, args);
434
435     if (this.state in states.Trigger.states) {
436       this.element = $(this.selector);
437
438       // Only call the trigger initializer when it wasn't yet attached to this
439       // element. Otherwise we'd end up with duplicate events.
440       if (!this.element.data(`trigger:${this.state}`)) {
441         this.initialize();
442       }
443     }
444   };
445
446   states.Trigger.prototype = {
447     /**
448      * @memberof Drupal.states.Trigger#
449      */
450     initialize() {
451       const trigger = states.Trigger.states[this.state];
452
453       if (typeof trigger === 'function') {
454         // We have a custom trigger initialization function.
455         trigger.call(window, this.element);
456       } else {
457         Object.keys(trigger || {}).forEach(event => {
458           this.defaultTrigger(event, trigger[event]);
459         });
460       }
461
462       // Mark this trigger as initialized for this element.
463       this.element.data(`trigger:${this.state}`, true);
464     },
465
466     /**
467      * @memberof Drupal.states.Trigger#
468      *
469      * @param {jQuery.Event} event
470      *   The event triggered.
471      * @param {function} valueFn
472      *   The function to call.
473      */
474     defaultTrigger(event, valueFn) {
475       let oldValue = valueFn.call(this.element);
476
477       // Attach the event callback.
478       this.element.on(
479         event,
480         $.proxy(function(e) {
481           const value = valueFn.call(this.element, e);
482           // Only trigger the event if the value has actually changed.
483           if (oldValue !== value) {
484             this.element.trigger({
485               type: `state:${this.state}`,
486               value,
487               oldValue,
488             });
489             oldValue = value;
490           }
491         }, this),
492       );
493
494       states.postponed.push(
495         $.proxy(function() {
496           // Trigger the event once for initialization purposes.
497           this.element.trigger({
498             type: `state:${this.state}`,
499             value: oldValue,
500             oldValue: null,
501           });
502         }, this),
503       );
504     },
505   };
506
507   /**
508    * This list of states contains functions that are used to monitor the state
509    * of an element. Whenever an element depends on the state of another element,
510    * one of these trigger functions is added to the dependee so that the
511    * dependent element can be updated.
512    *
513    * @name Drupal.states.Trigger.states
514    *
515    * @prop empty
516    * @prop checked
517    * @prop value
518    * @prop collapsed
519    */
520   states.Trigger.states = {
521     // 'empty' describes the state to be monitored.
522     empty: {
523       // 'keyup' is the (native DOM) event that we watch for.
524       keyup() {
525         // The function associated with that trigger returns the new value for
526         // the state.
527         return this.val() === '';
528       },
529     },
530
531     checked: {
532       change() {
533         // prop() and attr() only takes the first element into account. To
534         // support selectors matching multiple checkboxes, iterate over all and
535         // return whether any is checked.
536         let checked = false;
537         this.each(function() {
538           // Use prop() here as we want a boolean of the checkbox state.
539           // @see http://api.jquery.com/prop/
540           checked = $(this).prop('checked');
541           // Break the each() loop if this is checked.
542           return !checked;
543         });
544         return checked;
545       },
546     },
547
548     // For radio buttons, only return the value if the radio button is selected.
549     value: {
550       keyup() {
551         // Radio buttons share the same :input[name="key"] selector.
552         if (this.length > 1) {
553           // Initial checked value of radios is undefined, so we return false.
554           return this.filter(':checked').val() || false;
555         }
556         return this.val();
557       },
558       change() {
559         // Radio buttons share the same :input[name="key"] selector.
560         if (this.length > 1) {
561           // Initial checked value of radios is undefined, so we return false.
562           return this.filter(':checked').val() || false;
563         }
564         return this.val();
565       },
566     },
567
568     collapsed: {
569       collapsed(e) {
570         return typeof e !== 'undefined' && 'value' in e
571           ? e.value
572           : !this.is('[open]');
573       },
574     },
575   };
576
577   /**
578    * A state object is used for describing the state and performing aliasing.
579    *
580    * @constructor Drupal.states.State
581    *
582    * @param {string} state
583    *   The name of the state.
584    */
585   states.State = function(state) {
586     /**
587      * Original unresolved name.
588      */
589     this.pristine = state;
590     this.name = state;
591
592     // Normalize the state name.
593     let process = true;
594     do {
595       // Iteratively remove exclamation marks and invert the value.
596       while (this.name.charAt(0) === '!') {
597         this.name = this.name.substring(1);
598         this.invert = !this.invert;
599       }
600
601       // Replace the state with its normalized name.
602       if (this.name in states.State.aliases) {
603         this.name = states.State.aliases[this.name];
604       } else {
605         process = false;
606       }
607     } while (process);
608   };
609
610   /**
611    * Creates a new State object by sanitizing the passed value.
612    *
613    * @name Drupal.states.State.sanitize
614    *
615    * @param {string|Drupal.states.State} state
616    *   A state object or the name of a state.
617    *
618    * @return {Drupal.states.state}
619    *   A state object.
620    */
621   states.State.sanitize = function(state) {
622     if (state instanceof states.State) {
623       return state;
624     }
625
626     return new states.State(state);
627   };
628
629   /**
630    * This list of aliases is used to normalize states and associates negated
631    * names with their respective inverse state.
632    *
633    * @name Drupal.states.State.aliases
634    */
635   states.State.aliases = {
636     enabled: '!disabled',
637     invisible: '!visible',
638     invalid: '!valid',
639     untouched: '!touched',
640     optional: '!required',
641     filled: '!empty',
642     unchecked: '!checked',
643     irrelevant: '!relevant',
644     expanded: '!collapsed',
645     open: '!collapsed',
646     closed: 'collapsed',
647     readwrite: '!readonly',
648   };
649
650   states.State.prototype = {
651     /**
652      * @memberof Drupal.states.State#
653      */
654     invert: false,
655
656     /**
657      * Ensures that just using the state object returns the name.
658      *
659      * @memberof Drupal.states.State#
660      *
661      * @return {string}
662      *   The name of the state.
663      */
664     toString() {
665       return this.name;
666     },
667   };
668
669   /**
670    * Global state change handlers. These are bound to "document" to cover all
671    * elements whose state changes. Events sent to elements within the page
672    * bubble up to these handlers. We use this system so that themes and modules
673    * can override these state change handlers for particular parts of a page.
674    */
675
676   const $document = $(document);
677   $document.on('state:disabled', e => {
678     // Only act when this change was triggered by a dependency and not by the
679     // element monitoring itself.
680     if (e.trigger) {
681       $(e.target)
682         .prop('disabled', e.value)
683         .closest('.js-form-item, .js-form-submit, .js-form-wrapper')
684         .toggleClass('form-disabled', e.value)
685         .find('select, input, textarea')
686         .prop('disabled', e.value);
687
688       // Note: WebKit nightlies don't reflect that change correctly.
689       // See https://bugs.webkit.org/show_bug.cgi?id=23789
690     }
691   });
692
693   $document.on('state:required', e => {
694     if (e.trigger) {
695       if (e.value) {
696         const label = `label${e.target.id ? `[for=${e.target.id}]` : ''}`;
697         const $label = $(e.target)
698           .attr({ required: 'required', 'aria-required': 'aria-required' })
699           .closest('.js-form-item, .js-form-wrapper')
700           .find(label);
701         // Avoids duplicate required markers on initialization.
702         if (!$label.hasClass('js-form-required').length) {
703           $label.addClass('js-form-required form-required');
704         }
705       } else {
706         $(e.target)
707           .removeAttr('required aria-required')
708           .closest('.js-form-item, .js-form-wrapper')
709           .find('label.js-form-required')
710           .removeClass('js-form-required form-required');
711       }
712     }
713   });
714
715   $document.on('state:visible', e => {
716     if (e.trigger) {
717       $(e.target)
718         .closest('.js-form-item, .js-form-submit, .js-form-wrapper')
719         .toggle(e.value);
720     }
721   });
722
723   $document.on('state:checked', e => {
724     if (e.trigger) {
725       $(e.target).prop('checked', e.value);
726     }
727   });
728
729   $document.on('state:collapsed', e => {
730     if (e.trigger) {
731       if ($(e.target).is('[open]') === e.value) {
732         $(e.target)
733           .find('> summary')
734           .trigger('click');
735       }
736     }
737   });
738 })(jQuery, Drupal);