Drupal investigation

states.js 22KB

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