Newer
Older
Alex Pott
committed
/**
* @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
*/
Jess
committed
const states = {
Alex Pott
committed
/**
* An array of functions that should be postponed.
*/
postponed: [],
Alex Pott
committed
};
Jess
committed
Drupal.states = states;
Alex Pott
committed
/**
* 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++) {
Angie Byron
committed
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],
});
});
Alex Pott
committed
}
// Execute all postponed functions now.
while (states.postponed.length) {
(states.postponed.shift())();
}
},
Alex Pott
committed
};
/**
* 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);
Alex Pott
committed
this.dependees = this.getDependees();
Angie Byron
committed
Object.keys(this.dependees || {}).forEach((selector) => {
this.initializeDependee(selector, this.dependees[selector]);
});
Alex Pott
committed
};
/**
* 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} Number
*/
states.Dependent.comparisons = {
RegExp(reference, value) {
Alex Pott
committed
return reference.test(value);
},
Function(reference, value) {
Alex Pott
committed
// The "reference" variable is a comparison function.
return reference(value);
},
Number(reference, value) {
Alex Pott
committed
// 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);
},
Alex Pott
committed
};
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) {
Alex Pott
committed
// Cache for the states of this dependee.
this.values[selector] = {};
Alex Pott
committed
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;
}
Alex Pott
committed
Alex Pott
committed
state = states.State.sanitize(state);
Alex Pott
committed
Alex Pott
committed
// Initialize the value of this state.
this.values[selector][state.name] = null;
Alex Pott
committed
Alex Pott
committed
// 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);
});
Alex Pott
committed
Alex Pott
committed
// Make sure the event we just bound ourselves to is actually fired.
new states.Trigger({ selector, state });
});
Alex Pott
committed
},
/**
* 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 {bool}
* true or false.
*/
compare(reference, selector, state) {
const value = this.values[selector][state.name];
Alex Pott
committed
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);
}
Alex Pott
committed
// Do a plain comparison otherwise.
return compare(reference, value);
Alex Pott
committed
},
/**
* 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) {
Alex Pott
committed
// 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() {
Alex Pott
committed
// Check whether any constraint for this dependent state is satisfied.
let value = this.verifyConstraints(this.constraints);
Alex Pott
committed
// 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 });
Alex Pott
committed
}
},
/**
* 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 {bool}
* true or false, depending on whether these constraints are satisfied.
*/
verifyConstraints(constraints, selector) {
let result;
Alex Pott
committed
if ($.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++) {
Alex Pott
committed
if (constraints[i] !== 'xor') {
const constraint = this.checkConstraints(constraints[i], selector, i);
Alex Pott
committed
// 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).
Alex Pott
committed
result = Object.keys(constraints).every((constraint) => {
const check = this.checkConstraints(
constraints[constraint],
selector,
constraint,
);
/**
* The checkConstraints() function's return value can be undefined. If
* this so, consider it to have returned true.
*/
return typeof check === 'undefined' ? true : check;
});
Alex Pott
committed
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
}
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 {bool}
* true or false, depending on whether this constraint is satisfied.
*/
checkConstraints(value, selector, state) {
Alex Pott
committed
// 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);
}
Alex Pott
committed
// Resolve this constraint as an AND/OR operator.
return this.verifyConstraints(value, selector);
Alex Pott
committed
},
/**
* Gathers information about all required triggers.
*
* @memberof Drupal.states.Dependent#
*
* @return {object}
* An object describing the required triggers.
*/
getDependees() {
const cache = {};
Alex Pott
committed
// Swivel the lookup function so that we can record all available
// selector- state combinations for initialization.
const _compare = this.compare;
Alex Pott
committed
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;
},
Alex Pott
committed
};
/**
* @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}`)) {
Alex Pott
committed
this.initialize();
}
}
};
states.Trigger.prototype = {
/**
* @memberof Drupal.states.Trigger#
*/
initialize() {
const trigger = states.Trigger.states[this.state];
Alex Pott
committed
if (typeof trigger === 'function') {
// We have a custom trigger initialization function.
trigger.call(window, this.element);
}
else {
Angie Byron
committed
Object.keys(trigger || {}).forEach((event) => {
this.defaultTrigger(event, trigger[event]);
});
Alex Pott
committed
}
// Mark this trigger as initialized for this element.
this.element.data(`trigger:${this.state}`, true);
Alex Pott
committed
},
/**
* @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);
Alex Pott
committed
// Attach the event callback.
this.element.on(event, $.proxy(function (e) {
const value = valueFn.call(this.element, e);
Alex Pott
committed
// Only trigger the event if the value has actually changed.
if (oldValue !== value) {
this.element.trigger({ type: `state:${this.state}`, value, oldValue });
Alex Pott
committed
oldValue = value;
}
}, this));
states.postponed.push($.proxy(function () {
// Trigger the event once for initialization purposes.
this.element.trigger({ type: `state:${this.state}`, value: oldValue, oldValue: null });
Alex Pott
committed
}, this));
},
Alex Pott
committed
};
/**
* 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() {
Alex Pott
committed
// The function associated with that trigger returns the new value for
// the state.
return this.val() === '';
},
Alex Pott
committed
},
checked: {
change() {
Alex Pott
committed
// 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;
Alex Pott
committed
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;
},
Alex Pott
committed
},
// For radio buttons, only return the value if the radio button is selected.
value: {
keyup() {
Alex Pott
committed
// 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() {
Alex Pott
committed
// 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();
},
Alex Pott
committed
},
collapsed: {
collapsed(e) {
Alex Pott
committed
return (typeof e !== 'undefined' && 'value' in e) ? e.value : !this.is('[open]');
},
},
Alex Pott
committed
};
/**
* 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.
*/
Jess
committed
this.pristine = state;
this.name = state;
Alex Pott
committed
// Normalize the state name.
let process = true;
Alex Pott
committed
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
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);
Alex Pott
committed
};
/**
* 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',
Alex Pott
committed
};
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() {
Alex Pott
committed
return this.name;
},
Alex Pott
committed
};
/**
* 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) => {
Alex Pott
committed
// Only act when this change was triggered by a dependency and not by the
// element monitoring itself.
if (e.trigger) {
$(e.target)
.prop('disabled', e.value)
.closest('.js-form-item, .js-form-submit, .js-form-wrapper')
.toggleClass('form-disabled', e.value)
.find('select, input, textarea')
.prop('disabled', e.value);
Alex Pott
committed
// Note: WebKit nightlies don't reflect that change correctly.
// See https://bugs.webkit.org/show_bug.cgi?id=23789
}
});
$document.on('state:required', (e) => {
Alex Pott
committed
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': 'aria-required' }).closest('.js-form-item, .js-form-wrapper').find(label);
Alex Pott
committed
// 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');
Alex Pott
committed
}
}
});
$document.on('state:visible', (e) => {
Alex Pott
committed
if (e.trigger) {
$(e.target).closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggle(e.value);
}
});
$document.on('state:checked', (e) => {
Alex Pott
committed
if (e.trigger) {
$(e.target).prop('checked', e.value);
}
});
$document.on('state:collapsed', (e) => {
Alex Pott
committed
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
if (e.trigger) {
if ($(e.target).is('[open]') === e.value) {
$(e.target).find('> summary').trigger('click');
}
}
});
/**
* These are helper functions implementing addition "operators" and don't
* implement any logic that is particular to states.
*/
/**
* Inverts a (if it's not undefined) when invertState is true.
*
* @function Drupal.states~invert
*
* @param {*} a
* The value to maybe invert.
* @param {bool} invertState
* Whether to invert state or not.
*
* @return {bool}
* 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 {bool}
* The comparison result.
*/
function compare(a, b) {
if (a === b) {
return typeof a === 'undefined' ? a : true;
}
return typeof a === 'undefined' || typeof b === 'undefined';
}
}(jQuery, Drupal));