PluginProbe
Contact Forms by Cimatti / 2.3.0
Contact Forms by Cimatti v2.3.0
2.3.6 2.3.5 2.3.0 2.2.32 2.2.4 2.2.0 2.1.2 2.1.1 trunk 1.0 1.1 1.2 1.2.1 1.3 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4.0 1.4.1 All 62 releases
contact-forms / assets / js / frontend / file-upload.js

file-upload.js in Contact Forms by Cimatti 2.3.0, at assets/js/frontend/file-upload.js

460 lines 16.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Accua Forms - Accessible File Upload with Drag & Drop
3 * WCAG 2.2 AA Compliant / European Accessibility Act
4 *
5 * @package AccuaForms
6 * @since 2.0.0-beta.7
7 */
8
9 (function() {
10 'use strict';
11
12 /**
13 * AccuaFileUpload Class
14 * Progressive enhancement for file inputs with drag & drop support
15 */
16 class AccuaFileUpload {
17 /**
18 * @param {HTMLElement} container - The .accua-file-upload-wrapper element
19 */
20 constructor(container) {
21 this.container = container;
22 this.dropzone = container.querySelector('.accua-file-dropzone');
23 this.input = container.querySelector('input[type="file"]');
24 this.fileList = container.querySelector('.accua-file-list');
25 this.liveRegion = container.querySelector('.accua-file-sr-announcement');
26 this.browseButton = container.querySelector('.accua-file-browse-btn');
27
28 if (!this.input || !this.dropzone) {
29 return;
30 }
31
32 // Get configuration from data attributes
33 this.maxSize = parseInt(this.container.dataset.maxSize, 10) || 0;
34 this.acceptedTypes = this.container.dataset.accept || '';
35 this.multiple = this.input.hasAttribute('multiple');
36 this.required = this.input.hasAttribute('required');
37
38 // State
39 this.files = [];
40 this.dragCounter = 0;
41 this.syncing = false; // Flag to prevent infinite recursion
42
43 // Create error display element
44 this.errorDisplay = this.createErrorDisplay();
45
46 this.init();
47 }
48
49 /**
50 * Create visible error display element
51 */
52 createErrorDisplay() {
53 const errorDiv = document.createElement('div');
54 errorDiv.className = 'accua-file-error';
55 errorDiv.setAttribute('role', 'alert');
56 errorDiv.setAttribute('aria-live', 'polite');
57 errorDiv.style.display = 'none';
58
59 // Insert after dropzone
60 this.dropzone.insertAdjacentElement('afterend', errorDiv);
61
62 return errorDiv;
63 }
64
65 init() {
66 this.bindEvents();
67 this.container.classList.add('accua-file-upload-enhanced');
68 }
69
70 bindEvents() {
71 // Drag events on dropzone
72 this.dropzone.addEventListener('dragenter', (e) => this.handleDragEnter(e));
73 this.dropzone.addEventListener('dragover', (e) => this.handleDragOver(e));
74 this.dropzone.addEventListener('dragleave', (e) => this.handleDragLeave(e));
75 this.dropzone.addEventListener('drop', (e) => this.handleDrop(e));
76
77 // Keyboard activation on dropzone
78 this.dropzone.addEventListener('keydown', (e) => this.handleKeydown(e));
79 this.dropzone.addEventListener('click', (e) => this.handleClick(e));
80
81 // Browse button click
82 if (this.browseButton) {
83 this.browseButton.addEventListener('click', (e) => {
84 e.preventDefault();
85 e.stopPropagation();
86 this.input.click();
87 });
88 }
89
90 // File input change
91 this.input.addEventListener('change', () => this.handleFileSelect());
92
93 // Focus management
94 this.input.addEventListener('focus', () => this.dropzone.classList.add('accua-file-dropzone-focused'));
95 this.input.addEventListener('blur', () => this.dropzone.classList.remove('accua-file-dropzone-focused'));
96 }
97
98 handleDragEnter(e) {
99 e.preventDefault();
100 e.stopPropagation();
101 this.dragCounter++;
102 this.dropzone.classList.add('accua-file-dropzone-drag');
103 this.dropzone.setAttribute('aria-dropeffect', 'copy');
104 }
105
106 handleDragOver(e) {
107 e.preventDefault();
108 e.stopPropagation();
109 }
110
111 handleDragLeave(e) {
112 e.preventDefault();
113 e.stopPropagation();
114 this.dragCounter--;
115 if (this.dragCounter === 0) {
116 this.dropzone.classList.remove('accua-file-dropzone-drag');
117 this.dropzone.setAttribute('aria-dropeffect', 'none');
118 }
119 }
120
121 handleDrop(e) {
122 e.preventDefault();
123 e.stopPropagation();
124 this.dragCounter = 0;
125 this.dropzone.classList.remove('accua-file-dropzone-drag');
126 this.dropzone.setAttribute('aria-dropeffect', 'none');
127
128 const files = e.dataTransfer.files;
129 if (files.length > 0) {
130 this.processFiles(files);
131 }
132 }
133
134 handleKeydown(e) {
135 // Activate with Enter or Space
136 if (e.key === 'Enter' || e.key === ' ') {
137 e.preventDefault();
138 this.input.click();
139 }
140 }
141
142 handleClick(e) {
143 // If clicking the dropzone (not the native file input directly), trigger input
144 // Allow click through on any element except the native file input
145 if (e.target !== this.input && !e.target.classList.contains('accua-file-remove-btn')) {
146 e.preventDefault();
147 this.input.click();
148 }
149 }
150
151 handleFileSelect() {
152 // Skip if triggered by our own syncFilesToInput
153 if (this.syncing) return;
154
155 if (this.input.files.length > 0) {
156 this.processFiles(this.input.files);
157 }
158 }
159
160 processFiles(fileList) {
161 const newFiles = Array.from(fileList);
162 const errors = [];
163 const validFiles = [];
164
165 for (const file of newFiles) {
166 const validation = this.validateFile(file);
167 if (validation.valid) {
168 validFiles.push(file);
169 } else {
170 errors.push({ file: file.name, error: validation.error });
171 }
172 }
173
174 // Update state
175 if (this.multiple) {
176 this.files = [...this.files, ...validFiles];
177 } else {
178 this.files = validFiles.slice(0, 1);
179 }
180
181 // Update the native input with valid files
182 this.syncFilesToInput();
183
184 // Update UI
185 this.renderFileList();
186
187 // Announce to screen readers
188 this.announceResult(validFiles, errors);
189 }
190
191 validateFile(file) {
192 // Check file size
193 if (this.maxSize > 0 && file.size > this.maxSize) {
194 const maxSizeMB = (this.maxSize / (1024 * 1024)).toFixed(1);
195 return {
196 valid: false,
197 error: this.getString('fileTooLarge', `File exceeds maximum size of ${maxSizeMB} MB`)
198 };
199 }
200
201 // Check file type
202 if (this.acceptedTypes) {
203 const accepted = this.acceptedTypes.split(',').map(t => t.trim().toLowerCase());
204 const fileExt = '.' + file.name.split('.').pop().toLowerCase();
205 const fileMime = file.type.toLowerCase();
206
207 const isAccepted = accepted.some(type => {
208 if (type.startsWith('.')) {
209 return fileExt === type;
210 }
211 if (type.endsWith('/*')) {
212 return fileMime.startsWith(type.slice(0, -1));
213 }
214 return fileMime === type;
215 });
216
217 if (!isAccepted) {
218 return {
219 valid: false,
220 error: this.getString('invalidType', 'File type not allowed')
221 };
222 }
223 }
224
225 return { valid: true };
226 }
227
228 syncFilesToInput() {
229 // Set flag to prevent handleFileSelect from processing
230 this.syncing = true;
231
232 // Create a new DataTransfer to update the input's files
233 const dt = new DataTransfer();
234 for (const file of this.files) {
235 dt.items.add(file);
236 }
237 this.input.files = dt.files;
238
239 // Trigger change event for form validation
240 this.input.dispatchEvent(new Event('change', { bubbles: true }));
241
242 // Trigger custom event for submit button state management
243 const form = this.input.closest('form');
244 if (form && typeof jQuery !== 'undefined') {
245 jQuery(form).trigger('accua-file-change');
246 }
247
248 // Reset flag after event dispatch
249 this.syncing = false;
250 }
251
252 renderFileList() {
253 if (!this.fileList) return;
254
255 this.fileList.innerHTML = '';
256
257 if (this.files.length === 0) {
258 this.fileList.setAttribute('hidden', '');
259 return;
260 }
261
262 this.fileList.removeAttribute('hidden');
263
264 const ul = document.createElement('ul');
265 ul.className = 'accua-file-list-items';
266 ul.setAttribute('role', 'list');
267
268 for (let i = 0; i < this.files.length; i++) {
269 const file = this.files[i];
270 const li = document.createElement('li');
271 li.className = 'accua-file-list-item';
272
273 const fileInfo = document.createElement('span');
274 fileInfo.className = 'accua-file-info';
275 fileInfo.textContent = `${file.name} (${this.formatFileSize(file.size)})`;
276
277 const removeBtn = document.createElement('button');
278 removeBtn.type = 'button';
279 removeBtn.className = 'accua-file-remove-btn';
280 removeBtn.setAttribute('aria-label', this.getString('removeFile', 'Remove') + ': ' + file.name);
281 removeBtn.innerHTML = '<span aria-hidden="true">×</span>';
282 removeBtn.addEventListener('click', () => this.removeFile(i));
283
284 li.appendChild(fileInfo);
285 li.appendChild(removeBtn);
286 ul.appendChild(li);
287 }
288
289 this.fileList.appendChild(ul);
290 }
291
292 removeFile(index) {
293 const removedFile = this.files[index];
294 this.files.splice(index, 1);
295 this.syncFilesToInput();
296 this.renderFileList();
297
298 // Announce removal
299 this.announce(this.getString('fileRemoved', 'File removed') + ': ' + removedFile.name);
300
301 // Return focus to dropzone
302 this.dropzone.focus();
303 }
304
305 formatFileSize(bytes) {
306 if (bytes === 0) return '0 B';
307 const k = 1024;
308 const sizes = ['B', 'KB', 'MB', 'GB'];
309 const i = Math.floor(Math.log(bytes) / Math.log(k));
310 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
311 }
312
313 announceResult(validFiles, errors) {
314 let message = '';
315
316 if (validFiles.length > 0) {
317 if (validFiles.length === 1) {
318 message = this.getString('fileAdded', 'File added') + ': ' + validFiles[0].name;
319 } else {
320 message = validFiles.length + ' ' + this.getString('filesAdded', 'files added');
321 }
322
323 // Clear any previous errors when files are successfully added
324 this.hideError();
325 }
326
327 if (errors.length > 0) {
328 const errorMessages = errors.map(e => e.file + ': ' + e.error).join('. ');
329 message += (message ? '. ' : '') + this.getString('errors', 'Errors') + ': ' + errorMessages;
330
331 // Show visible error message
332 this.showError(errors);
333 }
334
335 if (message) {
336 this.announce(message);
337 }
338 }
339
340 /**
341 * Show visible error message
342 */
343 showError(errors) {
344 if (!this.errorDisplay) return;
345
346 // Build error message
347 const errorHtml = errors.map(e => {
348 return '<span class="accua-file-error-item">âš  <strong>' + this.escapeHtml(e.file) + '</strong>: ' + this.escapeHtml(e.error) + '</span>';
349 }).join('<br>');
350
351 this.errorDisplay.innerHTML = errorHtml;
352 this.errorDisplay.style.display = 'block';
353 this.container.classList.add('has-file-error');
354 }
355
356 /**
357 * Hide error message
358 */
359 hideError() {
360 if (!this.errorDisplay) return;
361
362 this.errorDisplay.innerHTML = '';
363 this.errorDisplay.style.display = 'none';
364 this.container.classList.remove('has-file-error');
365 }
366
367 /**
368 * Escape HTML to prevent XSS
369 */
370 escapeHtml(text) {
371 const div = document.createElement('div');
372 div.textContent = text;
373 return div.innerHTML;
374 }
375
376 announce(message) {
377 if (!this.liveRegion) return;
378
379 // Clear and set with slight delay to ensure announcement
380 this.liveRegion.textContent = '';
381 setTimeout(() => {
382 this.liveRegion.textContent = message;
383 }, 100);
384 }
385
386 getString(key, fallback) {
387 // Check for localized strings from WordPress
388 if (typeof accuaFileUploadL10n !== 'undefined' && accuaFileUploadL10n[key]) {
389 return accuaFileUploadL10n[key];
390 }
391 return fallback;
392 }
393 }
394
395 /**
396 * Initialize all file upload components on the page
397 */
398 function initFileUploads() {
399 const containers = document.querySelectorAll('.accua-file-upload-wrapper');
400 containers.forEach(container => {
401 // Avoid double initialization
402 if (!container.classList.contains('accua-file-upload-enhanced')) {
403 new AccuaFileUpload(container);
404 }
405 });
406 }
407
408 // Initialize on DOM ready
409 if (document.readyState === 'loading') {
410 document.addEventListener('DOMContentLoaded', initFileUploads);
411 } else {
412 initFileUploads();
413 }
414
415 // Re-initialize when new content is added (for AJAX-loaded forms or form builder)
416 // Use MutationObserver for admin area form builder
417 const observer = new MutationObserver((mutations) => {
418 let shouldInit = false;
419 for (const mutation of mutations) {
420 if (mutation.addedNodes.length > 0) {
421 for (const node of mutation.addedNodes) {
422 if (node.nodeType === Node.ELEMENT_NODE) {
423 if (node.classList && node.classList.contains('accua-file-upload-wrapper')) {
424 shouldInit = true;
425 break;
426 }
427 if (node.querySelector && node.querySelector('.accua-file-upload-wrapper')) {
428 shouldInit = true;
429 break;
430 }
431 }
432 }
433 }
434 if (shouldInit) break;
435 }
436 if (shouldInit) {
437 initFileUploads();
438 }
439 });
440
441 // Start observing once DOM is ready
442 function startObserver() {
443 observer.observe(document.body, {
444 childList: true,
445 subtree: true
446 });
447 }
448
449 if (document.readyState === 'loading') {
450 document.addEventListener('DOMContentLoaded', startObserver);
451 } else {
452 startObserver();
453 }
454
455 // Expose for manual initialization if needed
456 window.AccuaFileUpload = AccuaFileUpload;
457 window.accuaInitFileUploads = initFileUploads;
458
459 })();
460