You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
769 lines
23 KiB
769 lines
23 KiB
/** |
|
* @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) { |
|
// Uses once to avoid duplicates if attach is called multiple times. |
|
const elements = once('states', '[data-drupal-states]', context); |
|
const il = elements.length; |
|
for (let i = 0; i < il; i++) { |
|
const config = JSON.parse( |
|
elements[i].getAttribute('data-drupal-states'), |
|
); |
|
Object.keys(config || {}).forEach((state) => { |
|
new states.Dependent({ |
|
element: $(elements[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' }) |
|
.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') |
|
.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);
|
|
|