states.es6.js 22 KB

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