states.es6.js 21 KB

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