states.es6.js 22 KB

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