@georapbox/files-dropzone-element 1.0.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,505 @@
1
+ import { isValidFile } from './utils/is-valid-file.js';
2
+ import { getFilesFromEvent } from './utils/files-selector.js';
3
+
4
+ const COMPONENT_NAME = 'files-dropzone';
5
+ const TOO_MANY_FILES = 'TOO_MANY_FILES';
6
+ const FILE_TOO_LARGE = 'FILE_TOO_LARGE';
7
+ const FILE_TOO_SMALL = 'FILE_TOO_SMALL';
8
+ const INVALID_MIME_TYPE = 'INVALID_MIME_TYPE';
9
+ const template = document.createElement('template');
10
+
11
+ template.innerHTML = /* html */`
12
+ <style>
13
+ *,
14
+ *::before,
15
+ *::after {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ :host([hidden]),
20
+ [hidden] {
21
+ display: none !important;
22
+ }
23
+
24
+ :host {
25
+ --dropzone-border-width: 2px;
26
+ --dropzone-border-style: dashed;
27
+ --dropzone-border-radius: 0.25rem;
28
+ --dropzone-border-color: #71717a;
29
+ --dropzone-border-color-dragover: #0ea5e9;
30
+ --dropzone-background-color: #ffffff;
31
+ --dropzone-background-color-dragover: #f4f4f5;
32
+ --dropzone-body-color: #3f3f46;
33
+ --dropzone-body-color-dragover: var(--dropzone-body-color);
34
+
35
+ display: block;
36
+ }
37
+
38
+ :host(:not([no-style])) .dropzone {
39
+ border: var(--dropzone-border-width) var(--dropzone-border-style) var(--dropzone-border-color);
40
+ border-radius: var(--dropzone-border-radius);
41
+ padding: 3rem 1rem;
42
+ overflow: hidden;
43
+ background-color: var(--dropzone-background-color);
44
+ color: var(--dropzone-body-color);
45
+ text-align: center;
46
+ cursor: pointer;
47
+ transition: border 0.2s ease-in-out, background-color 0.2s ease-in-out, color 0.2s ease-in-out;
48
+ }
49
+
50
+ :host(:not([no-style])[no-click]) .dropzone {
51
+ cursor: default;
52
+ }
53
+
54
+ :host(:not([no-style])[disabled]) .dropzone {
55
+ opacity: 0.8;
56
+ cursor: not-allowed;
57
+ }
58
+
59
+ :host(:not([no-style]):not([disabled])) .dropzone--dragover {
60
+ border-color: var(--dropzone-border-color-dragover);
61
+ background-color: var(--dropzone-background-color-dragover);
62
+ color: var(--dropzone-body-color-dragover);
63
+ }
64
+ </style>
65
+
66
+ <input type="file" id="fileInput" hidden>
67
+
68
+ <div part="dropzone" class="dropzone" id="dropzoneEl" tabindex="0" role="presentation">
69
+ <slot>Drag 'n' drop files here, or click to select files</slot>
70
+ </div>
71
+ `;
72
+
73
+ class FilesDropzone extends HTMLElement {
74
+ #fileInput;
75
+ #dropzoneEl;
76
+
77
+ constructor() {
78
+ super();
79
+
80
+ if (!this.shadowRoot) {
81
+ this.attachShadow({ mode: 'open' });
82
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
83
+ }
84
+
85
+ this.#fileInput = this.shadowRoot.getElementById('fileInput');
86
+ this.#dropzoneEl = this.shadowRoot.getElementById('dropzoneEl');
87
+ }
88
+
89
+ static get observedAttributes() {
90
+ return ['accept', 'disabled', 'multiple', 'no-keyboard'];
91
+ }
92
+
93
+ attributeChangedCallback(name, oldValue, newValue) {
94
+ if (name === 'accept' && oldValue !== newValue && this.#fileInput) {
95
+ this.#fileInput.accept = this.accept;
96
+ }
97
+
98
+ if (name === 'disabled' && oldValue !== newValue && this.#fileInput) {
99
+ this.#fileInput.disabled = this.disabled;
100
+
101
+ if (this.disabled) {
102
+ this.#dropzoneEl.removeAttribute('tabindex');
103
+ } else {
104
+ this.#dropzoneEl.setAttribute('tabindex', '0');
105
+ }
106
+ }
107
+
108
+ if (name === 'multiple' && oldValue !== newValue && this.#fileInput) {
109
+ this.#fileInput.multiple = this.multiple;
110
+ }
111
+
112
+ if (name === 'no-keyboard' && oldValue !== newValue && this.#dropzoneEl) {
113
+ if (this.noKeyboard) {
114
+ this.#dropzoneEl.removeAttribute('tabindex');
115
+ } else {
116
+ this.#dropzoneEl.setAttribute('tabindex', '0');
117
+ }
118
+ }
119
+ }
120
+
121
+ connectedCallback() {
122
+ this.#upgradeProperty('accept');
123
+ this.#upgradeProperty('disabled');
124
+ this.#upgradeProperty('maxFiles');
125
+ this.#upgradeProperty('maxSize');
126
+ this.#upgradeProperty('minSize');
127
+ this.#upgradeProperty('multiple');
128
+ this.#upgradeProperty('noClick');
129
+ this.#upgradeProperty('noDrag');
130
+ this.#upgradeProperty('noKeyboard');
131
+ this.#upgradeProperty('noStyle');
132
+
133
+ this.#fileInput.addEventListener('change', this.#onFileInputChange);
134
+ this.#dropzoneEl.addEventListener('dragenter', this.#onDragEnter);
135
+ this.#dropzoneEl.addEventListener('dragover', this.#onDragOver);
136
+ this.#dropzoneEl.addEventListener('dragleave', this.#onDragLeave);
137
+ this.#dropzoneEl.addEventListener('drop', this.#onDrop);
138
+ this.#dropzoneEl.addEventListener('click', this.#onClick);
139
+ this.#dropzoneEl.addEventListener('keyup', this.#onKeyUp);
140
+ }
141
+
142
+ disconnectedCallback() {
143
+ this.#fileInput.removeEventListener('change', this.#onFileInputChange);
144
+ this.#dropzoneEl.removeEventListener('dragenter', this.#onDragEnter);
145
+ this.#dropzoneEl.removeEventListener('dragover', this.#onDragOver);
146
+ this.#dropzoneEl.removeEventListener('dragleave', this.#onDragLeave);
147
+ this.#dropzoneEl.removeEventListener('drop', this.#onDrop);
148
+ this.#dropzoneEl.removeEventListener('click', this.#onClick);
149
+ this.#dropzoneEl.removeEventListener('keyup', this.#onKeyUp);
150
+ }
151
+
152
+ get accept() {
153
+ return this.getAttribute('accept');
154
+ }
155
+
156
+ set accept(value) {
157
+ this.setAttribute('accept', value);
158
+ }
159
+
160
+ get disabled() {
161
+ return this.hasAttribute('disabled');
162
+ }
163
+
164
+ set disabled(value) {
165
+ if (value) {
166
+ this.setAttribute('disabled', '');
167
+ } else {
168
+ this.removeAttribute('disabled');
169
+ }
170
+ }
171
+
172
+ get maxFiles() {
173
+ const num = Number(this.getAttribute('max-files')) || 0;
174
+
175
+ if (num <= 0) {
176
+ return Infinity;
177
+ }
178
+
179
+ return Math.floor(Math.abs(num));
180
+ }
181
+
182
+ set maxFiles(value) {
183
+ this.setAttribute('max-files', value);
184
+ }
185
+
186
+ get maxSize() {
187
+ const value = this.getAttribute('max-size');
188
+
189
+ if (value === null) {
190
+ return Infinity;
191
+ }
192
+
193
+ const num = Number(value);
194
+
195
+ return Number.isNaN(num) ? Infinity : num;
196
+ }
197
+
198
+ set maxSize(value) {
199
+ this.setAttribute('max-size', value);
200
+ }
201
+
202
+ get minSize() {
203
+ const value = this.getAttribute('min-size');
204
+
205
+ if (value === null) {
206
+ return 0;
207
+ }
208
+
209
+ const num = Number(value);
210
+
211
+ return Number.isNaN(num) ? 0 : num;
212
+ }
213
+
214
+ set minSize(value) {
215
+ this.setAttribute('min-size', value);
216
+ }
217
+
218
+ get multiple() {
219
+ return this.hasAttribute('multiple');
220
+ }
221
+
222
+ set multiple(value) {
223
+ if (value) {
224
+ this.setAttribute('multiple', '');
225
+ } else {
226
+ this.removeAttribute('multiple');
227
+ }
228
+ }
229
+
230
+ get noClick() {
231
+ return this.hasAttribute('no-click');
232
+ }
233
+
234
+ set noClick(value) {
235
+ if (value) {
236
+ this.setAttribute('no-click', '');
237
+ } else {
238
+ this.removeAttribute('no-click');
239
+ }
240
+ }
241
+
242
+ get noDrag() {
243
+ return this.hasAttribute('no-drag');
244
+ }
245
+
246
+ set noDrag(value) {
247
+ if (value) {
248
+ this.setAttribute('no-drag', '');
249
+ } else {
250
+ this.removeAttribute('no-drag');
251
+ }
252
+ }
253
+
254
+ get noKeyboard() {
255
+ return this.hasAttribute('no-keyboard');
256
+ }
257
+
258
+ set noKeyboard(value) {
259
+ if (value) {
260
+ this.setAttribute('no-keyboard', '');
261
+ } else {
262
+ this.removeAttribute('no-keyboard');
263
+ }
264
+ }
265
+
266
+ get noStyle() {
267
+ return this.hasAttribute('no-style');
268
+ }
269
+
270
+ set noStyle(value) {
271
+ if (value) {
272
+ this.setAttribute('no-style', '');
273
+ } else {
274
+ this.removeAttribute('no-style');
275
+ }
276
+ }
277
+
278
+ #onFileInputChange = async evt => {
279
+ try {
280
+ this.#handleFilesSelect(await getFilesFromEvent(evt));
281
+ } catch (error) {
282
+ this.dispatchEvent(new CustomEvent(`${COMPONENT_NAME}-error`, {
283
+ bubbles: true,
284
+ composed: true,
285
+ detail: { error }
286
+ }));
287
+ }
288
+ };
289
+
290
+ #onDragEnter = () => {
291
+ if (this.disabled || this.noDrag) {
292
+ return;
293
+ }
294
+
295
+ this.dispatchEvent(new Event(`${COMPONENT_NAME}-dragenter`, {
296
+ bubbles: true,
297
+ composed: true
298
+ }));
299
+ };
300
+
301
+ #onDragOver = evt => {
302
+ evt.preventDefault();
303
+
304
+ if (this.disabled || this.noDrag) {
305
+ evt.dataTransfer.dropEffect = 'none';
306
+ return;
307
+ }
308
+
309
+ evt.dataTransfer.dropEffect = 'copy';
310
+
311
+ this.#dropzoneEl.classList.add('dropzone--dragover');
312
+ this.#dropzoneEl.part.add('dropzone--dragover');
313
+
314
+ this.dispatchEvent(new Event(`${COMPONENT_NAME}-dragover`, {
315
+ bubbles: true,
316
+ composed: true
317
+ }));
318
+ };
319
+
320
+ #onDragLeave = () => {
321
+ if (this.disabled || this.noDrag) {
322
+ return;
323
+ }
324
+
325
+ this.#dropzoneEl.classList.remove('dropzone--dragover');
326
+ this.#dropzoneEl.part.remove('dropzone--dragover');
327
+
328
+ this.dispatchEvent(new Event(`${COMPONENT_NAME}-dragleave`, {
329
+ bubbles: true,
330
+ composed: true
331
+ }));
332
+ };
333
+
334
+ #onDrop = async evt => {
335
+ if (this.disabled || this.noDrag) {
336
+ return;
337
+ }
338
+
339
+ evt.preventDefault();
340
+
341
+ this.#dropzoneEl.classList.remove('dropzone--dragover');
342
+ this.#dropzoneEl.part.remove('dropzone--dragover');
343
+
344
+ try {
345
+ this.#handleFilesSelect(await getFilesFromEvent(evt));
346
+ } catch (error) {
347
+ this.dispatchEvent(new CustomEvent(`${COMPONENT_NAME}-error`, {
348
+ bubbles: true,
349
+ composed: true,
350
+ detail: { error }
351
+ }));
352
+ }
353
+ };
354
+
355
+ #onClick = () => {
356
+ if (this.disabled || this.noClick) {
357
+ return;
358
+ }
359
+
360
+ this.#fileInput.click();
361
+ };
362
+
363
+ #onKeyUp = evt => {
364
+ if (this.disabled || this.noKeyboard) {
365
+ return;
366
+ }
367
+
368
+ if (evt.key === ' ' || evt.key === 'Enter') {
369
+ this.#fileInput.click();
370
+ }
371
+ };
372
+
373
+ #handleFilesSelect(files) {
374
+ if (!Array.isArray(files) || !files.length) {
375
+ return;
376
+ }
377
+
378
+ const acceptedFiles = [];
379
+ const rejectedFiles = [];
380
+ const filesLength = files.length;
381
+
382
+ // If the component is not in multiple mode, reject all files.
383
+ if (!this.multiple && filesLength > 1) {
384
+ for (const file of files) {
385
+ rejectedFiles.push({
386
+ file,
387
+ errors: [{
388
+ code: TOO_MANY_FILES,
389
+ message: `Too many files selected. Only 1 file is allowed.`
390
+ }]
391
+ });
392
+ }
393
+ } else if (this.multiple && filesLength > this.maxFiles) {
394
+ // If the component is in multiple mode, but the number of files exceeds
395
+ // the maxFiles attribute, reject all files.
396
+ for (const file of files) {
397
+ rejectedFiles.push({
398
+ file,
399
+ errors: [{
400
+ code: TOO_MANY_FILES,
401
+ message: `Too many files selected. Only ${this.maxFiles} ${this.maxFiles > 1 ? 'files are' : 'file is'} allowed.`
402
+ }]
403
+ });
404
+ }
405
+ } else {
406
+ // Validate each file. If it's valid, add it to the accepted files array,
407
+ // otherwise add it to the rejected files array.
408
+ for (const file of files) {
409
+ const fileHasValidType = isValidFile(file, this.accept);
410
+ const fileExceedsMaxSize = file.size > this.maxSize;
411
+ const fileIsSmallerThanMinSize = file.size < this.minSize;
412
+
413
+ if (fileHasValidType && !fileExceedsMaxSize && !fileIsSmallerThanMinSize) {
414
+ acceptedFiles.push(file);
415
+ } else {
416
+ const errors = [];
417
+
418
+ if (!fileHasValidType) {
419
+ errors.push({
420
+ code: INVALID_MIME_TYPE,
421
+ message: `File type "${file.type}" is not accepted.`
422
+ });
423
+ }
424
+
425
+ if (fileExceedsMaxSize) {
426
+ errors.push({
427
+ code: FILE_TOO_LARGE,
428
+ message: `File size ${file.size} exceeds the maximum size of ${this.maxSize}.`
429
+ });
430
+ }
431
+
432
+ if (fileIsSmallerThanMinSize) {
433
+ errors.push({
434
+ code: FILE_TOO_SMALL,
435
+ message: `File size ${file.size} is smaller than the minimum size of ${this.minSize}.`
436
+ });
437
+ }
438
+
439
+ rejectedFiles.push({ file, errors });
440
+ }
441
+ }
442
+ }
443
+
444
+ this.dispatchEvent(new CustomEvent(`${COMPONENT_NAME}-drop`, {
445
+ bubbles: true,
446
+ composed: true,
447
+ detail: {
448
+ acceptedFiles,
449
+ rejectedFiles
450
+ }
451
+ }));
452
+
453
+ if (acceptedFiles.length > 0) {
454
+ this.dispatchEvent(new CustomEvent(`${COMPONENT_NAME}-drop-accepted`, {
455
+ bubbles: true,
456
+ composed: true,
457
+ detail: {
458
+ acceptedFiles
459
+ }
460
+ }));
461
+ }
462
+
463
+ if (rejectedFiles.length > 0) {
464
+ this.dispatchEvent(new CustomEvent(`${COMPONENT_NAME}-drop-rejected`, {
465
+ bubbles: true,
466
+ composed: true,
467
+ detail: {
468
+ rejectedFiles
469
+ }
470
+ }));
471
+ }
472
+
473
+ this.#fileInput.value = this.#fileInput.defaultValue;
474
+ }
475
+
476
+ openFileDialog() {
477
+ if (this.disabled) {
478
+ return;
479
+ }
480
+
481
+ this.#fileInput.click();
482
+ }
483
+
484
+ /**
485
+ * https://developers.google.com/web/fundamentals/web-components/best-practices#lazy-properties
486
+ * This is to safe guard against cases where, for instance, a framework may have added the element to the page and set a
487
+ * value on one of its properties, but lazy loaded its definition. Without this guard, the upgraded element would miss that
488
+ * property and the instance property would prevent the class property setter from ever being called.
489
+ */
490
+ #upgradeProperty(prop) {
491
+ if (Object.prototype.hasOwnProperty.call(this, prop)) {
492
+ const value = this[prop];
493
+ delete this[prop];
494
+ this[prop] = value;
495
+ }
496
+ }
497
+
498
+ static defineCustomElement(elementName = COMPONENT_NAME) {
499
+ if (typeof window !== 'undefined' && !window.customElements.get(elementName)) {
500
+ window.customElements.define(elementName, FilesDropzone);
501
+ }
502
+ }
503
+ }
504
+
505
+ export { FilesDropzone };