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