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