3 * Drupal's states library.
6 (function ($, Drupal) {
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
15 const states = Drupal.states = {
18 * An array of functions that should be postponed.
24 * Attaches the states.
26 * @type {Drupal~behavior}
28 * @prop {Drupal~behaviorAttach} attach
29 * Attaches states behaviors.
31 Drupal.behaviors.states = {
32 attach(context, settings) {
33 const $states = $(context).find('[data-drupal-states]');
36 const il = $states.length;
37 for (let i = 0; i < il; i++) {
38 config = JSON.parse($states[i].getAttribute('data-drupal-states'));
39 for (state in config) {
40 if (config.hasOwnProperty(state)) {
41 new states.Dependent({
42 element: $($states[i]),
43 state: states.State.sanitize(state),
44 constraints: config[state],
50 // Execute all postponed functions now.
51 while (states.postponed.length) {
52 (states.postponed.shift())();
58 * Object representing an element that depends on other elements.
60 * @constructor Drupal.states.Dependent
62 * @param {object} args
63 * Object with the following keys (all of which are required)
64 * @param {jQuery} args.element
65 * A jQuery object of the dependent element
66 * @param {Drupal.states.State} args.state
67 * A State object describing the state that is dependent
68 * @param {object} args.constraints
69 * An object with dependency specifications. Lists all elements that this
70 * element depends on. It can be nested and can contain
71 * arbitrary AND and OR clauses.
73 states.Dependent = function (args) {
74 $.extend(this, { values: {}, oldValue: null }, args);
76 this.dependees = this.getDependees();
77 for (const selector in this.dependees) {
78 if (this.dependees.hasOwnProperty(selector)) {
79 this.initializeDependee(selector, this.dependees[selector]);
85 * Comparison functions for comparing the value of an element with the
86 * specification from the dependency settings. If the object type can't be
87 * found in this list, the === operator is used by default.
89 * @name Drupal.states.Dependent.comparisons
91 * @prop {function} RegExp
92 * @prop {function} Function
93 * @prop {function} Number
95 states.Dependent.comparisons = {
96 RegExp(reference, value) {
97 return reference.test(value);
99 Function(reference, value) {
100 // The "reference" variable is a comparison function.
101 return reference(value);
103 Number(reference, value) {
104 // If "reference" is a number and "value" is a string, then cast
105 // reference as a string before applying the strict comparison in
107 // Otherwise numeric keys in the form's #states array fail to match
108 // string values returned from jQuery's val().
109 return (typeof value === 'string') ? compare(reference.toString(), value) : compare(reference, value);
113 states.Dependent.prototype = {
116 * Initializes one of the elements this dependent depends on.
118 * @memberof Drupal.states.Dependent#
120 * @param {string} selector
121 * The CSS selector describing the dependee.
122 * @param {object} dependeeStates
123 * The list of states that have to be monitored for tracking the
124 * dependee's compliance status.
126 initializeDependee(selector, dependeeStates) {
130 function stateEventHandler(e) {
131 self.update(e.data.selector, e.data.state, e.value);
134 // Cache for the states of this dependee.
135 this.values[selector] = {};
137 for (const i in dependeeStates) {
138 if (dependeeStates.hasOwnProperty(i)) {
139 state = dependeeStates[i];
140 // Make sure we're not initializing this selector/state combination
142 if ($.inArray(state, dependeeStates) === -1) {
146 state = states.State.sanitize(state);
148 // Initialize the value of this state.
149 this.values[selector][state.name] = null;
151 // Monitor state changes of the specified state for this dependee.
152 $(selector).on(`state:${state}`, { selector, state }, stateEventHandler);
154 // Make sure the event we just bound ourselves to is actually fired.
155 new states.Trigger({ selector, state });
161 * Compares a value with a reference value.
163 * @memberof Drupal.states.Dependent#
165 * @param {object} reference
166 * The value used for reference.
167 * @param {string} selector
168 * CSS selector describing the dependee.
169 * @param {Drupal.states.State} state
170 * A State object describing the dependee's updated state.
175 compare(reference, selector, state) {
176 const value = this.values[selector][state.name];
177 if (reference.constructor.name in states.Dependent.comparisons) {
178 // Use a custom compare function for certain reference value types.
179 return states.Dependent.comparisons[reference.constructor.name](reference, value);
182 // Do a plain comparison otherwise.
183 return compare(reference, value);
187 * Update the value of a dependee's state.
189 * @memberof Drupal.states.Dependent#
191 * @param {string} selector
192 * CSS selector describing the dependee.
193 * @param {Drupal.states.state} state
194 * A State object describing the dependee's updated state.
195 * @param {string} value
196 * The new value for the dependee's updated state.
198 update(selector, state, value) {
199 // Only act when the 'new' value is actually new.
200 if (value !== this.values[selector][state.name]) {
201 this.values[selector][state.name] = value;
207 * Triggers change events in case a state changed.
209 * @memberof Drupal.states.Dependent#
212 // Check whether any constraint for this dependent state is satisfied.
213 let value = this.verifyConstraints(this.constraints);
215 // Only invoke a state change event when the value actually changed.
216 if (value !== this.oldValue) {
217 // Store the new value so that we can compare later whether the value
219 this.oldValue = value;
221 // Normalize the value to match the normalized state name.
222 value = invert(value, this.state.invert);
224 // By adding "trigger: true", we ensure that state changes don't go into
226 this.element.trigger({ type: `state:${this.state}`, value, trigger: true });
231 * Evaluates child constraints to determine if a constraint is satisfied.
233 * @memberof Drupal.states.Dependent#
235 * @param {object|Array} constraints
236 * A constraint object or an array of constraints.
237 * @param {string} selector
238 * The selector for these constraints. If undefined, there isn't yet a
239 * selector that these constraints apply to. In that case, the keys of the
240 * object are interpreted as the selector if encountered.
243 * true or false, depending on whether these constraints are satisfied.
245 verifyConstraints(constraints, selector) {
247 if ($.isArray(constraints)) {
248 // This constraint is an array (OR or XOR).
249 const hasXor = $.inArray('xor', constraints) === -1;
250 const len = constraints.length;
251 for (let i = 0; i < len; i++) {
252 if (constraints[i] !== 'xor') {
253 const constraint = this.checkConstraints(constraints[i], selector, i);
254 // Return if this is OR and we have a satisfied constraint or if
255 // this is XOR and we have a second satisfied constraint.
256 if (constraint && (hasXor || result)) {
259 result = result || constraint;
263 // Make sure we don't try to iterate over things other than objects. This
264 // shouldn't normally occur, but in case the condition definition is
265 // bogus, we don't want to end up with an infinite loop.
266 else if ($.isPlainObject(constraints)) {
267 // This constraint is an object (AND).
268 for (const n in constraints) {
269 if (constraints.hasOwnProperty(n)) {
270 result = ternary(result, this.checkConstraints(constraints[n], selector, n));
271 // False and anything else will evaluate to false, so return when
272 // any false condition is found.
273 if (result === false) {
283 * Checks whether the value matches the requirements for this constraint.
285 * @memberof Drupal.states.Dependent#
287 * @param {string|Array|object} value
288 * Either the value of a state or an array/object of constraints. In the
289 * latter case, resolving the constraint continues.
290 * @param {string} [selector]
291 * The selector for this constraint. If undefined, there isn't yet a
292 * selector that this constraint applies to. In that case, the state key
293 * is propagates to a selector and resolving continues.
294 * @param {Drupal.states.State} [state]
295 * The state to check for this constraint. If undefined, resolving
296 * continues. If both selector and state aren't undefined and valid
297 * non-numeric strings, a lookup for the actual value of that selector's
298 * state is performed. This parameter is not a State object but a pristine
302 * true or false, depending on whether this constraint is satisfied.
304 checkConstraints(value, selector, state) {
305 // Normalize the last parameter. If it's non-numeric, we treat it either
306 // as a selector (in case there isn't one yet) or as a trigger/state.
307 if (typeof state !== 'string' || (/[0-9]/).test(state[0])) {
310 else if (typeof selector === 'undefined') {
311 // Propagate the state to the selector when there isn't one yet.
316 if (state !== null) {
317 // Constraints is the actual constraints of an element to check for.
318 state = states.State.sanitize(state);
319 return invert(this.compare(value, selector, state), state.invert);
322 // Resolve this constraint as an AND/OR operator.
323 return this.verifyConstraints(value, selector);
327 * Gathers information about all required triggers.
329 * @memberof Drupal.states.Dependent#
332 * An object describing the required triggers.
336 // Swivel the lookup function so that we can record all available
337 // selector- state combinations for initialization.
338 const _compare = this.compare;
339 this.compare = function (reference, selector, state) {
340 (cache[selector] || (cache[selector] = [])).push(state.name);
341 // Return nothing (=== undefined) so that the constraint loops are not
345 // This call doesn't actually verify anything but uses the resolving
346 // mechanism to go through the constraints array, trying to look up each
347 // value. Since we swivelled the compare function, this comparison returns
348 // undefined and lookup continues until the very end. Instead of lookup up
349 // the value, we record that combination of selector and state so that we
350 // can initialize all triggers.
351 this.verifyConstraints(this.constraints);
352 // Restore the original function.
353 this.compare = _compare;
360 * @constructor Drupal.states.Trigger
362 * @param {object} args
365 states.Trigger = function (args) {
366 $.extend(this, args);
368 if (this.state in states.Trigger.states) {
369 this.element = $(this.selector);
371 // Only call the trigger initializer when it wasn't yet attached to this
372 // element. Otherwise we'd end up with duplicate events.
373 if (!this.element.data(`trigger:${this.state}`)) {
379 states.Trigger.prototype = {
382 * @memberof Drupal.states.Trigger#
385 const trigger = states.Trigger.states[this.state];
387 if (typeof trigger === 'function') {
388 // We have a custom trigger initialization function.
389 trigger.call(window, this.element);
392 for (const event in trigger) {
393 if (trigger.hasOwnProperty(event)) {
394 this.defaultTrigger(event, trigger[event]);
399 // Mark this trigger as initialized for this element.
400 this.element.data(`trigger:${this.state}`, true);
404 * @memberof Drupal.states.Trigger#
406 * @param {jQuery.Event} event
407 * The event triggered.
408 * @param {function} valueFn
409 * The function to call.
411 defaultTrigger(event, valueFn) {
412 let oldValue = valueFn.call(this.element);
414 // Attach the event callback.
415 this.element.on(event, $.proxy(function (e) {
416 const value = valueFn.call(this.element, e);
417 // Only trigger the event if the value has actually changed.
418 if (oldValue !== value) {
419 this.element.trigger({ type: `state:${this.state}`, value, oldValue });
424 states.postponed.push($.proxy(function () {
425 // Trigger the event once for initialization purposes.
426 this.element.trigger({ type: `state:${this.state}`, value: oldValue, oldValue: null });
432 * This list of states contains functions that are used to monitor the state
433 * of an element. Whenever an element depends on the state of another element,
434 * one of these trigger functions is added to the dependee so that the
435 * dependent element can be updated.
437 * @name Drupal.states.Trigger.states
444 states.Trigger.states = {
445 // 'empty' describes the state to be monitored.
447 // 'keyup' is the (native DOM) event that we watch for.
449 // The function associated with that trigger returns the new value for
451 return this.val() === '';
457 // prop() and attr() only takes the first element into account. To
458 // support selectors matching multiple checkboxes, iterate over all and
459 // return whether any is checked.
461 this.each(function () {
462 // Use prop() here as we want a boolean of the checkbox state.
463 // @see http://api.jquery.com/prop/
464 checked = $(this).prop('checked');
465 // Break the each() loop if this is checked.
472 // For radio buttons, only return the value if the radio button is selected.
475 // Radio buttons share the same :input[name="key"] selector.
476 if (this.length > 1) {
477 // Initial checked value of radios is undefined, so we return false.
478 return this.filter(':checked').val() || false;
483 // Radio buttons share the same :input[name="key"] selector.
484 if (this.length > 1) {
485 // Initial checked value of radios is undefined, so we return false.
486 return this.filter(':checked').val() || false;
494 return (typeof e !== 'undefined' && 'value' in e) ? e.value : !this.is('[open]');
500 * A state object is used for describing the state and performing aliasing.
502 * @constructor Drupal.states.State
504 * @param {string} state
505 * The name of the state.
507 states.State = function (state) {
509 * Original unresolved name.
511 this.pristine = this.name = state;
513 // Normalize the state name.
516 // Iteratively remove exclamation marks and invert the value.
517 while (this.name.charAt(0) === '!') {
518 this.name = this.name.substring(1);
519 this.invert = !this.invert;
522 // Replace the state with its normalized name.
523 if (this.name in states.State.aliases) {
524 this.name = states.State.aliases[this.name];
533 * Creates a new State object by sanitizing the passed value.
535 * @name Drupal.states.State.sanitize
537 * @param {string|Drupal.states.State} state
538 * A state object or the name of a state.
540 * @return {Drupal.states.state}
543 states.State.sanitize = function (state) {
544 if (state instanceof states.State) {
548 return new states.State(state);
552 * This list of aliases is used to normalize states and associates negated
553 * names with their respective inverse state.
555 * @name Drupal.states.State.aliases
557 states.State.aliases = {
558 enabled: '!disabled',
559 invisible: '!visible',
561 untouched: '!touched',
562 optional: '!required',
564 unchecked: '!checked',
565 irrelevant: '!relevant',
566 expanded: '!collapsed',
569 readwrite: '!readonly',
572 states.State.prototype = {
575 * @memberof Drupal.states.State#
580 * Ensures that just using the state object returns the name.
582 * @memberof Drupal.states.State#
585 * The name of the state.
593 * Global state change handlers. These are bound to "document" to cover all
594 * elements whose state changes. Events sent to elements within the page
595 * bubble up to these handlers. We use this system so that themes and modules
596 * can override these state change handlers for particular parts of a page.
599 const $document = $(document);
600 $document.on('state:disabled', (e) => {
601 // Only act when this change was triggered by a dependency and not by the
602 // element monitoring itself.
605 .prop('disabled', e.value)
606 .closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggleClass('form-disabled', e.value)
607 .find('select, input, textarea').prop('disabled', e.value);
609 // Note: WebKit nightlies don't reflect that change correctly.
610 // See https://bugs.webkit.org/show_bug.cgi?id=23789
614 $document.on('state:required', (e) => {
617 const label = `label${e.target.id ? `[for=${e.target.id}]` : ''}`;
618 const $label = $(e.target).attr({ required: 'required', 'aria-required': 'aria-required' }).closest('.js-form-item, .js-form-wrapper').find(label);
619 // Avoids duplicate required markers on initialization.
620 if (!$label.hasClass('js-form-required').length) {
621 $label.addClass('js-form-required form-required');
625 $(e.target).removeAttr('required aria-required').closest('.js-form-item, .js-form-wrapper').find('label.js-form-required').removeClass('js-form-required form-required');
630 $document.on('state:visible', (e) => {
632 $(e.target).closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggle(e.value);
636 $document.on('state:checked', (e) => {
638 $(e.target).prop('checked', e.value);
642 $document.on('state:collapsed', (e) => {
644 if ($(e.target).is('[open]') === e.value) {
645 $(e.target).find('> summary').trigger('click');
651 * These are helper functions implementing addition "operators" and don't
652 * implement any logic that is particular to states.
656 * Bitwise AND with a third undefined state.
658 * @function Drupal.states~ternary
668 function ternary(a, b) {
669 if (typeof a === 'undefined') {
672 else if (typeof b === 'undefined') {
680 * Inverts a (if it's not undefined) when invertState is true.
682 * @function Drupal.states~invert
685 * The value to maybe invert.
686 * @param {bool} invertState
687 * Whether to invert state or not.
692 function invert(a, invertState) {
693 return (invertState && typeof a !== 'undefined') ? !a : a;
697 * Compares two values while ignoring undefined values.
699 * @function Drupal.states~compare
707 * The comparison result.
709 function compare(a, b) {
711 return typeof a === 'undefined' ? a : true;
714 return typeof a === 'undefined' || typeof b === 'undefined';