1/** 2 * @file 3 * JavaScript behaviors for file validation error placement and accessibility. 4 */ 5 6(function ($, Drupal, once) { 7 "use strict"; 8 9 const MANAGED_FILE_SELECTOR = 'div.js-form-managed-file'; 10 const FILE_CHECKBOX_SELECTOR = 'input.form-checkbox[type="checkbox"][name*="selected"]'; 11 const REMOVE_BUTTON_SELECTOR = 'button[data-drupal-selector$="-remove-button"]'; 12 const ROLE_BUTTON_ANCHOR_SELECTOR = 'a[role="button"]'; 13 const MODAL_FOCUSABLE_SELECTOR = [ 14 'a[href]', 15 'area[href]', 16 'input:not([disabled]):not([type="hidden"])', 17 'select:not([disabled])', 18 'textarea:not([disabled])', 19 'button:not([disabled])', 20 'iframe', 21 'object', 22 'embed', 23 '[contenteditable="true"]', 24 '[tabindex]:not([tabindex="-1"])' 25 ].join(','); 26 27 const requestNextFrame = window.requestAnimationFrame 28 ? window.requestAnimationFrame.bind(window) 29 : function (callback) { 30 setTimeout(callback, 0); 31 }; 32 33 function getFileErrorId($input) { 34 const baseId = $input.attr('id') || $input.attr('data-drupal-selector') || 'file-upload'; 35 return baseId.replace(/[^A-Za-z0-9\-_:]/g, '-') + '-error'; 36 } 37 38 function getFocusableElements($scope) { 39 return $scope 40 .find(MODAL_FOCUSABLE_SELECTOR) 41 .filter(':visible') 42 .filter(function () { 43 return !$(this).is('[aria-hidden="true"]'); 44 }); 45 } 46 function syncAriaDescribedBy($input, errorId, hasError) { 47 const describedBy = ($input.attr('aria-describedby') || '') 48 .split(/\s+/) 49 .filter(function (id) { 50 return id && id !== errorId; 51 }); 52 53 if (hasError) { 54 describedBy.push(errorId); 55 } 56 57 if (describedBy.length) { 58 $input.attr('aria-describedby', describedBy.join(' ')); 59 } else { 60 $input.removeAttr('aria-describedby'); 61 } 62 } 63 64 function extractServerError($input) { 65 const $formItem = $input.closest('div.form-item.form-type-managed-file'); 66 const $ajaxWrapper = $formItem.closest('div[id^="ajax-wrapper"]'); 67 let $serverError = $(); 68 69 if ($ajaxWrapper.length) { 70 $serverError = $ajaxWrapper.find('div[data-drupal-messages] .alert-danger').first(); 71 } 72 73 if (!$serverError.length) { 74 $serverError = $formItem 75 .siblings('div[data-drupal-messages]') 76 .find('.alert-danger') 77 .first(); 78 } 79 80 if (!$serverError.length) { 81 return ''; 82 } 83 84 const $clone = $serverError.clone(); 85 $clone.find('span[aria-hidden="true"], h2.sr-only').remove(); 86 87 const errorText = $clone.text().replace(/\s+/g, ' ').trim(); 88 89 $serverError.remove(); 90 91 // Clean empty wrappers after removing the alert. 92 if ($ajaxWrapper.length) { 93 $ajaxWrapper.find('div.messages__wrapper, div[data-drupal-messages]').each(function () { 94 if (!$(this).children().length) { 95 $(this).remove(); 96 } 97 }); 98 } 99 100 return errorText; 101 } 102 103 function processFileErrorPlacement($input) { 104 const errorId = getFileErrorId($input); 105 const $managedFile = $input.closest(MANAGED_FILE_SELECTOR); 106 const $coreError = $managedFile.find('div.file-upload-js-error').first(); 107 let errorText = ''; 108 109 // Clear any previous custom file error reference. 110 syncAriaDescribedBy($input, errorId, false); 111 $input.attr('aria-invalid', 'false'); 112 $input.siblings('span#' + errorId + '.file-upload-js-error').remove(); 113 114 let hasCoreError = false; 115 if ($coreError.length) { 116 errorText = $coreError.text(); 117 hasCoreError = true; 118 $coreError.remove(); 119 } else { 120 errorText = extractServerError($input); 121 } 122 123 if (errorText) { 124 if (typeof drupalSettings !== 'undefined' && drupalSettings.multiform && drupalSettings.multiform.validations) { 125 if (hasCoreError) { 126 if (drupalSettings.multiform.validations.fileextension) { 127 errorText = drupalSettings.multiform.validations.fileextension; 128 } 129 } else { 130 if (drupalSettings.multiform.validations.filesize) { 131 errorText = drupalSettings.multiform.validations.filesize; 132 } 133 } 134 } 135 136 $input.siblings('span.file-upload-js-error').remove(); 137 $('<span/>', { 138 id: errorId, 139 'class': 'error file-upload-js-error', 140 text: errorText 141 }).insertAfter($input); 142 143 syncAriaDescribedBy($input, errorId, true); 144 $input.attr('aria-invalid', 'true'); 145 } 146 } 147 148 function scheduleFileErrorPlacement($input) { 149 if ($input.data('mcFileErrorPlacementScheduled')) { 150 return; 151 } 152 153 $input.data('mcFileErrorPlacementScheduled', true); 154 155 requestNextFrame(function () { 156 $input.removeData('mcFileErrorPlacementScheduled'); 157 processFileErrorPlacement($input); 158 }); 159 } 160 161 function toTitleCase(str) { 162 return str.replace(/\b\w/g, function (c) { return c.toUpperCase(); }); 163 } 164 165 function updateRemoveButtonState($managedFile) { 166 const $removeButton = $managedFile.find(REMOVE_BUTTON_SELECTOR); 167 const anyChecked = $managedFile.find(FILE_CHECKBOX_SELECTOR + ':checked').length > 0; 168 169 if ($removeButton.length) { 170 if (!$removeButton.data('mcTitleCased')) { 171 const rawText = $removeButton.text().trim(); 172 const titleText = toTitleCase(rawText); 173 if (rawText !== titleText) { 174 $removeButton.text(titleText); 175 } 176 $removeButton.data('mcTitleCased', true); 177 } 178 $removeButton.prop('disabled', !anyChecked); 179 // Also update aria-disabled for better accessibility 180 $removeButton.attr('aria-disabled', !anyChecked); 181 } 182 } 183 184 function scheduleRemoveButtonStateUpdate($managedFile) { 185 if ($managedFile.data('mcRemoveStateScheduled')) { 186 return; 187 } 188 189 $managedFile.data('mcRemoveStateScheduled', true); 190 191 requestNextFrame(function () { 192 $managedFile.removeData('mcRemoveStateScheduled'); 193 updateRemoveButtonState($managedFile); 194 }); 195 } 196 197 Drupal.behaviors.fileValidateErrorPlacement = { 198 attach: function (context, settings) {
199 // Keep Drupal core file validation and only alter where the message is rendered. 200 if (settings.file && settings.file.elements) { 201 Object.keys(settings.file.elements).forEach(function (selector) { 202 once('fileValidateErrorPlacement', selector, context).forEach(function (input) { 203 const $input = $(input); 204 const $managedFile = $input.closest(MANAGED_FILE_SELECTOR); 205 206 // This behavior only applies to managed file widgets. 207 if (!$managedFile.length) { 208 return; 209 } 210 211 // Process errors already present after an Ajax refresh. 212 scheduleFileErrorPlacement($input); 213 214 // Handle valid file selections: core does NOT call stopImmediatePropagation() 215 // in this case, so the change event reaches this handler normally. 216 $(input).on('change.fileValidateErrorPlacement', function () { 217 // Run after core validation has injected any error message. 218 scheduleFileErrorPlacement($input); 219 }); 220 221 // Handle invalid file selections: core's change.fileValidate calls 222 // stopImmediatePropagation() after injecting div.file-upload-js-error, 223 // which prevents the change.fileValidateErrorPlacement handler above 224 // from ever firing when it is bound after change.fileValidate (i.e. on 225 // initial page load). A MutationObserver watching for the core error 226 // div being added to the DOM is independent of event-handler order and 227 // guarantees processFileErrorPlacement always runs. 228 const observer = new MutationObserver(function (mutations) { 229 const coreErrorAdded = mutations.some(function (mutation) { 230 return Array.from(mutation.addedNodes).some(function (node) { 231 // Only react to the specific <div class="file-upload-js-error"> 232 // element that core injects on validation failure. 233 return node.nodeType === Node.ELEMENT_NODE && 234 node.tagName === 'DIV' && 235 node.classList.contains('file-upload-js-error'); 236 }); 237 }); 238 if (coreErrorAdded) { 239 scheduleFileErrorPlacement($input); 240 } 241 }); 242 243 observer.observe($managedFile[0], { childList: true, subtree: true }); 244 }); 245 }); 246 } 247 } 248 }; 249 250 Drupal.behaviors.manageRemoveButtonState = { 251 attach: function (context, settings) { 252 once('manageRemoveButtonState', MANAGED_FILE_SELECTOR, context).forEach(function (element) { 253 const $managedFile = $(element); 254 255 // Initialize button state 256 updateRemoveButtonState($managedFile); 257 258 // Update button state when file selection checkboxes change 259 $managedFile.on('change.manageRemoveButtonState', FILE_CHECKBOX_SELECTOR, function () { 260 updateRemoveButtonState($managedFile); 261 }); 262 263 // Update button state after Ajax file uploads complete 264 $managedFile.on('ajaxComplete.manageRemoveButtonState', function () { 265 scheduleRemoveButtonStateUpdate($managedFile); 266 }); 267 }); 268 } 269 }; 270 271 Drupal.behaviors.modalFocusTrap = { 272 attach: function (context, settings) { 273 // Trap focus for modals within forms. 274 once('mcmultiform-contact-modal-focus', '.contact-modal', context).forEach(function (modal) { 275 const $modal = $(modal); 276 277 $modal.on('shown.bs.modal', function () { 278 const $focusable = getFocusableElements($modal); 279 280 if ($focusable.length) { 281 $focusable.first().trigger('focus'); 282 } 283 284 $modal.on('keydown.mcmultiformFocusTrap', function (event) { 285 if (event.key !== 'Tab' && event.which !== 9) { 286 return; 287 } 288 289 const $tabbable = getFocusableElements($modal); 290 291 if (!$tabbable.length) { 292 event.preventDefault(); 293 $modal.trigger('focus'); 294 return; 295 } 296 297 if ($tabbable.length === 1) { 298 event.preventDefault(); 299 $tabbable.first().trigger('focus'); 300 return; 301 } 302 303 const first = $tabbable.get(0); 304 const last = $tabbable.get($tabbable.length - 1); 305 const activeElement = document.activeElement; 306 307 if (!event.shiftKey && activeElement === last) { 308 event.preventDefault(); 309 first.focus(); 310 } 311 312 if (event.shiftKey && activeElement === first) { 313 event.preventDefault(); 314 last.focus(); 315 } 316 }); 317 }); 318 319 $modal.on('hidden.bs.modal', function () { 320 $modal.off('keydown.mcmultiformFocusTrap'); 321 }); 322 }); 323 } 324 }; 325 326 Drupal.behaviors.roleButtonSpaceKeySupport = { 327 attach: function (context) { 328 once('mcmultiform-role-button-space-key', ROLE_BUTTON_ANCHOR_SELECTOR, context).forEach(function (element) { 329 $(element).on('keydown.mcmultiformRoleButtonSpace', function (event) { 330 if (event.key !== ' ' && event.key !== 'Space' && event.key !== 'Spacebar') { 331 return; 332 } 333 334 // Match native button keyboard activation with Space key. 335 event.preventDefault(); 336 event.currentTarget.click(); 337 }); 338 }); 339 } 340 }; 341})(jQuery, Drupal, once);
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.