form.es6.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. /**
  2. * @file
  3. * Form features.
  4. */
  5. /**
  6. * Triggers when a value in the form changed.
  7. *
  8. * The event triggers when content is typed or pasted in a text field, before
  9. * the change event triggers.
  10. *
  11. * @event formUpdated
  12. */
  13. /**
  14. * Triggers when a click on a page fragment link or hash change is detected.
  15. *
  16. * The event triggers when the fragment in the URL changes (a hash change) and
  17. * when a link containing a fragment identifier is clicked. In case the hash
  18. * changes due to a click this event will only be triggered once.
  19. *
  20. * @event formFragmentLinkClickOrHashChange
  21. */
  22. (function($, Drupal, debounce) {
  23. /**
  24. * Retrieves the summary for the first element.
  25. *
  26. * @return {string}
  27. * The text of the summary.
  28. */
  29. $.fn.drupalGetSummary = function() {
  30. const callback = this.data('summaryCallback');
  31. return this[0] && callback ? $.trim(callback(this[0])) : '';
  32. };
  33. /**
  34. * Sets the summary for all matched elements.
  35. *
  36. * @param {function} callback
  37. * Either a function that will be called each time the summary is
  38. * retrieved or a string (which is returned each time).
  39. *
  40. * @return {jQuery}
  41. * jQuery collection of the current element.
  42. *
  43. * @fires event:summaryUpdated
  44. *
  45. * @listens event:formUpdated
  46. */
  47. $.fn.drupalSetSummary = function(callback) {
  48. const self = this;
  49. // To facilitate things, the callback should always be a function. If it's
  50. // not, we wrap it into an anonymous function which just returns the value.
  51. if (typeof callback !== 'function') {
  52. const val = callback;
  53. callback = function() {
  54. return val;
  55. };
  56. }
  57. return (
  58. this.data('summaryCallback', callback)
  59. // To prevent duplicate events, the handlers are first removed and then
  60. // (re-)added.
  61. .off('formUpdated.summary')
  62. .on('formUpdated.summary', () => {
  63. self.trigger('summaryUpdated');
  64. })
  65. // The actual summaryUpdated handler doesn't fire when the callback is
  66. // changed, so we have to do this manually.
  67. .trigger('summaryUpdated')
  68. );
  69. };
  70. /**
  71. * Prevents consecutive form submissions of identical form values.
  72. *
  73. * Repetitive form submissions that would submit the identical form values
  74. * are prevented, unless the form values are different to the previously
  75. * submitted values.
  76. *
  77. * This is a simplified re-implementation of a user-agent behavior that
  78. * should be natively supported by major web browsers, but at this time, only
  79. * Firefox has a built-in protection.
  80. *
  81. * A form value-based approach ensures that the constraint is triggered for
  82. * consecutive, identical form submissions only. Compared to that, a form
  83. * button-based approach would (1) rely on [visible] buttons to exist where
  84. * technically not required and (2) require more complex state management if
  85. * there are multiple buttons in a form.
  86. *
  87. * This implementation is based on form-level submit events only and relies
  88. * on jQuery's serialize() method to determine submitted form values. As such,
  89. * the following limitations exist:
  90. *
  91. * - Event handlers on form buttons that preventDefault() do not receive a
  92. * double-submit protection. That is deemed to be fine, since such button
  93. * events typically trigger reversible client-side or server-side
  94. * operations that are local to the context of a form only.
  95. * - Changed values in advanced form controls, such as file inputs, are not
  96. * part of the form values being compared between consecutive form submits
  97. * (due to limitations of jQuery.serialize()). That is deemed to be
  98. * acceptable, because if the user forgot to attach a file, then the size of
  99. * HTTP payload will most likely be small enough to be fully passed to the
  100. * server endpoint within (milli)seconds. If a user mistakenly attached a
  101. * wrong file and is technically versed enough to cancel the form submission
  102. * (and HTTP payload) in order to attach a different file, then that
  103. * edge-case is not supported here.
  104. *
  105. * Lastly, all forms submitted via HTTP GET are idempotent by definition of
  106. * HTTP standards, so excluded in this implementation.
  107. *
  108. * @type {Drupal~behavior}
  109. */
  110. Drupal.behaviors.formSingleSubmit = {
  111. attach() {
  112. function onFormSubmit(e) {
  113. const $form = $(e.currentTarget);
  114. const formValues = $form.serialize();
  115. const previousValues = $form.attr('data-drupal-form-submit-last');
  116. if (previousValues === formValues) {
  117. e.preventDefault();
  118. } else {
  119. $form.attr('data-drupal-form-submit-last', formValues);
  120. }
  121. }
  122. $('body')
  123. .once('form-single-submit')
  124. .on('submit.singleSubmit', 'form:not([method~="GET"])', onFormSubmit);
  125. },
  126. };
  127. /**
  128. * Sends a 'formUpdated' event each time a form element is modified.
  129. *
  130. * @param {HTMLElement} element
  131. * The element to trigger a form updated event on.
  132. *
  133. * @fires event:formUpdated
  134. */
  135. function triggerFormUpdated(element) {
  136. $(element).trigger('formUpdated');
  137. }
  138. /**
  139. * Collects the IDs of all form fields in the given form.
  140. *
  141. * @param {HTMLFormElement} form
  142. * The form element to search.
  143. *
  144. * @return {Array}
  145. * Array of IDs for form fields.
  146. */
  147. function fieldsList(form) {
  148. const $fieldList = $(form)
  149. .find('[name]')
  150. .map(
  151. // We use id to avoid name duplicates on radio fields and filter out
  152. // elements with a name but no id.
  153. (index, element) => element.getAttribute('id'),
  154. );
  155. // Return a true array.
  156. return $.makeArray($fieldList);
  157. }
  158. /**
  159. * Triggers the 'formUpdated' event on form elements when they are modified.
  160. *
  161. * @type {Drupal~behavior}
  162. *
  163. * @prop {Drupal~behaviorAttach} attach
  164. * Attaches formUpdated behaviors.
  165. * @prop {Drupal~behaviorDetach} detach
  166. * Detaches formUpdated behaviors.
  167. *
  168. * @fires event:formUpdated
  169. */
  170. Drupal.behaviors.formUpdated = {
  171. attach(context) {
  172. const $context = $(context);
  173. const contextIsForm = $context.is('form');
  174. const $forms = (contextIsForm ? $context : $context.find('form')).once(
  175. 'form-updated',
  176. );
  177. let formFields;
  178. if ($forms.length) {
  179. // Initialize form behaviors, use $.makeArray to be able to use native
  180. // forEach array method and have the callback parameters in the right
  181. // order.
  182. $.makeArray($forms).forEach(form => {
  183. const events = 'change.formUpdated input.formUpdated ';
  184. const eventHandler = debounce(event => {
  185. triggerFormUpdated(event.target);
  186. }, 300);
  187. formFields = fieldsList(form).join(',');
  188. form.setAttribute('data-drupal-form-fields', formFields);
  189. $(form).on(events, eventHandler);
  190. });
  191. }
  192. // On ajax requests context is the form element.
  193. if (contextIsForm) {
  194. formFields = fieldsList(context).join(',');
  195. // @todo replace with form.getAttribute() when #1979468 is in.
  196. const currentFields = $(context).attr('data-drupal-form-fields');
  197. // If there has been a change in the fields or their order, trigger
  198. // formUpdated.
  199. if (formFields !== currentFields) {
  200. triggerFormUpdated(context);
  201. }
  202. }
  203. },
  204. detach(context, settings, trigger) {
  205. const $context = $(context);
  206. const contextIsForm = $context.is('form');
  207. if (trigger === 'unload') {
  208. const $forms = (contextIsForm
  209. ? $context
  210. : $context.find('form')
  211. ).removeOnce('form-updated');
  212. if ($forms.length) {
  213. $.makeArray($forms).forEach(form => {
  214. form.removeAttribute('data-drupal-form-fields');
  215. $(form).off('.formUpdated');
  216. });
  217. }
  218. }
  219. },
  220. };
  221. /**
  222. * Prepopulate form fields with information from the visitor browser.
  223. *
  224. * @type {Drupal~behavior}
  225. *
  226. * @prop {Drupal~behaviorAttach} attach
  227. * Attaches the behavior for filling user info from browser.
  228. */
  229. Drupal.behaviors.fillUserInfoFromBrowser = {
  230. attach(context, settings) {
  231. const userInfo = ['name', 'mail', 'homepage'];
  232. const $forms = $('[data-user-info-from-browser]').once(
  233. 'user-info-from-browser',
  234. );
  235. if ($forms.length) {
  236. userInfo.forEach(info => {
  237. const $element = $forms.find(`[name=${info}]`);
  238. const browserData = localStorage.getItem(`Drupal.visitor.${info}`);
  239. const emptyOrDefault =
  240. $element.val() === '' ||
  241. $element.attr('data-drupal-default-value') === $element.val();
  242. if ($element.length && emptyOrDefault && browserData) {
  243. $element.val(browserData);
  244. }
  245. });
  246. }
  247. $forms.on('submit', () => {
  248. userInfo.forEach(info => {
  249. const $element = $forms.find(`[name=${info}]`);
  250. if ($element.length) {
  251. localStorage.setItem(`Drupal.visitor.${info}`, $element.val());
  252. }
  253. });
  254. });
  255. },
  256. };
  257. /**
  258. * Sends a fragment interaction event on a hash change or fragment link click.
  259. *
  260. * @param {jQuery.Event} e
  261. * The event triggered.
  262. *
  263. * @fires event:formFragmentLinkClickOrHashChange
  264. */
  265. const handleFragmentLinkClickOrHashChange = e => {
  266. let url;
  267. if (e.type === 'click') {
  268. url = e.currentTarget.location
  269. ? e.currentTarget.location
  270. : e.currentTarget;
  271. } else {
  272. url = window.location;
  273. }
  274. const hash = url.hash.substr(1);
  275. if (hash) {
  276. const $target = $(`#${hash}`);
  277. $('body').trigger('formFragmentLinkClickOrHashChange', [$target]);
  278. /**
  279. * Clicking a fragment link or a hash change should focus the target
  280. * element, but event timing issues in multiple browsers require a timeout.
  281. */
  282. setTimeout(() => $target.trigger('focus'), 300);
  283. }
  284. };
  285. const debouncedHandleFragmentLinkClickOrHashChange = debounce(
  286. handleFragmentLinkClickOrHashChange,
  287. 300,
  288. true,
  289. );
  290. // Binds a listener to handle URL fragment changes.
  291. $(window).on(
  292. 'hashchange.form-fragment',
  293. debouncedHandleFragmentLinkClickOrHashChange,
  294. );
  295. /**
  296. * Binds a listener to handle clicks on fragment links and absolute URL links
  297. * containing a fragment, this is needed next to the hash change listener
  298. * because clicking such links doesn't trigger a hash change when the fragment
  299. * is already in the URL.
  300. */
  301. $(document).on(
  302. 'click.form-fragment',
  303. 'a[href*="#"]',
  304. debouncedHandleFragmentLinkClickOrHashChange,
  305. );
  306. })(jQuery, Drupal, Drupal.debounce);