annotate core/misc/form.es6.js @ 0:c75dbcec494b

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