3 * Drupal's states library.
8 * The base States namespace.
10 * Having the local states variable allows us to use the States namespace
11 * without having to always declare "Drupal.states".
13 * @namespace Drupal.states
17 * An array of functions that should be postponed.
22 Drupal.states = states;
25 * Inverts a (if it's not undefined) when invertState is true.
27 * @function Drupal.states~invert
30 * The value to maybe invert.
31 * @param {bool} invertState
32 * Whether to invert state or not.
37 function invert(a, invertState) {
38 return invertState && typeof a !== 'undefined' ? !a : a;
42 * Compares two values while ignoring undefined values.
44 * @function Drupal.states~compare
52 * The comparison result.
54 function compare(a, b) {
56 return typeof a === 'undefined' ? a : true;
59 return typeof a === 'undefined' || typeof b === 'undefined';
63 * Bitwise AND with a third undefined state.
65 * @function Drupal.states~ternary
75 function ternary(a, b) {
76 if (typeof a === 'undefined') {
79 if (typeof b === 'undefined') {
87 * Attaches the states.
89 * @type {Drupal~behavior}
91 * @prop {Drupal~behaviorAttach} attach
92 * Attaches states behaviors.
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'),
102 Object.keys(config || {}).forEach(state => {
103 new states.Dependent({
104 element: $($states[i]),
105 state: states.State.sanitize(state),
106 constraints: config[state],
111 // Execute all postponed functions now.
112 while (states.postponed.length) {
113 states.postponed.shift()();
119 * Object representing an element that depends on other elements.
121 * @constructor Drupal.states.Dependent
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.
134 states.Dependent = function(args) {
135 $.extend(this, { values: {}, oldValue: null }, args);
137 this.dependees = this.getDependees();
138 Object.keys(this.dependees || {}).forEach(selector => {
139 this.initializeDependee(selector, this.dependees[selector]);
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.
148 * @name Drupal.states.Dependent.comparisons
150 * @prop {function} RegExp
151 * @prop {function} Function
152 * @prop {function} Number
154 states.Dependent.comparisons = {
155 RegExp(reference, value) {
156 return reference.test(value);
158 Function(reference, value) {
159 // The "reference" variable is a comparison function.
160 return reference(value);
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
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);
174 states.Dependent.prototype = {
176 * Initializes one of the elements this dependent depends on.
178 * @memberof Drupal.states.Dependent#
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.
186 initializeDependee(selector, dependeeStates) {
187 // Cache for the states of this dependee.
188 this.values[selector] = {};
190 Object.keys(dependeeStates).forEach(i => {
191 let state = dependeeStates[i];
192 // Make sure we're not initializing this selector/state combination
194 if ($.inArray(state, dependeeStates) === -1) {
198 state = states.State.sanitize(state);
200 // Initialize the value of this state.
201 this.values[selector][state.name] = null;
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);
208 // Make sure the event we just bound ourselves to is actually fired.
209 new states.Trigger({ selector, state });
214 * Compares a value with a reference value.
216 * @memberof Drupal.states.Dependent#
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.
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](
238 // Do a plain comparison otherwise.
239 return compare(reference, value);
243 * Update the value of a dependee's state.
245 * @memberof Drupal.states.Dependent#
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.
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;
263 * Triggers change events in case a state changed.
265 * @memberof Drupal.states.Dependent#
268 // Check whether any constraint for this dependent state is satisfied.
269 let value = this.verifyConstraints(this.constraints);
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
275 this.oldValue = value;
277 // Normalize the value to match the normalized state name.
278 value = invert(value, this.state.invert);
280 // By adding "trigger: true", we ensure that state changes don't go into
282 this.element.trigger({
283 type: `state:${this.state}`,
291 * Evaluates child constraints to determine if a constraint is satisfied.
293 * @memberof Drupal.states.Dependent#
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.
303 * true or false, depending on whether these constraints are satisfied.
305 verifyConstraints(constraints, selector) {
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(
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)) {
323 result = result || constraint;
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)) {
337 this.checkConstraints(constraints[n], selector, n),
339 // False and anything else will evaluate to false, so return when
340 // any false condition is found.
341 if (result === false) {
351 * Checks whether the value matches the requirements for this constraint.
353 * @memberof Drupal.states.Dependent#
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
370 * true or false, depending on whether this constraint is satisfied.
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])) {
377 } else if (typeof selector === 'undefined') {
378 // Propagate the state to the selector when there isn't one yet.
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);
389 // Resolve this constraint as an AND/OR operator.
390 return this.verifyConstraints(value, selector);
394 * Gathers information about all required triggers.
396 * @memberof Drupal.states.Dependent#
399 * An object describing the required triggers.
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
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;
427 * @constructor Drupal.states.Trigger
429 * @param {object} args
432 states.Trigger = function(args) {
433 $.extend(this, args);
435 if (this.state in states.Trigger.states) {
436 this.element = $(this.selector);
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}`)) {
446 states.Trigger.prototype = {
448 * @memberof Drupal.states.Trigger#
451 const trigger = states.Trigger.states[this.state];
453 if (typeof trigger === 'function') {
454 // We have a custom trigger initialization function.
455 trigger.call(window, this.element);
457 Object.keys(trigger || {}).forEach(event => {
458 this.defaultTrigger(event, trigger[event]);
462 // Mark this trigger as initialized for this element.
463 this.element.data(`trigger:${this.state}`, true);
467 * @memberof Drupal.states.Trigger#
469 * @param {jQuery.Event} event
470 * The event triggered.
471 * @param {function} valueFn
472 * The function to call.
474 defaultTrigger(event, valueFn) {
475 let oldValue = valueFn.call(this.element);
477 // Attach the event callback.
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}`,
494 states.postponed.push(
496 // Trigger the event once for initialization purposes.
497 this.element.trigger({
498 type: `state:${this.state}`,
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.
513 * @name Drupal.states.Trigger.states
520 states.Trigger.states = {
521 // 'empty' describes the state to be monitored.
523 // 'keyup' is the (native DOM) event that we watch for.
525 // The function associated with that trigger returns the new value for
527 return this.val() === '';
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.
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.
548 // For radio buttons, only return the value if the radio button is selected.
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;
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;
570 return typeof e !== 'undefined' && 'value' in e
572 : !this.is('[open]');
578 * A state object is used for describing the state and performing aliasing.
580 * @constructor Drupal.states.State
582 * @param {string} state
583 * The name of the state.
585 states.State = function(state) {
587 * Original unresolved name.
589 this.pristine = state;
592 // Normalize the state name.
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;
601 // Replace the state with its normalized name.
602 if (this.name in states.State.aliases) {
603 this.name = states.State.aliases[this.name];
611 * Creates a new State object by sanitizing the passed value.
613 * @name Drupal.states.State.sanitize
615 * @param {string|Drupal.states.State} state
616 * A state object or the name of a state.
618 * @return {Drupal.states.state}
621 states.State.sanitize = function(state) {
622 if (state instanceof states.State) {
626 return new states.State(state);
630 * This list of aliases is used to normalize states and associates negated
631 * names with their respective inverse state.
633 * @name Drupal.states.State.aliases
635 states.State.aliases = {
636 enabled: '!disabled',
637 invisible: '!visible',
639 untouched: '!touched',
640 optional: '!required',
642 unchecked: '!checked',
643 irrelevant: '!relevant',
644 expanded: '!collapsed',
647 readwrite: '!readonly',
650 states.State.prototype = {
652 * @memberof Drupal.states.State#
657 * Ensures that just using the state object returns the name.
659 * @memberof Drupal.states.State#
662 * The name of the state.
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.
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.
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);
688 // Note: WebKit nightlies don't reflect that change correctly.
689 // See https://bugs.webkit.org/show_bug.cgi?id=23789
693 $document.on('state:required', e => {
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')
701 // Avoids duplicate required markers on initialization.
702 if (!$label.hasClass('js-form-required').length) {
703 $label.addClass('js-form-required form-required');
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');
715 $document.on('state:visible', e => {
718 .closest('.js-form-item, .js-form-submit, .js-form-wrapper')
723 $document.on('state:checked', e => {
725 $(e.target).prop('checked', e.value);
729 $document.on('state:collapsed', e => {
731 if ($(e.target).is('[open]') === e.value) {