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