states.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  1. (function ($) {
  2. /**
  3. * The base States namespace.
  4. *
  5. * Having the local states variable allows us to use the States namespace
  6. * without having to always declare "Drupal.states".
  7. */
  8. var states = Drupal.states = {
  9. // An array of functions that should be postponed.
  10. postponed: []
  11. };
  12. /**
  13. * Attaches the states.
  14. */
  15. Drupal.behaviors.states = {
  16. attach: function (context, settings) {
  17. var $context = $(context);
  18. for (var selector in settings.states) {
  19. for (var state in settings.states[selector]) {
  20. new states.Dependent({
  21. element: $context.find(selector),
  22. state: states.State.sanitize(state),
  23. constraints: settings.states[selector][state]
  24. });
  25. }
  26. }
  27. // Execute all postponed functions now.
  28. while (states.postponed.length) {
  29. (states.postponed.shift())();
  30. }
  31. }
  32. };
  33. /**
  34. * Object representing an element that depends on other elements.
  35. *
  36. * @param args
  37. * Object with the following keys (all of which are required):
  38. * - element: A jQuery object of the dependent element
  39. * - state: A State object describing the state that is dependent
  40. * - constraints: An object with dependency specifications. Lists all elements
  41. * that this element depends on. It can be nested and can contain arbitrary
  42. * AND and OR clauses.
  43. */
  44. states.Dependent = function (args) {
  45. $.extend(this, { values: {}, oldValue: null }, args);
  46. this.dependees = this.getDependees();
  47. for (var selector in this.dependees) {
  48. this.initializeDependee(selector, this.dependees[selector]);
  49. }
  50. };
  51. /**
  52. * Comparison functions for comparing the value of an element with the
  53. * specification from the dependency settings. If the object type can't be
  54. * found in this list, the === operator is used by default.
  55. */
  56. states.Dependent.comparisons = {
  57. 'RegExp': function (reference, value) {
  58. return reference.test(value);
  59. },
  60. 'Function': function (reference, value) {
  61. // The "reference" variable is a comparison function.
  62. return reference(value);
  63. },
  64. 'Number': function (reference, value) {
  65. // If "reference" is a number and "value" is a string, then cast reference
  66. // as a string before applying the strict comparison in compare(). Otherwise
  67. // numeric keys in the form's #states array fail to match string values
  68. // returned from jQuery's val().
  69. return (typeof value === 'string') ? compare(reference.toString(), value) : compare(reference, value);
  70. }
  71. };
  72. states.Dependent.prototype = {
  73. /**
  74. * Initializes one of the elements this dependent depends on.
  75. *
  76. * @param selector
  77. * The CSS selector describing the dependee.
  78. * @param dependeeStates
  79. * The list of states that have to be monitored for tracking the
  80. * dependee's compliance status.
  81. */
  82. initializeDependee: function (selector, dependeeStates) {
  83. var state;
  84. // Cache for the states of this dependee.
  85. this.values[selector] = {};
  86. for (var i in dependeeStates) {
  87. if (dependeeStates.hasOwnProperty(i)) {
  88. state = dependeeStates[i];
  89. // Make sure we're not initializing this selector/state combination twice.
  90. if ($.inArray(state, dependeeStates) === -1) {
  91. continue;
  92. }
  93. state = states.State.sanitize(state);
  94. // Initialize the value of this state.
  95. this.values[selector][state.name] = null;
  96. // Monitor state changes of the specified state for this dependee.
  97. $(selector).bind('state:' + state, $.proxy(function (e) {
  98. this.update(selector, state, e.value);
  99. }, this));
  100. // Make sure the event we just bound ourselves to is actually fired.
  101. new states.Trigger({ selector: selector, state: state });
  102. }
  103. }
  104. },
  105. /**
  106. * Compares a value with a reference value.
  107. *
  108. * @param reference
  109. * The value used for reference.
  110. * @param selector
  111. * CSS selector describing the dependee.
  112. * @param state
  113. * A State object describing the dependee's updated state.
  114. *
  115. * @return
  116. * true or false.
  117. */
  118. compare: function (reference, selector, state) {
  119. var value = this.values[selector][state.name];
  120. if (reference.constructor.name in states.Dependent.comparisons) {
  121. // Use a custom compare function for certain reference value types.
  122. return states.Dependent.comparisons[reference.constructor.name](reference, value);
  123. }
  124. else {
  125. // Do a plain comparison otherwise.
  126. return compare(reference, value);
  127. }
  128. },
  129. /**
  130. * Update the value of a dependee's state.
  131. *
  132. * @param selector
  133. * CSS selector describing the dependee.
  134. * @param state
  135. * A State object describing the dependee's updated state.
  136. * @param value
  137. * The new value for the dependee's updated state.
  138. */
  139. update: function (selector, state, value) {
  140. // Only act when the 'new' value is actually new.
  141. if (value !== this.values[selector][state.name]) {
  142. this.values[selector][state.name] = value;
  143. this.reevaluate();
  144. }
  145. },
  146. /**
  147. * Triggers change events in case a state changed.
  148. */
  149. reevaluate: function () {
  150. // Check whether any constraint for this dependent state is satisifed.
  151. var value = this.verifyConstraints(this.constraints);
  152. // Only invoke a state change event when the value actually changed.
  153. if (value !== this.oldValue) {
  154. // Store the new value so that we can compare later whether the value
  155. // actually changed.
  156. this.oldValue = value;
  157. // Normalize the value to match the normalized state name.
  158. value = invert(value, this.state.invert);
  159. // By adding "trigger: true", we ensure that state changes don't go into
  160. // infinite loops.
  161. this.element.trigger({ type: 'state:' + this.state, value: value, trigger: true });
  162. }
  163. },
  164. /**
  165. * Evaluates child constraints to determine if a constraint is satisfied.
  166. *
  167. * @param constraints
  168. * A constraint object or an array of constraints.
  169. * @param selector
  170. * The selector for these constraints. If undefined, there isn't yet a
  171. * selector that these constraints apply to. In that case, the keys of the
  172. * object are interpreted as the selector if encountered.
  173. *
  174. * @return
  175. * true or false, depending on whether these constraints are satisfied.
  176. */
  177. verifyConstraints: function(constraints, selector) {
  178. var result;
  179. if ($.isArray(constraints)) {
  180. // This constraint is an array (OR or XOR).
  181. var hasXor = $.inArray('xor', constraints) === -1;
  182. for (var i = 0, len = constraints.length; i < len; i++) {
  183. if (constraints[i] != 'xor') {
  184. var constraint = this.checkConstraints(constraints[i], selector, i);
  185. // Return if this is OR and we have a satisfied constraint or if this
  186. // is XOR and we have a second satisfied constraint.
  187. if (constraint && (hasXor || result)) {
  188. return hasXor;
  189. }
  190. result = result || constraint;
  191. }
  192. }
  193. }
  194. // Make sure we don't try to iterate over things other than objects. This
  195. // shouldn't normally occur, but in case the condition definition is bogus,
  196. // we don't want to end up with an infinite loop.
  197. else if ($.isPlainObject(constraints)) {
  198. // This constraint is an object (AND).
  199. for (var n in constraints) {
  200. if (constraints.hasOwnProperty(n)) {
  201. result = ternary(result, this.checkConstraints(constraints[n], selector, n));
  202. // False and anything else will evaluate to false, so return when any
  203. // false condition is found.
  204. if (result === false) { return false; }
  205. }
  206. }
  207. }
  208. return result;
  209. },
  210. /**
  211. * Checks whether the value matches the requirements for this constraint.
  212. *
  213. * @param value
  214. * Either the value of a state or an array/object of constraints. In the
  215. * latter case, resolving the constraint continues.
  216. * @param selector
  217. * The selector for this constraint. If undefined, there isn't yet a
  218. * selector that this constraint applies to. In that case, the state key is
  219. * propagates to a selector and resolving continues.
  220. * @param state
  221. * The state to check for this constraint. If undefined, resolving
  222. * continues.
  223. * If both selector and state aren't undefined and valid non-numeric
  224. * strings, a lookup for the actual value of that selector's state is
  225. * performed. This parameter is not a State object but a pristine state
  226. * string.
  227. *
  228. * @return
  229. * true or false, depending on whether this constraint is satisfied.
  230. */
  231. checkConstraints: function(value, selector, state) {
  232. // Normalize the last parameter. If it's non-numeric, we treat it either as
  233. // a selector (in case there isn't one yet) or as a trigger/state.
  234. if (typeof state !== 'string' || (/[0-9]/).test(state[0])) {
  235. state = null;
  236. }
  237. else if (typeof selector === 'undefined') {
  238. // Propagate the state to the selector when there isn't one yet.
  239. selector = state;
  240. state = null;
  241. }
  242. if (state !== null) {
  243. // constraints is the actual constraints of an element to check for.
  244. state = states.State.sanitize(state);
  245. return invert(this.compare(value, selector, state), state.invert);
  246. }
  247. else {
  248. // Resolve this constraint as an AND/OR operator.
  249. return this.verifyConstraints(value, selector);
  250. }
  251. },
  252. /**
  253. * Gathers information about all required triggers.
  254. */
  255. getDependees: function() {
  256. var cache = {};
  257. // Swivel the lookup function so that we can record all available selector-
  258. // state combinations for initialization.
  259. var _compare = this.compare;
  260. this.compare = function(reference, selector, state) {
  261. (cache[selector] || (cache[selector] = [])).push(state.name);
  262. // Return nothing (=== undefined) so that the constraint loops are not
  263. // broken.
  264. };
  265. // This call doesn't actually verify anything but uses the resolving
  266. // mechanism to go through the constraints array, trying to look up each
  267. // value. Since we swivelled the compare function, this comparison returns
  268. // undefined and lookup continues until the very end. Instead of lookup up
  269. // the value, we record that combination of selector and state so that we
  270. // can initialize all triggers.
  271. this.verifyConstraints(this.constraints);
  272. // Restore the original function.
  273. this.compare = _compare;
  274. return cache;
  275. }
  276. };
  277. states.Trigger = function (args) {
  278. $.extend(this, args);
  279. if (this.state in states.Trigger.states) {
  280. this.element = $(this.selector);
  281. // Only call the trigger initializer when it wasn't yet attached to this
  282. // element. Otherwise we'd end up with duplicate events.
  283. if (!this.element.data('trigger:' + this.state)) {
  284. this.initialize();
  285. }
  286. }
  287. };
  288. states.Trigger.prototype = {
  289. initialize: function () {
  290. var trigger = states.Trigger.states[this.state];
  291. if (typeof trigger == 'function') {
  292. // We have a custom trigger initialization function.
  293. trigger.call(window, this.element);
  294. }
  295. else {
  296. for (var event in trigger) {
  297. if (trigger.hasOwnProperty(event)) {
  298. this.defaultTrigger(event, trigger[event]);
  299. }
  300. }
  301. }
  302. // Mark this trigger as initialized for this element.
  303. this.element.data('trigger:' + this.state, true);
  304. },
  305. defaultTrigger: function (event, valueFn) {
  306. var oldValue = valueFn.call(this.element);
  307. // Attach the event callback.
  308. this.element.bind(event, $.proxy(function (e) {
  309. var value = valueFn.call(this.element, e);
  310. // Only trigger the event if the value has actually changed.
  311. if (oldValue !== value) {
  312. this.element.trigger({ type: 'state:' + this.state, value: value, oldValue: oldValue });
  313. oldValue = value;
  314. }
  315. }, this));
  316. states.postponed.push($.proxy(function () {
  317. // Trigger the event once for initialization purposes.
  318. this.element.trigger({ type: 'state:' + this.state, value: oldValue, oldValue: null });
  319. }, this));
  320. }
  321. };
  322. /**
  323. * This list of states contains functions that are used to monitor the state
  324. * of an element. Whenever an element depends on the state of another element,
  325. * one of these trigger functions is added to the dependee so that the
  326. * dependent element can be updated.
  327. */
  328. states.Trigger.states = {
  329. // 'empty' describes the state to be monitored
  330. empty: {
  331. // 'keyup' is the (native DOM) event that we watch for.
  332. 'keyup': function () {
  333. // The function associated to that trigger returns the new value for the
  334. // state.
  335. return this.val() == '';
  336. }
  337. },
  338. checked: {
  339. 'change': function () {
  340. return this.is(':checked');
  341. }
  342. },
  343. // For radio buttons, only return the value if the radio button is selected.
  344. value: {
  345. 'keyup': function () {
  346. // Radio buttons share the same :input[name="key"] selector.
  347. if (this.length > 1) {
  348. // Initial checked value of radios is undefined, so we return false.
  349. return this.filter(':checked').val() || false;
  350. }
  351. return this.val();
  352. },
  353. 'change': function () {
  354. // Radio buttons share the same :input[name="key"] selector.
  355. if (this.length > 1) {
  356. // Initial checked value of radios is undefined, so we return false.
  357. return this.filter(':checked').val() || false;
  358. }
  359. return this.val();
  360. }
  361. },
  362. collapsed: {
  363. 'collapsed': function(e) {
  364. return (typeof e !== 'undefined' && 'value' in e) ? e.value : this.is('.collapsed');
  365. }
  366. }
  367. };
  368. /**
  369. * A state object is used for describing the state and performing aliasing.
  370. */
  371. states.State = function(state) {
  372. // We may need the original unresolved name later.
  373. this.pristine = this.name = state;
  374. // Normalize the state name.
  375. while (true) {
  376. // Iteratively remove exclamation marks and invert the value.
  377. while (this.name.charAt(0) == '!') {
  378. this.name = this.name.substring(1);
  379. this.invert = !this.invert;
  380. }
  381. // Replace the state with its normalized name.
  382. if (this.name in states.State.aliases) {
  383. this.name = states.State.aliases[this.name];
  384. }
  385. else {
  386. break;
  387. }
  388. }
  389. };
  390. /**
  391. * Creates a new State object by sanitizing the passed value.
  392. */
  393. states.State.sanitize = function (state) {
  394. if (state instanceof states.State) {
  395. return state;
  396. }
  397. else {
  398. return new states.State(state);
  399. }
  400. };
  401. /**
  402. * This list of aliases is used to normalize states and associates negated names
  403. * with their respective inverse state.
  404. */
  405. states.State.aliases = {
  406. 'enabled': '!disabled',
  407. 'invisible': '!visible',
  408. 'invalid': '!valid',
  409. 'untouched': '!touched',
  410. 'optional': '!required',
  411. 'filled': '!empty',
  412. 'unchecked': '!checked',
  413. 'irrelevant': '!relevant',
  414. 'expanded': '!collapsed',
  415. 'readwrite': '!readonly'
  416. };
  417. states.State.prototype = {
  418. invert: false,
  419. /**
  420. * Ensures that just using the state object returns the name.
  421. */
  422. toString: function() {
  423. return this.name;
  424. }
  425. };
  426. /**
  427. * Global state change handlers. These are bound to "document" to cover all
  428. * elements whose state changes. Events sent to elements within the page
  429. * bubble up to these handlers. We use this system so that themes and modules
  430. * can override these state change handlers for particular parts of a page.
  431. */
  432. $(document).bind('state:disabled', function(e) {
  433. // Only act when this change was triggered by a dependency and not by the
  434. // element monitoring itself.
  435. if (e.trigger) {
  436. $(e.target)
  437. .attr('disabled', e.value)
  438. .closest('.form-item, .form-submit, .form-wrapper').toggleClass('form-disabled', e.value)
  439. .find('select, input, textarea').attr('disabled', e.value);
  440. // Note: WebKit nightlies don't reflect that change correctly.
  441. // See https://bugs.webkit.org/show_bug.cgi?id=23789
  442. }
  443. });
  444. $(document).bind('state:required', function(e) {
  445. if (e.trigger) {
  446. if (e.value) {
  447. var $label = $(e.target).closest('.form-item, .form-wrapper').find('label');
  448. // Avoids duplicate required markers on initialization.
  449. if (!$label.find('.form-required').length) {
  450. $label.append('<span class="form-required">*</span>');
  451. }
  452. }
  453. else {
  454. $(e.target).closest('.form-item, .form-wrapper').find('label .form-required').remove();
  455. }
  456. }
  457. });
  458. $(document).bind('state:visible', function(e) {
  459. if (e.trigger) {
  460. $(e.target).closest('.form-item, .form-submit, .form-wrapper').toggle(e.value);
  461. }
  462. });
  463. $(document).bind('state:checked', function(e) {
  464. if (e.trigger) {
  465. $(e.target).attr('checked', e.value);
  466. }
  467. });
  468. $(document).bind('state:collapsed', function(e) {
  469. if (e.trigger) {
  470. if ($(e.target).is('.collapsed') !== e.value) {
  471. $('> legend a', e.target).click();
  472. }
  473. }
  474. });
  475. /**
  476. * These are helper functions implementing addition "operators" and don't
  477. * implement any logic that is particular to states.
  478. */
  479. // Bitwise AND with a third undefined state.
  480. function ternary (a, b) {
  481. return typeof a === 'undefined' ? b : (typeof b === 'undefined' ? a : a && b);
  482. }
  483. // Inverts a (if it's not undefined) when invert is true.
  484. function invert (a, invert) {
  485. return (invert && typeof a !== 'undefined') ? !a : a;
  486. }
  487. // Compares two values while ignoring undefined values.
  488. function compare (a, b) {
  489. return (a === b) ? (typeof a === 'undefined' ? a : true) : (typeof a === 'undefined' || typeof b === 'undefined');
  490. }
  491. })(jQuery);