@schukai/monster 4.60.0 → 4.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1282 @@
1
+ /**
2
+ * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved.
3
+ * Node module: @schukai/monster
4
+ *
5
+ * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3).
6
+ * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html
7
+ *
8
+ * For those who do not wish to adhere to the AGPLv3, a commercial license is available.
9
+ * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms.
10
+ * For more information about purchasing a commercial license, please contact Volker Schukai.
11
+ *
12
+ * SPDX-License-Identifier: AGPL-3.0
13
+ */
14
+
15
+ import { instanceSymbol } from "../../constants.mjs";
16
+ import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
17
+ import {
18
+ assembleMethodSymbol,
19
+ CustomElement,
20
+ registerCustomElement,
21
+ } from "../../dom/customelement.mjs";
22
+ import {
23
+ findTargetElementFromEvent,
24
+ fireCustomEvent,
25
+ } from "../../dom/events.mjs";
26
+ import { addErrorAttribute, resetErrorAttribute } from "../../dom/error.mjs";
27
+ import { getDocument } from "../../dom/util.mjs";
28
+ import { getLocaleOfDocument } from "../../dom/locale.mjs";
29
+ import { isFunction, isObject, isString } from "../../types/is.mjs";
30
+ import { DropzoneStyleSheet } from "./stylesheet/dropzone.mjs";
31
+
32
+ import "./button.mjs";
33
+
34
+ export { Dropzone };
35
+
36
+ /**
37
+ * @private
38
+ * @type {symbol}
39
+ */
40
+ const dropzoneElementSymbol = Symbol("dropzoneElement");
41
+
42
+ /**
43
+ * @private
44
+ * @type {symbol}
45
+ */
46
+ const inputElementSymbol = Symbol("inputElement");
47
+
48
+ /**
49
+ * @private
50
+ * @type {symbol}
51
+ */
52
+ const buttonElementSymbol = Symbol("buttonElement");
53
+
54
+ /**
55
+ * @private
56
+ * @type {symbol}
57
+ */
58
+ const statusElementSymbol = Symbol("statusElement");
59
+
60
+ /**
61
+ * @private
62
+ * @type {symbol}
63
+ */
64
+ const dragCounterSymbol = Symbol("dragCounter");
65
+
66
+ /**
67
+ * @private
68
+ * @type {symbol}
69
+ */
70
+ const listElementSymbol = Symbol("listElement");
71
+
72
+ /**
73
+ * @private
74
+ * @type {symbol}
75
+ */
76
+ const fileItemMapSymbol = Symbol("fileItemMap");
77
+
78
+ /**
79
+ * @private
80
+ * @type {symbol}
81
+ */
82
+ const fileRequestMapSymbol = Symbol("fileRequestMap");
83
+
84
+ /**
85
+ * @private
86
+ * @type {symbol}
87
+ */
88
+ const fileTimeoutMapSymbol = Symbol("fileTimeoutMap");
89
+
90
+ /**
91
+ * A Dropzone control
92
+ *
93
+ * @fragments /fragments/components/form/dropzone/
94
+ *
95
+ * @example /examples/components/form/dropzone-simple
96
+ *
97
+ * @since 4.40.0
98
+ * @copyright Volker Schukai
99
+ * @summary A dropzone control for uploading documents via click or drag and drop.
100
+ *
101
+ * @fires monster-dropzone-selected
102
+ * @fires monster-dropzone-file-added
103
+ * @fires monster-dropzone-file-removed
104
+ * @fires monster-dropzone-file-retry
105
+ * @fires monster-dropzone-file-upload-start
106
+ * @fires monster-dropzone-file-upload-success
107
+ * @fires monster-dropzone-file-upload-error
108
+ * @fires monster-dropzone-upload-start
109
+ * @fires monster-dropzone-upload-success
110
+ * @fires monster-dropzone-upload-error
111
+ */
112
+ class Dropzone extends CustomElement {
113
+ /**
114
+ * This method is called by the `instanceof` operator.
115
+ * @return {symbol}
116
+ */
117
+ static get [instanceSymbol]() {
118
+ return Symbol.for("@schukai/monster/components/form/dropzone@@instance");
119
+ }
120
+
121
+ /**
122
+ * To set the options via the HTML tag, the attribute `data-monster-options` must be used.
123
+ * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control}
124
+ *
125
+ * The individual configuration values can be found in the table.
126
+ *
127
+ * @property {Object} templates Template definitions
128
+ * @property {string} templates.main Main template
129
+ * @property {Object} labels Label definitions
130
+ * @property {string} labels.title Title text
131
+ * @property {string} labels.hint Hint text
132
+ * @property {string} labels.button Button label
133
+ * @property {string} labels.statusIdle Status text for idle state
134
+ * @property {string} labels.statusUploading Status text for uploading state
135
+ * @property {string} labels.statusSuccess Status text for success state
136
+ * @property {string} labels.statusError Status text for error state
137
+ * @property {string} labels.statusMissingUrl Status text for missing URL
138
+ * @property {Object} classes Class definitions
139
+ * @property {string} classes.dropzone Dropzone CSS class
140
+ * @property {string} classes.button Monster button class
141
+ * @property {string} url Upload URL
142
+ * @property {string} fieldName="files" FormData field name for files
143
+ * @property {string} accept File input accept attribute
144
+ * @property {boolean} multiple Allow multiple file selection
145
+ * @property {boolean} disabled Disable interaction
146
+ * @property {Object} data Additional data appended to the FormData
147
+ * @property {Object} features Feature flags
148
+ * @property {boolean} features.autoUpload Automatically upload after selection
149
+ * @property {boolean} features.previewImages Show image previews
150
+ * @property {boolean} features.disappear Enable auto-removal of finished items
151
+ * @property {Object} disappear Disappear settings
152
+ * @property {number} disappear.time Delay before auto-removal (ms)
153
+ * @property {number} disappear.duration Delay before auto-removal (ms)
154
+ * @property {Object} actions Action definitions for custom event handling
155
+ * @property {Function} actions.fileAdded Called after a file is added to the list
156
+ * @property {Function} actions.fileRemoved Called after a file is removed
157
+ * @property {Function} actions.fileRetry Called before retrying a failed upload
158
+ * @property {Function} actions.uploadStart Called when uploads start
159
+ * @property {Function} actions.uploadSuccess Called after successful upload
160
+ * @property {Function} actions.uploadError Called when upload fails
161
+ * @property {Function} actions.beforeUpload Called before upload, return false to cancel
162
+ * @property {Object} fetch Fetch options
163
+ * @property {string} fetch.method="POST"
164
+ * @property {string} fetch.redirect="error"
165
+ * @property {string} fetch.mode="same-origin"
166
+ * @property {string} fetch.credentials="same-origin"
167
+ * @property {Object} fetch.headers={"accept":"application/json"}
168
+ */
169
+ get defaults() {
170
+ return Object.assign({}, super.defaults, {
171
+ templates: {
172
+ main: getTemplate(),
173
+ },
174
+ labels: getTranslations(),
175
+ classes: {
176
+ dropzone: "monster-dropzone",
177
+ button: "monster-button-outline-primary",
178
+ },
179
+ url: "",
180
+ fieldName: "files",
181
+ accept: "",
182
+ multiple: true,
183
+ disabled: false,
184
+ data: {},
185
+ features: {
186
+ autoUpload: true,
187
+ previewImages: true,
188
+ disappear: true,
189
+ },
190
+ disappear: {
191
+ duration: 3000,
192
+ },
193
+ actions: {
194
+ fileAdded: null,
195
+ fileRemoved: null,
196
+ fileRetry: null,
197
+ uploadStart: null,
198
+ uploadSuccess: null,
199
+ uploadError: null,
200
+ beforeUpload: null,
201
+ },
202
+ fetch: {
203
+ method: "POST",
204
+ redirect: "error",
205
+ mode: "same-origin",
206
+ credentials: "same-origin",
207
+ headers: {
208
+ accept: "application/json",
209
+ },
210
+ },
211
+ });
212
+ }
213
+
214
+ /**
215
+ *
216
+ */
217
+ [assembleMethodSymbol]() {
218
+ super[assembleMethodSymbol]();
219
+ initControlReferences.call(this);
220
+ initEventHandler.call(this);
221
+ this[fileItemMapSymbol] = new Map();
222
+ this[fileRequestMapSymbol] = new Map();
223
+ this[fileTimeoutMapSymbol] = new Map();
224
+ setStatus.call(this, this.getOption("labels.statusIdle"));
225
+ }
226
+
227
+ /**
228
+ *
229
+ * @return {CSSStyleSheet[]}
230
+ */
231
+ static getCSSStyleSheet() {
232
+ return [DropzoneStyleSheet];
233
+ }
234
+
235
+ /**
236
+ *
237
+ * @return {string}
238
+ */
239
+ static getTag() {
240
+ return "monster-dropzone";
241
+ }
242
+
243
+ /**
244
+ * Open the native file picker.
245
+ *
246
+ * @return {void}
247
+ */
248
+ open() {
249
+ if (this.getOption("disabled") === true) {
250
+ return;
251
+ }
252
+
253
+ const input = this[inputElementSymbol];
254
+ if (input && typeof input.click === "function") {
255
+ input.click();
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Upload files programmatically.
261
+ *
262
+ * @param {FileList|File[]} files
263
+ * @return {Promise<void>}
264
+ */
265
+ upload(files) {
266
+ const normalized = normalizeFiles(files);
267
+ if (normalized.length === 0) {
268
+ return Promise.resolve();
269
+ }
270
+
271
+ return uploadFiles.call(this, normalized);
272
+ }
273
+ }
274
+
275
+ /**
276
+ * @private
277
+ */
278
+ function initControlReferences() {
279
+ this[dropzoneElementSymbol] = this.shadowRoot.querySelector(
280
+ `[${ATTRIBUTE_ROLE}=dropzone]`,
281
+ );
282
+ this[inputElementSymbol] = this.shadowRoot.querySelector(
283
+ `[${ATTRIBUTE_ROLE}=input]`,
284
+ );
285
+ this[buttonElementSymbol] = this.shadowRoot.querySelector(
286
+ `[${ATTRIBUTE_ROLE}=button]`,
287
+ );
288
+ this[statusElementSymbol] = this.shadowRoot.querySelector(
289
+ `[${ATTRIBUTE_ROLE}=status]`,
290
+ );
291
+ this[listElementSymbol] = this.shadowRoot.querySelector(
292
+ `[${ATTRIBUTE_ROLE}=list]`,
293
+ );
294
+ }
295
+
296
+ /**
297
+ * @private
298
+ */
299
+ function initEventHandler() {
300
+ this[dragCounterSymbol] = 0;
301
+
302
+ const dropzone = this[dropzoneElementSymbol];
303
+ const input = this[inputElementSymbol];
304
+ const button = this[buttonElementSymbol];
305
+
306
+ if (dropzone) {
307
+ dropzone.addEventListener("dragenter", (event) => {
308
+ if (this.getOption("disabled") === true) {
309
+ return;
310
+ }
311
+ event.preventDefault();
312
+ this[dragCounterSymbol] += 1;
313
+ setDropActive.call(this, true);
314
+ });
315
+
316
+ dropzone.addEventListener("dragover", (event) => {
317
+ if (this.getOption("disabled") === true) {
318
+ return;
319
+ }
320
+ event.preventDefault();
321
+ setDropActive.call(this, true);
322
+ });
323
+
324
+ dropzone.addEventListener("dragleave", (event) => {
325
+ if (this.getOption("disabled") === true) {
326
+ return;
327
+ }
328
+ event.preventDefault();
329
+ this[dragCounterSymbol] = Math.max(0, this[dragCounterSymbol] - 1);
330
+ if (this[dragCounterSymbol] === 0) {
331
+ setDropActive.call(this, false);
332
+ }
333
+ });
334
+
335
+ dropzone.addEventListener("drop", (event) => {
336
+ if (this.getOption("disabled") === true) {
337
+ return;
338
+ }
339
+ event.preventDefault();
340
+ this[dragCounterSymbol] = 0;
341
+ setDropActive.call(this, false);
342
+ const files = event.dataTransfer?.files;
343
+ handleFiles.call(this, files);
344
+ });
345
+
346
+ dropzone.addEventListener("keydown", (event) => {
347
+ if (this.getOption("disabled") === true) {
348
+ return;
349
+ }
350
+ if (event.key === "Enter" || event.key === " ") {
351
+ event.preventDefault();
352
+ this.open();
353
+ }
354
+ });
355
+ }
356
+
357
+ if (input) {
358
+ input.addEventListener("change", (event) => {
359
+ const files = event.target?.files;
360
+ handleFiles.call(this, files);
361
+ });
362
+ }
363
+
364
+ if (button) {
365
+ button.addEventListener("monster-button-clicked", (event) => {
366
+ if (this.getOption("disabled") === true) {
367
+ return;
368
+ }
369
+
370
+ const element = findTargetElementFromEvent(
371
+ event,
372
+ ATTRIBUTE_ROLE,
373
+ "button",
374
+ );
375
+
376
+ if (!(element instanceof Node && this.hasNode(element))) {
377
+ return;
378
+ }
379
+
380
+ this.open();
381
+ });
382
+ }
383
+ }
384
+
385
+ /**
386
+ * @private
387
+ * @param {FileList|File[]} files
388
+ */
389
+ function handleFiles(files) {
390
+ const normalized = normalizeFiles(files);
391
+ if (normalized.length === 0) {
392
+ return;
393
+ }
394
+
395
+ for (const file of normalized) {
396
+ addFileItem.call(this, file);
397
+ fireCustomEvent(this, "monster-dropzone-file-added", { file });
398
+ triggerAction.call(this, "fileAdded", { file });
399
+ }
400
+
401
+ fireCustomEvent(this, "monster-dropzone-selected", {
402
+ files: normalized,
403
+ });
404
+
405
+ if (this.getOption("features.autoUpload") === true) {
406
+ uploadFiles.call(this, normalized);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * @private
412
+ * @param {FileList|File[]} files
413
+ * @return {File[]}
414
+ */
415
+ function normalizeFiles(files) {
416
+ if (!files) {
417
+ return [];
418
+ }
419
+
420
+ if (Array.isArray(files)) {
421
+ return files.filter((file) => file instanceof File);
422
+ }
423
+
424
+ if (files instanceof FileList) {
425
+ return Array.from(files);
426
+ }
427
+
428
+ return [];
429
+ }
430
+
431
+ /**
432
+ * @private
433
+ * @param {File[]} files
434
+ * @return {Promise<void>}
435
+ */
436
+ function uploadFiles(files) {
437
+ let url = this.getOption("url");
438
+ if (!isString(url) || url === "") {
439
+ const message = this.getOption("labels.statusMissingUrl");
440
+ setStatus.call(this, message);
441
+ addErrorAttribute(this, message);
442
+ fireCustomEvent(this, "monster-dropzone-upload-error", {
443
+ files,
444
+ error: new Error(message),
445
+ });
446
+ return Promise.reject(new Error(message));
447
+ }
448
+
449
+ try {
450
+ url = new URL(url, getDocument().location).toString();
451
+ } catch (error) {
452
+ addErrorAttribute(this, error);
453
+ setStatus.call(this, this.getOption("labels.statusError"));
454
+ fireCustomEvent(this, "monster-dropzone-upload-error", { files, error });
455
+ return Promise.reject(error);
456
+ }
457
+
458
+ setStatus.call(this, this.getOption("labels.statusUploading"));
459
+ fireCustomEvent(this, "monster-dropzone-upload-start", { files, url });
460
+ triggerAction.call(this, "uploadStart", { files, url });
461
+
462
+ const uploads = files.map((file) => {
463
+ const item = this[fileItemMapSymbol]?.get(file);
464
+ return uploadSingleFile.call(this, file, url, item);
465
+ });
466
+
467
+ return Promise.all(uploads)
468
+ .then((responses) => {
469
+ resetErrorAttribute(this);
470
+ setStatus.call(this, this.getOption("labels.statusSuccess"));
471
+ fireCustomEvent(this, "monster-dropzone-upload-success", {
472
+ files,
473
+ url,
474
+ response: responses,
475
+ });
476
+ triggerAction.call(this, "uploadSuccess", {
477
+ files,
478
+ url,
479
+ response: responses,
480
+ });
481
+ })
482
+ .catch((error) => {
483
+ addErrorAttribute(this, error);
484
+ setStatus.call(this, this.getOption("labels.statusError"));
485
+ fireCustomEvent(this, "monster-dropzone-upload-error", {
486
+ files,
487
+ url,
488
+ error,
489
+ });
490
+ triggerAction.call(this, "uploadError", { files, url, error });
491
+
492
+ throw error;
493
+ });
494
+ }
495
+
496
+ /**
497
+ * @private
498
+ * @param {File} file
499
+ * @param {string} url
500
+ * @param {HTMLElement|null} item
501
+ * @return {Promise<*>}
502
+ */
503
+ function uploadSingleFile(file, url, item) {
504
+ let formData = new FormData();
505
+ const fieldName = this.getOption("fieldName") || "files";
506
+ formData.append(fieldName, file, file.name);
507
+
508
+ const extraData = this.getOption("data");
509
+ if (isObject(extraData)) {
510
+ for (const [key, value] of Object.entries(extraData)) {
511
+ if (value === undefined || value === null) {
512
+ continue;
513
+ }
514
+ formData.append(key, `${value}`);
515
+ }
516
+ }
517
+
518
+ const beforeUpload = this.getOption("actions.beforeUpload");
519
+ if (isFunction(beforeUpload)) {
520
+ const result = beforeUpload.call(this, { file, formData, url });
521
+ if (result === false) {
522
+ return Promise.resolve(null);
523
+ }
524
+ if (result instanceof FormData) {
525
+ formData = result;
526
+ }
527
+ }
528
+
529
+ return new Promise((resolve, reject) => {
530
+ const xhr = new XMLHttpRequest();
531
+ const fetchOptions = Object.assign({}, this.getOption("fetch", {}));
532
+ const method = fetchOptions.method || "POST";
533
+
534
+ xhr.open(method, url);
535
+
536
+ if (fetchOptions.headers && isObject(fetchOptions.headers)) {
537
+ for (const [key, value] of Object.entries(fetchOptions.headers)) {
538
+ if (key.toLowerCase() === "content-type") {
539
+ continue;
540
+ }
541
+ xhr.setRequestHeader(key, String(value));
542
+ }
543
+ }
544
+
545
+ const credentials = fetchOptions.credentials || "same-origin";
546
+ xhr.withCredentials = credentials === "include";
547
+
548
+ xhr.upload.addEventListener("progress", (event) => {
549
+ if (event.lengthComputable) {
550
+ const percent = Math.round((event.loaded / event.total) * 100);
551
+ updateFileProgress.call(this, item, percent);
552
+ }
553
+ });
554
+
555
+ xhr.addEventListener("load", () => {
556
+ const status = xhr.status;
557
+ const response = parseXhrResponse(xhr);
558
+ this[fileRequestMapSymbol]?.delete(file);
559
+ if (status >= 200 && status < 300) {
560
+ updateFileProgress.call(this, item, 100);
561
+ setItemState.call(this, item, "success");
562
+ fireCustomEvent(this, "monster-dropzone-file-upload-success", {
563
+ file,
564
+ url,
565
+ response,
566
+ });
567
+ triggerAction.call(this, "uploadSuccess", {
568
+ files: [file],
569
+ url,
570
+ response,
571
+ });
572
+ resolve(response);
573
+ } else {
574
+ setItemState.call(this, item, "error");
575
+ fireCustomEvent(this, "monster-dropzone-file-upload-error", {
576
+ file,
577
+ url,
578
+ error: new Error(
579
+ `upload failed (${status} ${xhr.statusText || "error"})`,
580
+ ),
581
+ });
582
+ triggerAction.call(this, "uploadError", {
583
+ files: [file],
584
+ url,
585
+ error: new Error(
586
+ `upload failed (${status} ${xhr.statusText || "error"})`,
587
+ ),
588
+ });
589
+ reject(
590
+ new Error(`upload failed (${status} ${xhr.statusText || "error"})`),
591
+ );
592
+ }
593
+ });
594
+
595
+ xhr.addEventListener("error", () => {
596
+ this[fileRequestMapSymbol]?.delete(file);
597
+ setItemState.call(this, item, "error");
598
+ fireCustomEvent(this, "monster-dropzone-file-upload-error", {
599
+ file,
600
+ url,
601
+ error: new Error("upload failed"),
602
+ });
603
+ triggerAction.call(this, "uploadError", {
604
+ files: [file],
605
+ url,
606
+ error: new Error("upload failed"),
607
+ });
608
+ reject(new Error("upload failed"));
609
+ });
610
+
611
+ xhr.addEventListener("abort", () => {
612
+ this[fileRequestMapSymbol]?.delete(file);
613
+ setItemState.call(this, item, "error");
614
+ fireCustomEvent(this, "monster-dropzone-file-upload-error", {
615
+ file,
616
+ url,
617
+ error: new Error("upload aborted"),
618
+ });
619
+ triggerAction.call(this, "uploadError", {
620
+ files: [file],
621
+ url,
622
+ error: new Error("upload aborted"),
623
+ });
624
+ reject(new Error("upload aborted"));
625
+ });
626
+
627
+ setItemState.call(this, item, "uploading");
628
+ fireCustomEvent(this, "monster-dropzone-file-upload-start", { file, url });
629
+ triggerAction.call(this, "uploadStart", { files: [file], url });
630
+ this[fileRequestMapSymbol].set(file, xhr);
631
+ xhr.send(formData);
632
+ });
633
+ }
634
+
635
+ /**
636
+ * @private
637
+ * @param {XMLHttpRequest} xhr
638
+ * @return {*}
639
+ */
640
+ function parseXhrResponse(xhr) {
641
+ const contentType = xhr.getResponseHeader("content-type") || "";
642
+ if (contentType.includes("application/json")) {
643
+ try {
644
+ return JSON.parse(xhr.responseText || "{}");
645
+ } catch {
646
+ return {};
647
+ }
648
+ }
649
+ return xhr.responseText;
650
+ }
651
+
652
+ /**
653
+ * @private
654
+ * @param {boolean} active
655
+ */
656
+ function setDropActive(active) {
657
+ const dropzone = this[dropzoneElementSymbol];
658
+ if (!dropzone) {
659
+ return;
660
+ }
661
+ dropzone.classList.toggle("is-dragover", active);
662
+ }
663
+
664
+ /**
665
+ * @private
666
+ * @param {string} text
667
+ */
668
+ function setStatus(text) {
669
+ const status = this[statusElementSymbol];
670
+ if (!status) {
671
+ return;
672
+ }
673
+ status.textContent = isString(text) ? text : "";
674
+ }
675
+
676
+ /**
677
+ * @private
678
+ * @param {File} file
679
+ */
680
+ function addFileItem(file) {
681
+ const list = this[listElementSymbol];
682
+ if (!list || !file) {
683
+ return;
684
+ }
685
+
686
+ const item = document.createElement("li");
687
+ item.setAttribute("data-monster-role", "item");
688
+
689
+ const preview = document.createElement("div");
690
+ preview.setAttribute("data-monster-role", "preview");
691
+
692
+ const meta = document.createElement("div");
693
+ meta.setAttribute("data-monster-role", "meta");
694
+
695
+ const name = document.createElement("div");
696
+ name.setAttribute("data-monster-role", "name");
697
+ name.textContent = file.name;
698
+
699
+ const info = document.createElement("div");
700
+ info.setAttribute("data-monster-role", "info");
701
+ info.textContent = `${formatFileType(file)} | ${formatFileSize(file.size)}`;
702
+
703
+ const progress = document.createElement("div");
704
+ progress.setAttribute("data-monster-role", "progress");
705
+
706
+ const bar = document.createElement("div");
707
+ bar.setAttribute("data-monster-role", "bar");
708
+ progress.appendChild(bar);
709
+
710
+ const percent = document.createElement("div");
711
+ percent.setAttribute("data-monster-role", "percent");
712
+ percent.textContent = "0%";
713
+
714
+ const stateIcon = document.createElement("div");
715
+ stateIcon.setAttribute("data-monster-role", "state-icon");
716
+
717
+ const removeButton = document.createElement("button");
718
+ removeButton.setAttribute("type", "button");
719
+ removeButton.setAttribute("data-monster-role", "remove");
720
+ removeButton.setAttribute("aria-label", "remove");
721
+ removeButton.innerHTML = getIconMarkup("cancel");
722
+
723
+ const retryButton = document.createElement("button");
724
+ retryButton.setAttribute("type", "button");
725
+ retryButton.setAttribute("data-monster-role", "retry");
726
+ retryButton.setAttribute("aria-label", "retry");
727
+ retryButton.innerHTML = getIconMarkup("reload");
728
+
729
+ removeButton.addEventListener("click", () => {
730
+ handleItemRemove.call(this, file);
731
+ });
732
+
733
+ retryButton.addEventListener("click", () => {
734
+ handleItemRetry.call(this, file);
735
+ });
736
+
737
+ meta.appendChild(name);
738
+ meta.appendChild(info);
739
+
740
+ item.appendChild(preview);
741
+ item.appendChild(meta);
742
+ item.appendChild(progress);
743
+ item.appendChild(percent);
744
+ item.appendChild(stateIcon);
745
+ item.appendChild(retryButton);
746
+ item.appendChild(removeButton);
747
+ list.appendChild(item);
748
+
749
+ const showPreview =
750
+ this.getOption("features.previewImages") === true &&
751
+ isString(file.type) &&
752
+ file.type.startsWith("image/");
753
+
754
+ if (showPreview) {
755
+ const img = document.createElement("img");
756
+ img.setAttribute("data-monster-role", "preview-image");
757
+ img.alt = file.name;
758
+ const url = URL.createObjectURL(file);
759
+ img.src = url;
760
+ img.addEventListener(
761
+ "load",
762
+ () => {
763
+ URL.revokeObjectURL(url);
764
+ },
765
+ { once: true },
766
+ );
767
+ preview.appendChild(img);
768
+ } else {
769
+ const icon = document.createElement("div");
770
+ icon.setAttribute("data-monster-role", "preview-icon");
771
+ icon.textContent = formatFileExtension(file);
772
+ preview.appendChild(icon);
773
+ }
774
+
775
+ this[fileItemMapSymbol].set(file, item);
776
+ }
777
+
778
+ /**
779
+ * @private
780
+ * @param {HTMLElement|null} item
781
+ * @param {number} percent
782
+ */
783
+ function updateFileProgress(item, percent) {
784
+ if (!item) {
785
+ return;
786
+ }
787
+ const bar = item.querySelector(`[${ATTRIBUTE_ROLE}=bar]`);
788
+ if (bar instanceof HTMLElement) {
789
+ bar.style.width = `${percent}%`;
790
+ }
791
+ const label = item.querySelector(`[${ATTRIBUTE_ROLE}=percent]`);
792
+ if (label instanceof HTMLElement) {
793
+ label.textContent = `${percent}%`;
794
+ }
795
+ }
796
+
797
+ /**
798
+ * @private
799
+ * @param {HTMLElement|null} item
800
+ * @param {string} icon
801
+ */
802
+ /**
803
+ * @private
804
+ * @param {HTMLElement|null} item
805
+ * @param {string} state
806
+ */
807
+ function setItemState(item, state) {
808
+ if (!item) {
809
+ return;
810
+ }
811
+ item.setAttribute("data-monster-state", state);
812
+ setStateIcon.call(this, item, state);
813
+ if (state === "success" || state === "error") {
814
+ scheduleDisappear.call(this, item);
815
+ }
816
+ }
817
+
818
+ /**
819
+ * @private
820
+ * @param {HTMLElement|null} item
821
+ * @param {string} state
822
+ */
823
+ function setStateIcon(item, state) {
824
+ if (!item) {
825
+ return;
826
+ }
827
+ const target = item.querySelector(`[${ATTRIBUTE_ROLE}=state-icon]`);
828
+ if (!(target instanceof HTMLElement)) {
829
+ return;
830
+ }
831
+ if (state === "success") {
832
+ target.innerHTML = getIconMarkup("success");
833
+ return;
834
+ }
835
+ if (state === "error") {
836
+ target.innerHTML = getIconMarkup("error");
837
+ return;
838
+ }
839
+ target.innerHTML = "";
840
+ }
841
+
842
+ /**
843
+ * @private
844
+ * @param {File} file
845
+ */
846
+ function handleItemRemove(file) {
847
+ const item = this[fileItemMapSymbol]?.get(file);
848
+ const xhr = this[fileRequestMapSymbol]?.get(file);
849
+ const timeout = this[fileTimeoutMapSymbol]?.get(file);
850
+
851
+ if (xhr instanceof XMLHttpRequest) {
852
+ xhr.abort();
853
+ this[fileRequestMapSymbol]?.delete(file);
854
+ }
855
+
856
+ if (timeout) {
857
+ clearTimeout(timeout);
858
+ this[fileTimeoutMapSymbol]?.delete(file);
859
+ }
860
+
861
+ if (item && item.parentElement) {
862
+ item.parentElement.removeChild(item);
863
+ }
864
+
865
+ this[fileItemMapSymbol]?.delete(file);
866
+ fireCustomEvent(this, "monster-dropzone-file-removed", { file });
867
+ triggerAction.call(this, "fileRemoved", { file });
868
+ }
869
+
870
+ /**
871
+ * @private
872
+ * @param {File} file
873
+ */
874
+ function handleItemRetry(file) {
875
+ const item = this[fileItemMapSymbol]?.get(file);
876
+ const timeout = this[fileTimeoutMapSymbol]?.get(file);
877
+ if (!item) {
878
+ return;
879
+ }
880
+ if (timeout) {
881
+ clearTimeout(timeout);
882
+ this[fileTimeoutMapSymbol]?.delete(file);
883
+ }
884
+ updateFileProgress.call(this, item, 0);
885
+ setItemState.call(this, item, "uploading");
886
+ fireCustomEvent(this, "monster-dropzone-file-retry", { file });
887
+ triggerAction.call(this, "fileRetry", { file });
888
+ uploadSingleFile
889
+ .call(this, file, this.getOption("url"), item)
890
+ .catch(() => {});
891
+ }
892
+
893
+ /**
894
+ * @private
895
+ * @param {string} name
896
+ * @param {object} payload
897
+ */
898
+ function triggerAction(name, payload) {
899
+ const action = this.getOption(`actions.${name}`);
900
+ if (isFunction(action)) {
901
+ action.call(this, payload);
902
+ }
903
+ }
904
+
905
+ /**
906
+ * @private
907
+ * @param {HTMLElement} item
908
+ */
909
+ function scheduleDisappear(item) {
910
+ if (this.getOption("features.disappear") !== true) {
911
+ return;
912
+ }
913
+
914
+ const delay =
915
+ this.getOption("disappear.duration") ??
916
+ this.getOption("disappear.time") ??
917
+ 3000;
918
+
919
+ const file = [...this[fileItemMapSymbol].entries()].find(
920
+ ([, value]) => value === item,
921
+ )?.[0];
922
+
923
+ if (!file) {
924
+ return;
925
+ }
926
+
927
+ if (this[fileTimeoutMapSymbol].has(file)) {
928
+ clearTimeout(this[fileTimeoutMapSymbol].get(file));
929
+ }
930
+
931
+ const timeout = setTimeout(() => {
932
+ item.classList.add("is-disappearing");
933
+ setTimeout(() => {
934
+ handleItemRemove.call(this, file);
935
+ }, 250);
936
+ }, delay);
937
+
938
+ this[fileTimeoutMapSymbol].set(file, timeout);
939
+ }
940
+ /**
941
+ * @private
942
+ * @param {File} file
943
+ * @return {string}
944
+ */
945
+ function formatFileType(file) {
946
+ if (isString(file.type) && file.type !== "") {
947
+ return file.type;
948
+ }
949
+ return "unknown";
950
+ }
951
+
952
+ /**
953
+ * @private
954
+ * @param {File} file
955
+ * @return {string}
956
+ */
957
+ function formatFileExtension(file) {
958
+ const parts = file.name.split(".");
959
+ if (parts.length < 2) {
960
+ return "file";
961
+ }
962
+ const ext = parts.pop() || "file";
963
+ return ext.slice(0, 4).toLowerCase();
964
+ }
965
+
966
+ /**
967
+ * @private
968
+ * @param {number} bytes
969
+ * @return {string}
970
+ */
971
+ function formatFileSize(bytes) {
972
+ if (!Number.isFinite(bytes)) {
973
+ return "0 B";
974
+ }
975
+ const units = ["B", "KB", "MB", "GB"];
976
+ let size = bytes;
977
+ let unitIndex = 0;
978
+ while (size >= 1024 && unitIndex < units.length - 1) {
979
+ size /= 1024;
980
+ unitIndex += 1;
981
+ }
982
+ return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
983
+ }
984
+
985
+ /**
986
+ * @private
987
+ * @param {string} kind
988
+ * @return {string}
989
+ */
990
+ function getIconMarkup(kind) {
991
+ switch (kind) {
992
+ case "reload":
993
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
994
+ <path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
995
+ <path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/>
996
+ </svg>`;
997
+ case "success":
998
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
999
+ <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
1000
+ <path d="m10.97 4.97-.02.022-3.473 4.425-2.093-2.094a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05"/>
1001
+ </svg>`;
1002
+ case "error":
1003
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
1004
+ <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
1005
+ <path d="M7.002 11a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 4.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z"/>
1006
+ </svg>`;
1007
+ case "cancel":
1008
+ default:
1009
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
1010
+ <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
1011
+ <path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708"/>
1012
+ </svg>`;
1013
+ }
1014
+ }
1015
+
1016
+ /**
1017
+ * @private
1018
+ * @return {string}
1019
+ */
1020
+ function getTemplate() {
1021
+ // language=HTML
1022
+ return `
1023
+ <div data-monster-role="control" part="control">
1024
+ <div data-monster-role="dropzone" part="dropzone" tabindex="0"
1025
+ data-monster-attributes="
1026
+ class path:classes.dropzone,
1027
+ data-monster-disabled path:disabled | if:true">
1028
+ <div data-monster-role="content" part="content">
1029
+ <div data-monster-role="title" part="title"
1030
+ data-monster-replace="path:labels.title"></div>
1031
+ <div data-monster-role="hint" part="hint"
1032
+ data-monster-replace="path:labels.hint"></div>
1033
+ </div>
1034
+ <monster-button data-monster-role="button" part="button"
1035
+ data-monster-attributes="
1036
+ data-monster-button-class path:classes.button,
1037
+ disabled path:disabled | if:true">
1038
+ <span data-monster-replace="path:labels.button"></span>
1039
+ </monster-button>
1040
+ <input data-monster-role="input" part="input" type="file"
1041
+ data-monster-attributes="
1042
+ accept path:accept,
1043
+ multiple path:multiple | if:true,
1044
+ disabled path:disabled | if:true">
1045
+ </div>
1046
+ <div data-monster-role="status" part="status"></div>
1047
+ <ul data-monster-role="list" part="list"></ul>
1048
+ </div>`;
1049
+ }
1050
+
1051
+ /**
1052
+ * @private
1053
+ * @returns {object}
1054
+ */
1055
+ function getTranslations() {
1056
+ const locale = getLocaleOfDocument();
1057
+ switch (locale.language) {
1058
+ case "de":
1059
+ return {
1060
+ title: "Dokumente hochladen",
1061
+ hint: "oder Dateien hierhin ziehen",
1062
+ button: "Dateien wählen",
1063
+ statusIdle: "",
1064
+ statusUploading: "Upload läuft ...",
1065
+ statusSuccess: "Upload abgeschlossen.",
1066
+ statusError: "Upload fehlgeschlagen.",
1067
+ statusMissingUrl: "Keine Upload-URL konfiguriert.",
1068
+ };
1069
+ case "es":
1070
+ return {
1071
+ title: "Subir documentos",
1072
+ hint: "o arrastre archivos aquí",
1073
+ button: "Seleccionar archivos",
1074
+ statusIdle: "",
1075
+ statusUploading: "Cargando ...",
1076
+ statusSuccess: "Subida completa.",
1077
+ statusError: "Error al subir.",
1078
+ statusMissingUrl: "No se ha configurado la URL de carga.",
1079
+ };
1080
+ case "zh":
1081
+ return {
1082
+ title: "上传文档",
1083
+ hint: "或将文件拖到此处",
1084
+ button: "选择文件",
1085
+ statusIdle: "",
1086
+ statusUploading: "正在上传...",
1087
+ statusSuccess: "上传完成。",
1088
+ statusError: "上传失败。",
1089
+ statusMissingUrl: "未配置上传URL。",
1090
+ };
1091
+ case "hi":
1092
+ return {
1093
+ title: "दस्तावेज़ अपलोड करें",
1094
+ hint: "या फ़ाइलें यहाँ खींचें",
1095
+ button: "फ़ाइलें चुनें",
1096
+ statusIdle: "",
1097
+ statusUploading: "अपलोड हो रहा है...",
1098
+ statusSuccess: "अपलोड पूर्ण हुआ।",
1099
+ statusError: "अपलोड विफल।",
1100
+ statusMissingUrl: "अपलोड URL कॉन्फ़िगर नहीं है।",
1101
+ };
1102
+ case "bn":
1103
+ return {
1104
+ title: "ডকুমেন্ট আপলোড করুন",
1105
+ hint: "অথবা এখানে ফাইল টেনে আনুন",
1106
+ button: "ফাইল নির্বাচন করুন",
1107
+ statusIdle: "",
1108
+ statusUploading: "আপলোড হচ্ছে...",
1109
+ statusSuccess: "আপলোড সম্পন্ন।",
1110
+ statusError: "আপলোড ব্যর্থ।",
1111
+ statusMissingUrl: "আপলোড URL কনফিগার করা নেই।",
1112
+ };
1113
+ case "pt":
1114
+ return {
1115
+ title: "Enviar documentos",
1116
+ hint: "ou solte os arquivos aqui",
1117
+ button: "Selecionar arquivos",
1118
+ statusIdle: "",
1119
+ statusUploading: "Enviando...",
1120
+ statusSuccess: "Envio concluído.",
1121
+ statusError: "Falha no envio.",
1122
+ statusMissingUrl: "URL de envio não configurada.",
1123
+ };
1124
+ case "ru":
1125
+ return {
1126
+ title: "Загрузить документы",
1127
+ hint: "или перетащите файлы сюда",
1128
+ button: "Выбрать файлы",
1129
+ statusIdle: "",
1130
+ statusUploading: "Загрузка...",
1131
+ statusSuccess: "Загрузка завершена.",
1132
+ statusError: "Ошибка загрузки.",
1133
+ statusMissingUrl: "URL загрузки не настроен.",
1134
+ };
1135
+ case "ja":
1136
+ return {
1137
+ title: "ドキュメントをアップロード",
1138
+ hint: "またはここにファイルをドロップ",
1139
+ button: "ファイルを選択",
1140
+ statusIdle: "",
1141
+ statusUploading: "アップロード中...",
1142
+ statusSuccess: "アップロード完了。",
1143
+ statusError: "アップロードに失敗しました。",
1144
+ statusMissingUrl: "アップロードURLが設定されていません。",
1145
+ };
1146
+ case "pa":
1147
+ return {
1148
+ title: "ਦਸਤਾਵੇਜ਼ ਅਪਲੋਡ ਕਰੋ",
1149
+ hint: "ਜਾਂ ਫਾਈਲਾਂ ਇੱਥੇ ਖਿੱਚੋ",
1150
+ button: "ਫਾਈਲਾਂ ਚੁਣੋ",
1151
+ statusIdle: "",
1152
+ statusUploading: "ਅਪਲੋਡ ਹੋ ਰਿਹਾ ਹੈ...",
1153
+ statusSuccess: "ਅਪਲੋਡ ਪੂਰਾ ਹੋ ਗਿਆ।",
1154
+ statusError: "ਅਪਲੋਡ ਫੇਲ੍ਹ ਹੋ ਗਿਆ।",
1155
+ statusMissingUrl: "ਅਪਲੋਡ URL ਸੰਰਚਿਤ ਨਹੀਂ ਹੈ।",
1156
+ };
1157
+ case "mr":
1158
+ return {
1159
+ title: "दस्तऐवज अपलोड करा",
1160
+ hint: "किंवा फायली येथे ओढा",
1161
+ button: "फायली निवडा",
1162
+ statusIdle: "",
1163
+ statusUploading: "अपलोड होत आहे...",
1164
+ statusSuccess: "अपलोड पूर्ण झाले.",
1165
+ statusError: "अपलोड अयशस्वी.",
1166
+ statusMissingUrl: "अपलोड URL संरचीत केलेले नाही.",
1167
+ };
1168
+ case "fr":
1169
+ return {
1170
+ title: "Téléverser des documents",
1171
+ hint: "ou déposer les fichiers ici",
1172
+ button: "Choisir des fichiers",
1173
+ statusIdle: "",
1174
+ statusUploading: "Téléversement en cours...",
1175
+ statusSuccess: "Téléversement terminé.",
1176
+ statusError: "Échec du téléversement.",
1177
+ statusMissingUrl: "URL de téléversement non configurée.",
1178
+ };
1179
+ case "it":
1180
+ return {
1181
+ title: "Carica documenti",
1182
+ hint: "oppure trascina i file qui",
1183
+ button: "Seleziona file",
1184
+ statusIdle: "",
1185
+ statusUploading: "Caricamento in corso...",
1186
+ statusSuccess: "Caricamento completato.",
1187
+ statusError: "Caricamento non riuscito.",
1188
+ statusMissingUrl: "URL di caricamento non configurata.",
1189
+ };
1190
+ case "nl":
1191
+ return {
1192
+ title: "Documenten uploaden",
1193
+ hint: "of sleep bestanden hierheen",
1194
+ button: "Bestanden kiezen",
1195
+ statusIdle: "",
1196
+ statusUploading: "Uploaden...",
1197
+ statusSuccess: "Upload voltooid.",
1198
+ statusError: "Upload mislukt.",
1199
+ statusMissingUrl: "Upload-URL niet geconfigureerd.",
1200
+ };
1201
+ case "sv":
1202
+ return {
1203
+ title: "Ladda upp dokument",
1204
+ hint: "eller dra filer hit",
1205
+ button: "Välj filer",
1206
+ statusIdle: "",
1207
+ statusUploading: "Uppladdning pågår...",
1208
+ statusSuccess: "Uppladdning klar.",
1209
+ statusError: "Uppladdning misslyckades.",
1210
+ statusMissingUrl: "Ingen uppladdnings-URL konfigurerad.",
1211
+ };
1212
+ case "pl":
1213
+ return {
1214
+ title: "Prześlij dokumenty",
1215
+ hint: "lub upuść pliki tutaj",
1216
+ button: "Wybierz pliki",
1217
+ statusIdle: "",
1218
+ statusUploading: "Przesyłanie...",
1219
+ statusSuccess: "Przesyłanie zakończone.",
1220
+ statusError: "Przesyłanie nieudane.",
1221
+ statusMissingUrl: "Brak skonfigurowanego URL przesyłania.",
1222
+ };
1223
+ case "da":
1224
+ return {
1225
+ title: "Upload dokumenter",
1226
+ hint: "eller træk filer her",
1227
+ button: "Vælg filer",
1228
+ statusIdle: "",
1229
+ statusUploading: "Uploader...",
1230
+ statusSuccess: "Upload fuldført.",
1231
+ statusError: "Upload mislykkedes.",
1232
+ statusMissingUrl: "Ingen upload-URL konfigureret.",
1233
+ };
1234
+ case "fi":
1235
+ return {
1236
+ title: "Lataa asiakirjoja",
1237
+ hint: "tai pudota tiedostot tähän",
1238
+ button: "Valitse tiedostot",
1239
+ statusIdle: "",
1240
+ statusUploading: "Siirretään...",
1241
+ statusSuccess: "Siirto valmis.",
1242
+ statusError: "Siirto epäonnistui.",
1243
+ statusMissingUrl: "Siirto-URL ei ole määritetty.",
1244
+ };
1245
+ case "no":
1246
+ return {
1247
+ title: "Last opp dokumenter",
1248
+ hint: "eller dra filer hit",
1249
+ button: "Velg filer",
1250
+ statusIdle: "",
1251
+ statusUploading: "Laster opp...",
1252
+ statusSuccess: "Opplasting fullført.",
1253
+ statusError: "Opplasting feilet.",
1254
+ statusMissingUrl: "Ingen opplastings-URL konfigurert.",
1255
+ };
1256
+ case "cs":
1257
+ return {
1258
+ title: "Nahrát dokumenty",
1259
+ hint: "nebo přetáhněte soubory sem",
1260
+ button: "Vybrat soubory",
1261
+ statusIdle: "",
1262
+ statusUploading: "Nahrávání...",
1263
+ statusSuccess: "Nahrávání dokončeno.",
1264
+ statusError: "Nahrávání selhalo.",
1265
+ statusMissingUrl: "URL pro nahrávání není nastavena.",
1266
+ };
1267
+ default:
1268
+ case "en":
1269
+ return {
1270
+ title: "Upload documents",
1271
+ hint: "or drop files here",
1272
+ button: "Choose files",
1273
+ statusIdle: "",
1274
+ statusUploading: "Uploading ...",
1275
+ statusSuccess: "Upload complete.",
1276
+ statusError: "Upload failed.",
1277
+ statusMissingUrl: "No upload URL configured.",
1278
+ };
1279
+ }
1280
+ }
1281
+
1282
+ registerCustomElement(Dropzone);