/** * @file * Drupal's states library. */ (function ($, Drupal) { /** * The base States namespace. * * Having the local states variable allows us to use the States namespace * without having to always declare "Drupal.states". * * @namespace Drupal.states */ const states = { /** * An array of functions that should be postponed. */ postponed: [], }; Drupal.states = states; /** * Inverts a (if it's not undefined) when invertState is true. * * @function Drupal.states~invert * * @param {*} a * The value to maybe invert. * @param {boolean} invertState * Whether to invert state or not. * * @return {boolean} * The result. */ function invert(a, invertState) { return invertState && typeof a !== 'undefined' ? !a : a; } /** * Compares two values while ignoring undefined values. * * @function Drupal.states~compare * * @param {*} a * Value a. * @param {*} b * Value b. * * @return {boolean} * The comparison result. */ function compare(a, b) { if (a === b) { return typeof a === 'undefined' ? a : true; } return typeof a === 'undefined' || typeof b === 'undefined'; } /** * Bitwise AND with a third undefined state. * * @function Drupal.states~ternary * * @param {*} a * Value a. * @param {*} b * Value b * * @return {boolean} * The result. */ function ternary(a, b) { if (typeof a === 'undefined') { return b; } if (typeof b === 'undefined') { return a; } return a && b; } /** * Attaches the states. * * @type {Drupal~behavior} * * @prop {Drupal~behaviorAttach} attach * Attaches states behaviors. */ Drupal.behaviors.states = { attach(context, settings) { const $states = $(context).find('[data-drupal-states]'); const il = $states.length; for (let i = 0; i < il; i++) { const config = JSON.parse( $states[i].getAttribute('data-drupal-states'), ); Object.keys(config || {}).forEach((state) => { new states.Dependent({ element: $($states[i]), state: states.State.sanitize(state), constraints: config[state], }); }); } // Execute all postponed functions now. while (states.postponed.length) { states.postponed.shift()(); } }, }; /** * Object representing an element that depends on other elements. * * @constructor Drupal.states.Dependent * * @param {object} args * Object with the following keys (all of which are required) * @param {jQuery} args.element * A jQuery object of the dependent element * @param {Drupal.states.State} args.state * A State object describing the state that is dependent * @param {object} args.constraints * An object with dependency specifications. Lists all elements that this * element depends on. It can be nested and can contain * arbitrary AND and OR clauses. */ states.Dependent = function (args) { $.extend(this, { values: {}, oldValue: null }, args); this.dependees = this.getDependees(); Object.keys(this.dependees || {}).forEach((selector) => { this.initializeDependee(selector, this.dependees[selector]); }); }; /** * Comparison functions for comparing the value of an element with the * specification from the dependency settings. If the object type can't be * found in this list, the === operator is used by default. * * @name Drupal.states.Dependent.comparisons * * @prop {function} RegExp * @prop {function} Function * @prop {function} Array * @prop {function} Number */ states.Dependent.comparisons = { RegExp(reference, value) { return reference.test(value); }, Function(reference, value) { // The "reference" variable is a comparison function. return reference(value); }, Array(reference, value) { // Make sure value is an array. if (!Array.isArray(value)) { return false; } // The arrays values should match. return JSON.stringify(reference.sort()) === JSON.stringify(value.sort()); }, Number(reference, value) { // If "reference" is a number and "value" is a string, then cast // reference as a string before applying the strict comparison in // compare(). // Otherwise numeric keys in the form's #states array fail to match // string values returned from jQuery's val(). return typeof value === 'string' ? compare(reference.toString(), value) : compare(reference, value); }, }; states.Dependent.prototype = { /** * Initializes one of the elements this dependent depends on. * * @memberof Drupal.states.Dependent# * * @param {string} selector * The CSS selector describing the dependee. * @param {object} dependeeStates * The list of states that have to be monitored for tracking the * dependee's compliance status. */ initializeDependee(selector, dependeeStates) { // Cache for the states of this dependee. this.values[selector] = {}; Object.keys(dependeeStates).forEach((i) => { let state = dependeeStates[i]; // Make sure we're not initializing this selector/state combination // twice. if ($.inArray(state, dependeeStates) === -1) { return; } state = states.State.sanitize(state); // Initialize the value of this state. this.values[selector][state.name] = null; // Monitor state changes of the specified state for this dependee. $(selector).on(`state:${state}`, { selector, state }, (e) => { this.update(e.data.selector, e.data.state, e.value); }); // Make sure the event we just bound ourselves to is actually fired. new states.Trigger({ selector, state }); }); }, /** * Compares a value with a reference value. * * @memberof Drupal.states.Dependent# * * @param {object} reference * The value used for reference. * @param {string} selector * CSS selector describing the dependee. * @param {Drupal.states.State} state * A State object describing the dependee's updated state. * * @return {boolean} * true or false. */ compare(reference, selector, state) { const value = this.values[selector][state.name]; if (reference.constructor.name in states.Dependent.comparisons) { // Use a custom compare function for certain reference value types. return states.Dependent.comparisons[reference.constructor.name]( reference, value, ); } // Do a plain comparison otherwise. return compare(reference, value); }, /** * Update the value of a dependee's state. * * @memberof Drupal.states.Dependent# * * @param {string} selector * CSS selector describing the dependee. * @param {Drupal.states.state} state * A State object describing the dependee's updated state. * @param {string} value * The new value for the dependee's updated state. */ update(selector, state, value) { // Only act when the 'new' value is actually new. if (value !== this.values[selector][state.name]) { this.values[selector][state.name] = value; this.reevaluate(); } }, /** * Triggers change events in case a state changed. * * @memberof Drupal.states.Dependent# */ reevaluate() { // Check whether any constraint for this dependent state is satisfied. let value = this.verifyConstraints(this.constraints); // Only invoke a state change event when the value actually changed. if (value !== this.oldValue) { // Store the new value so that we can compare later whether the value // actually changed. this.oldValue = value; // Normalize the value to match the normalized state name. value = invert(value, this.state.invert); // By adding "trigger: true", we ensure that state changes don't go into // infinite loops. this.element.trigger({ type: `state:${this.state}`, value, trigger: true, }); } }, /** * Evaluates child constraints to determine if a constraint is satisfied. * * @memberof Drupal.states.Dependent# * * @param {object|Array} constraints * A constraint object or an array of constraints. * @param {string} selector * The selector for these constraints. If undefined, there isn't yet a * selector that these constraints apply to. In that case, the keys of the * object are interpreted as the selector if encountered. * * @return {boolean} * true or false, depending on whether these constraints are satisfied. */ verifyConstraints(constraints, selector) { let result; if (Array.isArray(constraints)) { // This constraint is an array (OR or XOR). const hasXor = $.inArray('xor', constraints) === -1; const len = constraints.length; for (let i = 0; i < len; i++) { if (constraints[i] !== 'xor') { const constraint = this.checkConstraints( constraints[i], selector, i, ); // Return if this is OR and we have a satisfied constraint or if // this is XOR and we have a second satisfied constraint. if (constraint && (hasXor || result)) { return hasXor; } result = result || constraint; } } } // Make sure we don't try to iterate over things other than objects. This // shouldn't normally occur, but in case the condition definition is // bogus, we don't want to end up with an infinite loop. else if ($.isPlainObject(constraints)) { // This constraint is an object (AND). // eslint-disable-next-line no-restricted-syntax for (const n in constraints) { if (constraints.hasOwnProperty(n)) { result = ternary( result, this.checkConstraints(constraints[n], selector, n), ); // False and anything else will evaluate to false, so return when // any false condition is found. if (result === false) { return false; } } } } return result; }, /** * Checks whether the value matches the requirements for this constraint. * * @memberof Drupal.states.Dependent# * * @param {string|Array|object} value * Either the value of a state or an array/object of constraints. In the * latter case, resolving the constraint continues. * @param {string} [selector] * The selector for this constraint. If undefined, there isn't yet a * selector that this constraint applies to. In that case, the state key * is propagates to a selector and resolving continues. * @param {Drupal.states.State} [state] * The state to check for this constraint. If undefined, resolving * continues. If both selector and state aren't undefined and valid * non-numeric strings, a lookup for the actual value of that selector's * state is performed. This parameter is not a State object but a pristine * state string. * * @return {boolean} * true or false, depending on whether this constraint is satisfied. */ checkConstraints(value, selector, state) { // Normalize the last parameter. If it's non-numeric, we treat it either // as a selector (in case there isn't one yet) or as a trigger/state. if (typeof state !== 'string' || /[0-9]/.test(state[0])) { state = null; } else if (typeof selector === 'undefined') { // Propagate the state to the selector when there isn't one yet. selector = state; state = null; } if (state !== null) { // Constraints is the actual constraints of an element to check for. state = states.State.sanitize(state); return invert(this.compare(value, selector, state), state.invert); } // Resolve this constraint as an AND/OR operator. return this.verifyConstraints(value, selector); }, /** * Gathers information about all required triggers. * * @memberof Drupal.states.Dependent# * * @return {object} * An object describing the required triggers. */ getDependees() { const cache = {}; // Swivel the lookup function so that we can record all available // selector- state combinations for initialization. const _compare = this.compare; this.compare = function (reference, selector, state) { (cache[selector] || (cache[selector] = [])).push(state.name); // Return nothing (=== undefined) so that the constraint loops are not // broken. }; // This call doesn't actually verify anything but uses the resolving // mechanism to go through the constraints array, trying to look up each // value. Since we swivelled the compare function, this comparison returns // undefined and lookup continues until the very end. Instead of lookup up // the value, we record that combination of selector and state so that we // can initialize all triggers. this.verifyConstraints(this.constraints); // Restore the original function. this.compare = _compare; return cache; }, }; /** * @constructor Drupal.states.Trigger * * @param {object} args * Trigger arguments. */ states.Trigger = function (args) { $.extend(this, args); if (this.state in states.Trigger.states) { this.element = $(this.selector); // Only call the trigger initializer when it wasn't yet attached to this // element. Otherwise we'd end up with duplicate events. if (!this.element.data(`trigger:${this.state}`)) { this.initialize(); } } }; states.Trigger.prototype = { /** * @memberof Drupal.states.Trigger# */ initialize() { const trigger = states.Trigger.states[this.state]; if (typeof trigger === 'function') { // We have a custom trigger initialization function. trigger.call(window, this.element); } else { Object.keys(trigger || {}).forEach((event) => { this.defaultTrigger(event, trigger[event]); }); } // Mark this trigger as initialized for this element. this.element.data(`trigger:${this.state}`, true); }, /** * @memberof Drupal.states.Trigger# * * @param {jQuery.Event} event * The event triggered. * @param {function} valueFn * The function to call. */ defaultTrigger(event, valueFn) { let oldValue = valueFn.call(this.element); // Attach the event callback. this.element.on( event, function (e) { const value = valueFn.call(this.element, e); // Only trigger the event if the value has actually changed. if (oldValue !== value) { this.element.trigger({ type: `state:${this.state}`, value, oldValue, }); oldValue = value; } }.bind(this), ); states.postponed.push( function () { // Trigger the event once for initialization purposes. this.element.trigger({ type: `state:${this.state}`, value: oldValue, oldValue: null, }); }.bind(this), ); }, }; /** * This list of states contains functions that are used to monitor the state * of an element. Whenever an element depends on the state of another element, * one of these trigger functions is added to the dependee so that the * dependent element can be updated. * * @name Drupal.states.Trigger.states * * @prop empty * @prop checked * @prop value * @prop collapsed */ states.Trigger.states = { // 'empty' describes the state to be monitored. empty: { // 'keyup' is the (native DOM) event that we watch for. keyup() { // The function associated with that trigger returns the new value for // the state. return this.val() === ''; }, // Listen to 'change' for number native "spinner" widgets. change() { return this.val() === ''; }, }, checked: { change() { // prop() and attr() only takes the first element into account. To // support selectors matching multiple checkboxes, iterate over all and // return whether any is checked. let checked = false; this.each(function () { // Use prop() here as we want a boolean of the checkbox state. // @see http://api.jquery.com/prop/ checked = $(this).prop('checked'); // Break the each() loop if this is checked. return !checked; }); return checked; }, }, // For radio buttons, only return the value if the radio button is selected. value: { keyup() { // Radio buttons share the same :input[name="key"] selector. if (this.length > 1) { // Initial checked value of radios is undefined, so we return false. return this.filter(':checked').val() || false; } return this.val(); }, change() { // Radio buttons share the same :input[name="key"] selector. if (this.length > 1) { // Initial checked value of radios is undefined, so we return false. return this.filter(':checked').val() || false; } return this.val(); }, }, collapsed: { collapsed(e) { return typeof e !== 'undefined' && 'value' in e ? e.value : !this[0].hasAttribute('open'); }, }, }; /** * A state object is used for describing the state and performing aliasing. * * @constructor Drupal.states.State * * @param {string} state * The name of the state. */ states.State = function (state) { /** * Original unresolved name. */ this.pristine = state; this.name = state; // Normalize the state name. let process = true; do { // Iteratively remove exclamation marks and invert the value. while (this.name.charAt(0) === '!') { this.name = this.name.substring(1); this.invert = !this.invert; } // Replace the state with its normalized name. if (this.name in states.State.aliases) { this.name = states.State.aliases[this.name]; } else { process = false; } } while (process); }; /** * Creates a new State object by sanitizing the passed value. * * @name Drupal.states.State.sanitize * * @param {string|Drupal.states.State} state * A state object or the name of a state. * * @return {Drupal.states.state} * A state object. */ states.State.sanitize = function (state) { if (state instanceof states.State) { return state; } return new states.State(state); }; /** * This list of aliases is used to normalize states and associates negated * names with their respective inverse state. * * @name Drupal.states.State.aliases */ states.State.aliases = { enabled: '!disabled', invisible: '!visible', invalid: '!valid', untouched: '!touched', optional: '!required', filled: '!empty', unchecked: '!checked', irrelevant: '!relevant', expanded: '!collapsed', open: '!collapsed', closed: 'collapsed', readwrite: '!readonly', }; states.State.prototype = { /** * @memberof Drupal.states.State# */ invert: false, /** * Ensures that just using the state object returns the name. * * @memberof Drupal.states.State# * * @return {string} * The name of the state. */ toString() { return this.name; }, }; /** * Global state change handlers. These are bound to "document" to cover all * elements whose state changes. Events sent to elements within the page * bubble up to these handlers. We use this system so that themes and modules * can override these state change handlers for particular parts of a page. */ const $document = $(document); $document.on('state:disabled', (e) => { // Only act when this change was triggered by a dependency and not by the // element monitoring itself. const tagsSupportDisable = 'button, fieldset, optgroup, option, select, textarea, input'; if (e.trigger) { $(e.target) .closest('.js-form-item, .js-form-submit, .js-form-wrapper') .toggleClass('form-disabled', e.value) .find(tagsSupportDisable) .addBack(tagsSupportDisable) .prop('disabled', e.value); } }); $document.on('state:readonly', (e) => { if (e.trigger) { $(e.target) .closest('.js-form-item, .js-form-submit, .js-form-wrapper') .toggleClass('form-readonly', e.value) .find('input, textarea') .prop('readonly', e.value); } }); $document.on('state:required', (e) => { if (e.trigger) { if (e.value) { const label = `label${e.target.id ? `[for=${e.target.id}]` : ''}`; const $label = $(e.target) .attr({ required: 'required', 'aria-required': 'true' }) .closest('.js-form-item, .js-form-wrapper') .find(label); // Avoids duplicate required markers on initialization. if (!$label.hasClass('js-form-required').length) { $label.addClass('js-form-required form-required'); } } else { $(e.target) .removeAttr('required aria-required') .closest('.js-form-item, .js-form-wrapper') .find('label.js-form-required') .removeClass('js-form-required form-required'); } } }); $document.on('state:visible', (e) => { if (e.trigger) { let $element = $(e.target).closest( '.js-form-item, .js-form-submit, .js-form-wrapper', ); // For links, update the state of itself instead of the wrapper. if (e.target.tagName === 'A') { $element = $(e.target); } $element.toggle(e.value); } }); $document.on('state:checked', (e) => { if (e.trigger) { $(e.target) .closest('.js-form-item, .js-form-wrapper') .find('input') .prop('checked', e.value) .trigger('change'); } }); $document.on('state:collapsed', (e) => { if (e.trigger) { if (e.target.hasAttribute('open') === e.value) { $(e.target).find('> summary').trigger('click'); } } }); })(jQuery, Drupal);