Chris@0
|
1 /**
|
Chris@0
|
2 * @file
|
Chris@0
|
3 * Drupal's states library.
|
Chris@0
|
4 */
|
Chris@0
|
5
|
Chris@17
|
6 (function($, Drupal) {
|
Chris@0
|
7 /**
|
Chris@0
|
8 * The base States namespace.
|
Chris@0
|
9 *
|
Chris@0
|
10 * Having the local states variable allows us to use the States namespace
|
Chris@0
|
11 * without having to always declare "Drupal.states".
|
Chris@0
|
12 *
|
Chris@0
|
13 * @namespace Drupal.states
|
Chris@0
|
14 */
|
Chris@14
|
15 const states = {
|
Chris@0
|
16 /**
|
Chris@0
|
17 * An array of functions that should be postponed.
|
Chris@0
|
18 */
|
Chris@0
|
19 postponed: [],
|
Chris@0
|
20 };
|
Chris@0
|
21
|
Chris@14
|
22 Drupal.states = states;
|
Chris@14
|
23
|
Chris@0
|
24 /**
|
Chris@17
|
25 * Inverts a (if it's not undefined) when invertState is true.
|
Chris@17
|
26 *
|
Chris@17
|
27 * @function Drupal.states~invert
|
Chris@17
|
28 *
|
Chris@17
|
29 * @param {*} a
|
Chris@17
|
30 * The value to maybe invert.
|
Chris@17
|
31 * @param {bool} invertState
|
Chris@17
|
32 * Whether to invert state or not.
|
Chris@17
|
33 *
|
Chris@17
|
34 * @return {bool}
|
Chris@17
|
35 * The result.
|
Chris@17
|
36 */
|
Chris@17
|
37 function invert(a, invertState) {
|
Chris@17
|
38 return invertState && typeof a !== 'undefined' ? !a : a;
|
Chris@17
|
39 }
|
Chris@17
|
40
|
Chris@17
|
41 /**
|
Chris@17
|
42 * Compares two values while ignoring undefined values.
|
Chris@17
|
43 *
|
Chris@17
|
44 * @function Drupal.states~compare
|
Chris@17
|
45 *
|
Chris@17
|
46 * @param {*} a
|
Chris@17
|
47 * Value a.
|
Chris@17
|
48 * @param {*} b
|
Chris@17
|
49 * Value b.
|
Chris@17
|
50 *
|
Chris@17
|
51 * @return {bool}
|
Chris@17
|
52 * The comparison result.
|
Chris@17
|
53 */
|
Chris@17
|
54 function compare(a, b) {
|
Chris@17
|
55 if (a === b) {
|
Chris@17
|
56 return typeof a === 'undefined' ? a : true;
|
Chris@17
|
57 }
|
Chris@17
|
58
|
Chris@17
|
59 return typeof a === 'undefined' || typeof b === 'undefined';
|
Chris@17
|
60 }
|
Chris@17
|
61
|
Chris@17
|
62 /**
|
Chris@17
|
63 * Bitwise AND with a third undefined state.
|
Chris@17
|
64 *
|
Chris@17
|
65 * @function Drupal.states~ternary
|
Chris@17
|
66 *
|
Chris@17
|
67 * @param {*} a
|
Chris@17
|
68 * Value a.
|
Chris@17
|
69 * @param {*} b
|
Chris@17
|
70 * Value b
|
Chris@17
|
71 *
|
Chris@17
|
72 * @return {bool}
|
Chris@17
|
73 * The result.
|
Chris@17
|
74 */
|
Chris@17
|
75 function ternary(a, b) {
|
Chris@17
|
76 if (typeof a === 'undefined') {
|
Chris@17
|
77 return b;
|
Chris@17
|
78 }
|
Chris@17
|
79 if (typeof b === 'undefined') {
|
Chris@17
|
80 return a;
|
Chris@17
|
81 }
|
Chris@17
|
82
|
Chris@17
|
83 return a && b;
|
Chris@17
|
84 }
|
Chris@17
|
85
|
Chris@17
|
86 /**
|
Chris@0
|
87 * Attaches the states.
|
Chris@0
|
88 *
|
Chris@0
|
89 * @type {Drupal~behavior}
|
Chris@0
|
90 *
|
Chris@0
|
91 * @prop {Drupal~behaviorAttach} attach
|
Chris@0
|
92 * Attaches states behaviors.
|
Chris@0
|
93 */
|
Chris@0
|
94 Drupal.behaviors.states = {
|
Chris@0
|
95 attach(context, settings) {
|
Chris@0
|
96 const $states = $(context).find('[data-drupal-states]');
|
Chris@0
|
97 const il = $states.length;
|
Chris@0
|
98 for (let i = 0; i < il; i++) {
|
Chris@17
|
99 const config = JSON.parse(
|
Chris@17
|
100 $states[i].getAttribute('data-drupal-states'),
|
Chris@17
|
101 );
|
Chris@17
|
102 Object.keys(config || {}).forEach(state => {
|
Chris@14
|
103 new states.Dependent({
|
Chris@14
|
104 element: $($states[i]),
|
Chris@14
|
105 state: states.State.sanitize(state),
|
Chris@14
|
106 constraints: config[state],
|
Chris@14
|
107 });
|
Chris@14
|
108 });
|
Chris@0
|
109 }
|
Chris@0
|
110
|
Chris@0
|
111 // Execute all postponed functions now.
|
Chris@0
|
112 while (states.postponed.length) {
|
Chris@17
|
113 states.postponed.shift()();
|
Chris@0
|
114 }
|
Chris@0
|
115 },
|
Chris@0
|
116 };
|
Chris@0
|
117
|
Chris@0
|
118 /**
|
Chris@0
|
119 * Object representing an element that depends on other elements.
|
Chris@0
|
120 *
|
Chris@0
|
121 * @constructor Drupal.states.Dependent
|
Chris@0
|
122 *
|
Chris@0
|
123 * @param {object} args
|
Chris@0
|
124 * Object with the following keys (all of which are required)
|
Chris@0
|
125 * @param {jQuery} args.element
|
Chris@0
|
126 * A jQuery object of the dependent element
|
Chris@0
|
127 * @param {Drupal.states.State} args.state
|
Chris@0
|
128 * A State object describing the state that is dependent
|
Chris@0
|
129 * @param {object} args.constraints
|
Chris@0
|
130 * An object with dependency specifications. Lists all elements that this
|
Chris@0
|
131 * element depends on. It can be nested and can contain
|
Chris@0
|
132 * arbitrary AND and OR clauses.
|
Chris@0
|
133 */
|
Chris@17
|
134 states.Dependent = function(args) {
|
Chris@0
|
135 $.extend(this, { values: {}, oldValue: null }, args);
|
Chris@0
|
136
|
Chris@0
|
137 this.dependees = this.getDependees();
|
Chris@17
|
138 Object.keys(this.dependees || {}).forEach(selector => {
|
Chris@14
|
139 this.initializeDependee(selector, this.dependees[selector]);
|
Chris@14
|
140 });
|
Chris@0
|
141 };
|
Chris@0
|
142
|
Chris@0
|
143 /**
|
Chris@0
|
144 * Comparison functions for comparing the value of an element with the
|
Chris@0
|
145 * specification from the dependency settings. If the object type can't be
|
Chris@0
|
146 * found in this list, the === operator is used by default.
|
Chris@0
|
147 *
|
Chris@0
|
148 * @name Drupal.states.Dependent.comparisons
|
Chris@0
|
149 *
|
Chris@0
|
150 * @prop {function} RegExp
|
Chris@0
|
151 * @prop {function} Function
|
Chris@0
|
152 * @prop {function} Number
|
Chris@0
|
153 */
|
Chris@0
|
154 states.Dependent.comparisons = {
|
Chris@0
|
155 RegExp(reference, value) {
|
Chris@0
|
156 return reference.test(value);
|
Chris@0
|
157 },
|
Chris@0
|
158 Function(reference, value) {
|
Chris@0
|
159 // The "reference" variable is a comparison function.
|
Chris@0
|
160 return reference(value);
|
Chris@0
|
161 },
|
Chris@0
|
162 Number(reference, value) {
|
Chris@0
|
163 // If "reference" is a number and "value" is a string, then cast
|
Chris@0
|
164 // reference as a string before applying the strict comparison in
|
Chris@0
|
165 // compare().
|
Chris@0
|
166 // Otherwise numeric keys in the form's #states array fail to match
|
Chris@0
|
167 // string values returned from jQuery's val().
|
Chris@17
|
168 return typeof value === 'string'
|
Chris@17
|
169 ? compare(reference.toString(), value)
|
Chris@17
|
170 : compare(reference, value);
|
Chris@0
|
171 },
|
Chris@0
|
172 };
|
Chris@0
|
173
|
Chris@0
|
174 states.Dependent.prototype = {
|
Chris@0
|
175 /**
|
Chris@0
|
176 * Initializes one of the elements this dependent depends on.
|
Chris@0
|
177 *
|
Chris@0
|
178 * @memberof Drupal.states.Dependent#
|
Chris@0
|
179 *
|
Chris@0
|
180 * @param {string} selector
|
Chris@0
|
181 * The CSS selector describing the dependee.
|
Chris@0
|
182 * @param {object} dependeeStates
|
Chris@0
|
183 * The list of states that have to be monitored for tracking the
|
Chris@0
|
184 * dependee's compliance status.
|
Chris@0
|
185 */
|
Chris@0
|
186 initializeDependee(selector, dependeeStates) {
|
Chris@0
|
187 // Cache for the states of this dependee.
|
Chris@0
|
188 this.values[selector] = {};
|
Chris@0
|
189
|
Chris@17
|
190 Object.keys(dependeeStates).forEach(i => {
|
Chris@17
|
191 let state = dependeeStates[i];
|
Chris@17
|
192 // Make sure we're not initializing this selector/state combination
|
Chris@17
|
193 // twice.
|
Chris@17
|
194 if ($.inArray(state, dependeeStates) === -1) {
|
Chris@17
|
195 return;
|
Chris@17
|
196 }
|
Chris@0
|
197
|
Chris@17
|
198 state = states.State.sanitize(state);
|
Chris@0
|
199
|
Chris@17
|
200 // Initialize the value of this state.
|
Chris@17
|
201 this.values[selector][state.name] = null;
|
Chris@0
|
202
|
Chris@17
|
203 // Monitor state changes of the specified state for this dependee.
|
Chris@17
|
204 $(selector).on(`state:${state}`, { selector, state }, e => {
|
Chris@17
|
205 this.update(e.data.selector, e.data.state, e.value);
|
Chris@17
|
206 });
|
Chris@0
|
207
|
Chris@17
|
208 // Make sure the event we just bound ourselves to is actually fired.
|
Chris@17
|
209 new states.Trigger({ selector, state });
|
Chris@17
|
210 });
|
Chris@0
|
211 },
|
Chris@0
|
212
|
Chris@0
|
213 /**
|
Chris@0
|
214 * Compares a value with a reference value.
|
Chris@0
|
215 *
|
Chris@0
|
216 * @memberof Drupal.states.Dependent#
|
Chris@0
|
217 *
|
Chris@0
|
218 * @param {object} reference
|
Chris@0
|
219 * The value used for reference.
|
Chris@0
|
220 * @param {string} selector
|
Chris@0
|
221 * CSS selector describing the dependee.
|
Chris@0
|
222 * @param {Drupal.states.State} state
|
Chris@0
|
223 * A State object describing the dependee's updated state.
|
Chris@0
|
224 *
|
Chris@0
|
225 * @return {bool}
|
Chris@0
|
226 * true or false.
|
Chris@0
|
227 */
|
Chris@0
|
228 compare(reference, selector, state) {
|
Chris@0
|
229 const value = this.values[selector][state.name];
|
Chris@0
|
230 if (reference.constructor.name in states.Dependent.comparisons) {
|
Chris@0
|
231 // Use a custom compare function for certain reference value types.
|
Chris@17
|
232 return states.Dependent.comparisons[reference.constructor.name](
|
Chris@17
|
233 reference,
|
Chris@17
|
234 value,
|
Chris@17
|
235 );
|
Chris@0
|
236 }
|
Chris@0
|
237
|
Chris@17
|
238 // Do a plain comparison otherwise.
|
Chris@0
|
239 return compare(reference, value);
|
Chris@0
|
240 },
|
Chris@0
|
241
|
Chris@0
|
242 /**
|
Chris@0
|
243 * Update the value of a dependee's state.
|
Chris@0
|
244 *
|
Chris@0
|
245 * @memberof Drupal.states.Dependent#
|
Chris@0
|
246 *
|
Chris@0
|
247 * @param {string} selector
|
Chris@0
|
248 * CSS selector describing the dependee.
|
Chris@0
|
249 * @param {Drupal.states.state} state
|
Chris@0
|
250 * A State object describing the dependee's updated state.
|
Chris@0
|
251 * @param {string} value
|
Chris@0
|
252 * The new value for the dependee's updated state.
|
Chris@0
|
253 */
|
Chris@0
|
254 update(selector, state, value) {
|
Chris@0
|
255 // Only act when the 'new' value is actually new.
|
Chris@0
|
256 if (value !== this.values[selector][state.name]) {
|
Chris@0
|
257 this.values[selector][state.name] = value;
|
Chris@0
|
258 this.reevaluate();
|
Chris@0
|
259 }
|
Chris@0
|
260 },
|
Chris@0
|
261
|
Chris@0
|
262 /**
|
Chris@0
|
263 * Triggers change events in case a state changed.
|
Chris@0
|
264 *
|
Chris@0
|
265 * @memberof Drupal.states.Dependent#
|
Chris@0
|
266 */
|
Chris@0
|
267 reevaluate() {
|
Chris@0
|
268 // Check whether any constraint for this dependent state is satisfied.
|
Chris@0
|
269 let value = this.verifyConstraints(this.constraints);
|
Chris@0
|
270
|
Chris@0
|
271 // Only invoke a state change event when the value actually changed.
|
Chris@0
|
272 if (value !== this.oldValue) {
|
Chris@0
|
273 // Store the new value so that we can compare later whether the value
|
Chris@0
|
274 // actually changed.
|
Chris@0
|
275 this.oldValue = value;
|
Chris@0
|
276
|
Chris@0
|
277 // Normalize the value to match the normalized state name.
|
Chris@0
|
278 value = invert(value, this.state.invert);
|
Chris@0
|
279
|
Chris@0
|
280 // By adding "trigger: true", we ensure that state changes don't go into
|
Chris@0
|
281 // infinite loops.
|
Chris@17
|
282 this.element.trigger({
|
Chris@17
|
283 type: `state:${this.state}`,
|
Chris@17
|
284 value,
|
Chris@17
|
285 trigger: true,
|
Chris@17
|
286 });
|
Chris@0
|
287 }
|
Chris@0
|
288 },
|
Chris@0
|
289
|
Chris@0
|
290 /**
|
Chris@0
|
291 * Evaluates child constraints to determine if a constraint is satisfied.
|
Chris@0
|
292 *
|
Chris@0
|
293 * @memberof Drupal.states.Dependent#
|
Chris@0
|
294 *
|
Chris@0
|
295 * @param {object|Array} constraints
|
Chris@0
|
296 * A constraint object or an array of constraints.
|
Chris@0
|
297 * @param {string} selector
|
Chris@0
|
298 * The selector for these constraints. If undefined, there isn't yet a
|
Chris@0
|
299 * selector that these constraints apply to. In that case, the keys of the
|
Chris@0
|
300 * object are interpreted as the selector if encountered.
|
Chris@0
|
301 *
|
Chris@0
|
302 * @return {bool}
|
Chris@0
|
303 * true or false, depending on whether these constraints are satisfied.
|
Chris@0
|
304 */
|
Chris@0
|
305 verifyConstraints(constraints, selector) {
|
Chris@0
|
306 let result;
|
Chris@0
|
307 if ($.isArray(constraints)) {
|
Chris@0
|
308 // This constraint is an array (OR or XOR).
|
Chris@0
|
309 const hasXor = $.inArray('xor', constraints) === -1;
|
Chris@0
|
310 const len = constraints.length;
|
Chris@0
|
311 for (let i = 0; i < len; i++) {
|
Chris@0
|
312 if (constraints[i] !== 'xor') {
|
Chris@17
|
313 const constraint = this.checkConstraints(
|
Chris@17
|
314 constraints[i],
|
Chris@17
|
315 selector,
|
Chris@17
|
316 i,
|
Chris@17
|
317 );
|
Chris@0
|
318 // Return if this is OR and we have a satisfied constraint or if
|
Chris@0
|
319 // this is XOR and we have a second satisfied constraint.
|
Chris@0
|
320 if (constraint && (hasXor || result)) {
|
Chris@0
|
321 return hasXor;
|
Chris@0
|
322 }
|
Chris@0
|
323 result = result || constraint;
|
Chris@0
|
324 }
|
Chris@0
|
325 }
|
Chris@0
|
326 }
|
Chris@0
|
327 // Make sure we don't try to iterate over things other than objects. This
|
Chris@0
|
328 // shouldn't normally occur, but in case the condition definition is
|
Chris@0
|
329 // bogus, we don't want to end up with an infinite loop.
|
Chris@0
|
330 else if ($.isPlainObject(constraints)) {
|
Chris@0
|
331 // This constraint is an object (AND).
|
Chris@14
|
332 // eslint-disable-next-line no-restricted-syntax
|
Chris@0
|
333 for (const n in constraints) {
|
Chris@0
|
334 if (constraints.hasOwnProperty(n)) {
|
Chris@17
|
335 result = ternary(
|
Chris@17
|
336 result,
|
Chris@17
|
337 this.checkConstraints(constraints[n], selector, n),
|
Chris@17
|
338 );
|
Chris@0
|
339 // False and anything else will evaluate to false, so return when
|
Chris@0
|
340 // any false condition is found.
|
Chris@0
|
341 if (result === false) {
|
Chris@0
|
342 return false;
|
Chris@0
|
343 }
|
Chris@0
|
344 }
|
Chris@0
|
345 }
|
Chris@0
|
346 }
|
Chris@0
|
347 return result;
|
Chris@0
|
348 },
|
Chris@0
|
349
|
Chris@0
|
350 /**
|
Chris@0
|
351 * Checks whether the value matches the requirements for this constraint.
|
Chris@0
|
352 *
|
Chris@0
|
353 * @memberof Drupal.states.Dependent#
|
Chris@0
|
354 *
|
Chris@0
|
355 * @param {string|Array|object} value
|
Chris@0
|
356 * Either the value of a state or an array/object of constraints. In the
|
Chris@0
|
357 * latter case, resolving the constraint continues.
|
Chris@0
|
358 * @param {string} [selector]
|
Chris@0
|
359 * The selector for this constraint. If undefined, there isn't yet a
|
Chris@0
|
360 * selector that this constraint applies to. In that case, the state key
|
Chris@0
|
361 * is propagates to a selector and resolving continues.
|
Chris@0
|
362 * @param {Drupal.states.State} [state]
|
Chris@0
|
363 * The state to check for this constraint. If undefined, resolving
|
Chris@0
|
364 * continues. If both selector and state aren't undefined and valid
|
Chris@0
|
365 * non-numeric strings, a lookup for the actual value of that selector's
|
Chris@0
|
366 * state is performed. This parameter is not a State object but a pristine
|
Chris@0
|
367 * state string.
|
Chris@0
|
368 *
|
Chris@0
|
369 * @return {bool}
|
Chris@0
|
370 * true or false, depending on whether this constraint is satisfied.
|
Chris@0
|
371 */
|
Chris@0
|
372 checkConstraints(value, selector, state) {
|
Chris@0
|
373 // Normalize the last parameter. If it's non-numeric, we treat it either
|
Chris@0
|
374 // as a selector (in case there isn't one yet) or as a trigger/state.
|
Chris@17
|
375 if (typeof state !== 'string' || /[0-9]/.test(state[0])) {
|
Chris@0
|
376 state = null;
|
Chris@17
|
377 } else if (typeof selector === 'undefined') {
|
Chris@0
|
378 // Propagate the state to the selector when there isn't one yet.
|
Chris@0
|
379 selector = state;
|
Chris@0
|
380 state = null;
|
Chris@0
|
381 }
|
Chris@0
|
382
|
Chris@0
|
383 if (state !== null) {
|
Chris@0
|
384 // Constraints is the actual constraints of an element to check for.
|
Chris@0
|
385 state = states.State.sanitize(state);
|
Chris@0
|
386 return invert(this.compare(value, selector, state), state.invert);
|
Chris@0
|
387 }
|
Chris@0
|
388
|
Chris@17
|
389 // Resolve this constraint as an AND/OR operator.
|
Chris@0
|
390 return this.verifyConstraints(value, selector);
|
Chris@0
|
391 },
|
Chris@0
|
392
|
Chris@0
|
393 /**
|
Chris@0
|
394 * Gathers information about all required triggers.
|
Chris@0
|
395 *
|
Chris@0
|
396 * @memberof Drupal.states.Dependent#
|
Chris@0
|
397 *
|
Chris@0
|
398 * @return {object}
|
Chris@0
|
399 * An object describing the required triggers.
|
Chris@0
|
400 */
|
Chris@0
|
401 getDependees() {
|
Chris@0
|
402 const cache = {};
|
Chris@0
|
403 // Swivel the lookup function so that we can record all available
|
Chris@0
|
404 // selector- state combinations for initialization.
|
Chris@0
|
405 const _compare = this.compare;
|
Chris@17
|
406 this.compare = function(reference, selector, state) {
|
Chris@0
|
407 (cache[selector] || (cache[selector] = [])).push(state.name);
|
Chris@0
|
408 // Return nothing (=== undefined) so that the constraint loops are not
|
Chris@0
|
409 // broken.
|
Chris@0
|
410 };
|
Chris@0
|
411
|
Chris@0
|
412 // This call doesn't actually verify anything but uses the resolving
|
Chris@0
|
413 // mechanism to go through the constraints array, trying to look up each
|
Chris@0
|
414 // value. Since we swivelled the compare function, this comparison returns
|
Chris@0
|
415 // undefined and lookup continues until the very end. Instead of lookup up
|
Chris@0
|
416 // the value, we record that combination of selector and state so that we
|
Chris@0
|
417 // can initialize all triggers.
|
Chris@0
|
418 this.verifyConstraints(this.constraints);
|
Chris@0
|
419 // Restore the original function.
|
Chris@0
|
420 this.compare = _compare;
|
Chris@0
|
421
|
Chris@0
|
422 return cache;
|
Chris@0
|
423 },
|
Chris@0
|
424 };
|
Chris@0
|
425
|
Chris@0
|
426 /**
|
Chris@0
|
427 * @constructor Drupal.states.Trigger
|
Chris@0
|
428 *
|
Chris@0
|
429 * @param {object} args
|
Chris@0
|
430 * Trigger arguments.
|
Chris@0
|
431 */
|
Chris@17
|
432 states.Trigger = function(args) {
|
Chris@0
|
433 $.extend(this, args);
|
Chris@0
|
434
|
Chris@0
|
435 if (this.state in states.Trigger.states) {
|
Chris@0
|
436 this.element = $(this.selector);
|
Chris@0
|
437
|
Chris@0
|
438 // Only call the trigger initializer when it wasn't yet attached to this
|
Chris@0
|
439 // element. Otherwise we'd end up with duplicate events.
|
Chris@0
|
440 if (!this.element.data(`trigger:${this.state}`)) {
|
Chris@0
|
441 this.initialize();
|
Chris@0
|
442 }
|
Chris@0
|
443 }
|
Chris@0
|
444 };
|
Chris@0
|
445
|
Chris@0
|
446 states.Trigger.prototype = {
|
Chris@0
|
447 /**
|
Chris@0
|
448 * @memberof Drupal.states.Trigger#
|
Chris@0
|
449 */
|
Chris@0
|
450 initialize() {
|
Chris@0
|
451 const trigger = states.Trigger.states[this.state];
|
Chris@0
|
452
|
Chris@0
|
453 if (typeof trigger === 'function') {
|
Chris@0
|
454 // We have a custom trigger initialization function.
|
Chris@0
|
455 trigger.call(window, this.element);
|
Chris@17
|
456 } else {
|
Chris@17
|
457 Object.keys(trigger || {}).forEach(event => {
|
Chris@14
|
458 this.defaultTrigger(event, trigger[event]);
|
Chris@14
|
459 });
|
Chris@0
|
460 }
|
Chris@0
|
461
|
Chris@0
|
462 // Mark this trigger as initialized for this element.
|
Chris@0
|
463 this.element.data(`trigger:${this.state}`, true);
|
Chris@0
|
464 },
|
Chris@0
|
465
|
Chris@0
|
466 /**
|
Chris@0
|
467 * @memberof Drupal.states.Trigger#
|
Chris@0
|
468 *
|
Chris@0
|
469 * @param {jQuery.Event} event
|
Chris@0
|
470 * The event triggered.
|
Chris@0
|
471 * @param {function} valueFn
|
Chris@0
|
472 * The function to call.
|
Chris@0
|
473 */
|
Chris@0
|
474 defaultTrigger(event, valueFn) {
|
Chris@0
|
475 let oldValue = valueFn.call(this.element);
|
Chris@0
|
476
|
Chris@0
|
477 // Attach the event callback.
|
Chris@17
|
478 this.element.on(
|
Chris@17
|
479 event,
|
Chris@17
|
480 $.proxy(function(e) {
|
Chris@17
|
481 const value = valueFn.call(this.element, e);
|
Chris@17
|
482 // Only trigger the event if the value has actually changed.
|
Chris@17
|
483 if (oldValue !== value) {
|
Chris@17
|
484 this.element.trigger({
|
Chris@17
|
485 type: `state:${this.state}`,
|
Chris@17
|
486 value,
|
Chris@17
|
487 oldValue,
|
Chris@17
|
488 });
|
Chris@17
|
489 oldValue = value;
|
Chris@17
|
490 }
|
Chris@17
|
491 }, this),
|
Chris@17
|
492 );
|
Chris@0
|
493
|
Chris@17
|
494 states.postponed.push(
|
Chris@17
|
495 $.proxy(function() {
|
Chris@17
|
496 // Trigger the event once for initialization purposes.
|
Chris@17
|
497 this.element.trigger({
|
Chris@17
|
498 type: `state:${this.state}`,
|
Chris@17
|
499 value: oldValue,
|
Chris@17
|
500 oldValue: null,
|
Chris@17
|
501 });
|
Chris@17
|
502 }, this),
|
Chris@17
|
503 );
|
Chris@0
|
504 },
|
Chris@0
|
505 };
|
Chris@0
|
506
|
Chris@0
|
507 /**
|
Chris@0
|
508 * This list of states contains functions that are used to monitor the state
|
Chris@0
|
509 * of an element. Whenever an element depends on the state of another element,
|
Chris@0
|
510 * one of these trigger functions is added to the dependee so that the
|
Chris@0
|
511 * dependent element can be updated.
|
Chris@0
|
512 *
|
Chris@0
|
513 * @name Drupal.states.Trigger.states
|
Chris@0
|
514 *
|
Chris@0
|
515 * @prop empty
|
Chris@0
|
516 * @prop checked
|
Chris@0
|
517 * @prop value
|
Chris@0
|
518 * @prop collapsed
|
Chris@0
|
519 */
|
Chris@0
|
520 states.Trigger.states = {
|
Chris@0
|
521 // 'empty' describes the state to be monitored.
|
Chris@0
|
522 empty: {
|
Chris@0
|
523 // 'keyup' is the (native DOM) event that we watch for.
|
Chris@0
|
524 keyup() {
|
Chris@0
|
525 // The function associated with that trigger returns the new value for
|
Chris@0
|
526 // the state.
|
Chris@0
|
527 return this.val() === '';
|
Chris@0
|
528 },
|
Chris@0
|
529 },
|
Chris@0
|
530
|
Chris@0
|
531 checked: {
|
Chris@0
|
532 change() {
|
Chris@0
|
533 // prop() and attr() only takes the first element into account. To
|
Chris@0
|
534 // support selectors matching multiple checkboxes, iterate over all and
|
Chris@0
|
535 // return whether any is checked.
|
Chris@0
|
536 let checked = false;
|
Chris@17
|
537 this.each(function() {
|
Chris@0
|
538 // Use prop() here as we want a boolean of the checkbox state.
|
Chris@0
|
539 // @see http://api.jquery.com/prop/
|
Chris@0
|
540 checked = $(this).prop('checked');
|
Chris@0
|
541 // Break the each() loop if this is checked.
|
Chris@0
|
542 return !checked;
|
Chris@0
|
543 });
|
Chris@0
|
544 return checked;
|
Chris@0
|
545 },
|
Chris@0
|
546 },
|
Chris@0
|
547
|
Chris@0
|
548 // For radio buttons, only return the value if the radio button is selected.
|
Chris@0
|
549 value: {
|
Chris@0
|
550 keyup() {
|
Chris@0
|
551 // Radio buttons share the same :input[name="key"] selector.
|
Chris@0
|
552 if (this.length > 1) {
|
Chris@0
|
553 // Initial checked value of radios is undefined, so we return false.
|
Chris@0
|
554 return this.filter(':checked').val() || false;
|
Chris@0
|
555 }
|
Chris@0
|
556 return this.val();
|
Chris@0
|
557 },
|
Chris@0
|
558 change() {
|
Chris@0
|
559 // Radio buttons share the same :input[name="key"] selector.
|
Chris@0
|
560 if (this.length > 1) {
|
Chris@0
|
561 // Initial checked value of radios is undefined, so we return false.
|
Chris@0
|
562 return this.filter(':checked').val() || false;
|
Chris@0
|
563 }
|
Chris@0
|
564 return this.val();
|
Chris@0
|
565 },
|
Chris@0
|
566 },
|
Chris@0
|
567
|
Chris@0
|
568 collapsed: {
|
Chris@0
|
569 collapsed(e) {
|
Chris@17
|
570 return typeof e !== 'undefined' && 'value' in e
|
Chris@17
|
571 ? e.value
|
Chris@17
|
572 : !this.is('[open]');
|
Chris@0
|
573 },
|
Chris@0
|
574 },
|
Chris@0
|
575 };
|
Chris@0
|
576
|
Chris@0
|
577 /**
|
Chris@0
|
578 * A state object is used for describing the state and performing aliasing.
|
Chris@0
|
579 *
|
Chris@0
|
580 * @constructor Drupal.states.State
|
Chris@0
|
581 *
|
Chris@0
|
582 * @param {string} state
|
Chris@0
|
583 * The name of the state.
|
Chris@0
|
584 */
|
Chris@17
|
585 states.State = function(state) {
|
Chris@0
|
586 /**
|
Chris@0
|
587 * Original unresolved name.
|
Chris@0
|
588 */
|
Chris@14
|
589 this.pristine = state;
|
Chris@14
|
590 this.name = state;
|
Chris@0
|
591
|
Chris@0
|
592 // Normalize the state name.
|
Chris@0
|
593 let process = true;
|
Chris@0
|
594 do {
|
Chris@0
|
595 // Iteratively remove exclamation marks and invert the value.
|
Chris@0
|
596 while (this.name.charAt(0) === '!') {
|
Chris@0
|
597 this.name = this.name.substring(1);
|
Chris@0
|
598 this.invert = !this.invert;
|
Chris@0
|
599 }
|
Chris@0
|
600
|
Chris@0
|
601 // Replace the state with its normalized name.
|
Chris@0
|
602 if (this.name in states.State.aliases) {
|
Chris@0
|
603 this.name = states.State.aliases[this.name];
|
Chris@17
|
604 } else {
|
Chris@0
|
605 process = false;
|
Chris@0
|
606 }
|
Chris@0
|
607 } while (process);
|
Chris@0
|
608 };
|
Chris@0
|
609
|
Chris@0
|
610 /**
|
Chris@0
|
611 * Creates a new State object by sanitizing the passed value.
|
Chris@0
|
612 *
|
Chris@0
|
613 * @name Drupal.states.State.sanitize
|
Chris@0
|
614 *
|
Chris@0
|
615 * @param {string|Drupal.states.State} state
|
Chris@0
|
616 * A state object or the name of a state.
|
Chris@0
|
617 *
|
Chris@0
|
618 * @return {Drupal.states.state}
|
Chris@0
|
619 * A state object.
|
Chris@0
|
620 */
|
Chris@17
|
621 states.State.sanitize = function(state) {
|
Chris@0
|
622 if (state instanceof states.State) {
|
Chris@0
|
623 return state;
|
Chris@0
|
624 }
|
Chris@0
|
625
|
Chris@0
|
626 return new states.State(state);
|
Chris@0
|
627 };
|
Chris@0
|
628
|
Chris@0
|
629 /**
|
Chris@0
|
630 * This list of aliases is used to normalize states and associates negated
|
Chris@0
|
631 * names with their respective inverse state.
|
Chris@0
|
632 *
|
Chris@0
|
633 * @name Drupal.states.State.aliases
|
Chris@0
|
634 */
|
Chris@0
|
635 states.State.aliases = {
|
Chris@0
|
636 enabled: '!disabled',
|
Chris@0
|
637 invisible: '!visible',
|
Chris@0
|
638 invalid: '!valid',
|
Chris@0
|
639 untouched: '!touched',
|
Chris@0
|
640 optional: '!required',
|
Chris@0
|
641 filled: '!empty',
|
Chris@0
|
642 unchecked: '!checked',
|
Chris@0
|
643 irrelevant: '!relevant',
|
Chris@0
|
644 expanded: '!collapsed',
|
Chris@0
|
645 open: '!collapsed',
|
Chris@0
|
646 closed: 'collapsed',
|
Chris@0
|
647 readwrite: '!readonly',
|
Chris@0
|
648 };
|
Chris@0
|
649
|
Chris@0
|
650 states.State.prototype = {
|
Chris@0
|
651 /**
|
Chris@0
|
652 * @memberof Drupal.states.State#
|
Chris@0
|
653 */
|
Chris@0
|
654 invert: false,
|
Chris@0
|
655
|
Chris@0
|
656 /**
|
Chris@0
|
657 * Ensures that just using the state object returns the name.
|
Chris@0
|
658 *
|
Chris@0
|
659 * @memberof Drupal.states.State#
|
Chris@0
|
660 *
|
Chris@0
|
661 * @return {string}
|
Chris@0
|
662 * The name of the state.
|
Chris@0
|
663 */
|
Chris@0
|
664 toString() {
|
Chris@0
|
665 return this.name;
|
Chris@0
|
666 },
|
Chris@0
|
667 };
|
Chris@0
|
668
|
Chris@0
|
669 /**
|
Chris@0
|
670 * Global state change handlers. These are bound to "document" to cover all
|
Chris@0
|
671 * elements whose state changes. Events sent to elements within the page
|
Chris@0
|
672 * bubble up to these handlers. We use this system so that themes and modules
|
Chris@0
|
673 * can override these state change handlers for particular parts of a page.
|
Chris@0
|
674 */
|
Chris@0
|
675
|
Chris@0
|
676 const $document = $(document);
|
Chris@17
|
677 $document.on('state:disabled', e => {
|
Chris@0
|
678 // Only act when this change was triggered by a dependency and not by the
|
Chris@0
|
679 // element monitoring itself.
|
Chris@0
|
680 if (e.trigger) {
|
Chris@0
|
681 $(e.target)
|
Chris@0
|
682 .prop('disabled', e.value)
|
Chris@14
|
683 .closest('.js-form-item, .js-form-submit, .js-form-wrapper')
|
Chris@14
|
684 .toggleClass('form-disabled', e.value)
|
Chris@14
|
685 .find('select, input, textarea')
|
Chris@14
|
686 .prop('disabled', e.value);
|
Chris@0
|
687
|
Chris@0
|
688 // Note: WebKit nightlies don't reflect that change correctly.
|
Chris@0
|
689 // See https://bugs.webkit.org/show_bug.cgi?id=23789
|
Chris@0
|
690 }
|
Chris@0
|
691 });
|
Chris@0
|
692
|
Chris@17
|
693 $document.on('state:required', e => {
|
Chris@0
|
694 if (e.trigger) {
|
Chris@0
|
695 if (e.value) {
|
Chris@0
|
696 const label = `label${e.target.id ? `[for=${e.target.id}]` : ''}`;
|
Chris@17
|
697 const $label = $(e.target)
|
Chris@18
|
698 .attr({ required: 'required', 'aria-required': 'true' })
|
Chris@17
|
699 .closest('.js-form-item, .js-form-wrapper')
|
Chris@17
|
700 .find(label);
|
Chris@0
|
701 // Avoids duplicate required markers on initialization.
|
Chris@0
|
702 if (!$label.hasClass('js-form-required').length) {
|
Chris@0
|
703 $label.addClass('js-form-required form-required');
|
Chris@0
|
704 }
|
Chris@17
|
705 } else {
|
Chris@14
|
706 $(e.target)
|
Chris@14
|
707 .removeAttr('required aria-required')
|
Chris@14
|
708 .closest('.js-form-item, .js-form-wrapper')
|
Chris@14
|
709 .find('label.js-form-required')
|
Chris@14
|
710 .removeClass('js-form-required form-required');
|
Chris@0
|
711 }
|
Chris@0
|
712 }
|
Chris@0
|
713 });
|
Chris@0
|
714
|
Chris@17
|
715 $document.on('state:visible', e => {
|
Chris@0
|
716 if (e.trigger) {
|
Chris@17
|
717 $(e.target)
|
Chris@17
|
718 .closest('.js-form-item, .js-form-submit, .js-form-wrapper')
|
Chris@17
|
719 .toggle(e.value);
|
Chris@0
|
720 }
|
Chris@0
|
721 });
|
Chris@0
|
722
|
Chris@17
|
723 $document.on('state:checked', e => {
|
Chris@0
|
724 if (e.trigger) {
|
Chris@0
|
725 $(e.target).prop('checked', e.value);
|
Chris@0
|
726 }
|
Chris@0
|
727 });
|
Chris@0
|
728
|
Chris@17
|
729 $document.on('state:collapsed', e => {
|
Chris@0
|
730 if (e.trigger) {
|
Chris@0
|
731 if ($(e.target).is('[open]') === e.value) {
|
Chris@17
|
732 $(e.target)
|
Chris@17
|
733 .find('> summary')
|
Chris@17
|
734 .trigger('click');
|
Chris@0
|
735 }
|
Chris@0
|
736 }
|
Chris@0
|
737 });
|
Chris@17
|
738 })(jQuery, Drupal);
|