states.es6.js 22 KB

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