@dodlhuat/basix 1.0.0 → 1.1.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.
Files changed (142) hide show
  1. package/README.md +1 -1
  2. package/css/accordion.scss +31 -22
  3. package/css/alert.scss +79 -27
  4. package/css/button.scss +151 -102
  5. package/css/card.scss +11 -12
  6. package/css/carousel.scss +123 -87
  7. package/css/chart.scss +9 -11
  8. package/css/chat-bubbles.scss +2 -2
  9. package/css/checkbox.scss +72 -55
  10. package/css/chips.scss +52 -52
  11. package/css/code-viewer.scss +73 -98
  12. package/css/datepicker.scss +20 -0
  13. package/css/dropdown.scss +151 -137
  14. package/css/editor.scss +9 -6
  15. package/css/file-uploader.scss +187 -195
  16. package/css/flyout-menu.scss +20 -13
  17. package/css/form.scss +168 -115
  18. package/css/gallery.scss +62 -63
  19. package/css/grid.scss +0 -1
  20. package/css/modal.scss +117 -72
  21. package/css/placeholder.scss +17 -12
  22. package/css/properties.scss +6 -0
  23. package/css/push-menu.scss +70 -23
  24. package/css/radiobutton.scss +86 -64
  25. package/css/range-slider.scss +136 -0
  26. package/css/scrollbar.scss +69 -69
  27. package/css/spinner.scss +41 -66
  28. package/css/style.css +4351 -3735
  29. package/css/style.css.map +1 -1
  30. package/css/style.scss +2 -1
  31. package/css/switch.scss +43 -42
  32. package/css/table.scss +61 -40
  33. package/css/tabs.scss +12 -7
  34. package/css/timeline.scss +72 -69
  35. package/css/timepicker.scss +151 -72
  36. package/css/toast.scss +49 -48
  37. package/css/tooltip.scss +112 -122
  38. package/css/tree.scss +135 -192
  39. package/css/typography.scss +70 -9
  40. package/css/virtual-dropdown.scss +201 -142
  41. package/js/carousel.js +45 -18
  42. package/js/carousel.ts +217 -173
  43. package/js/datepicker.js +505 -497
  44. package/js/datepicker.ts +9 -0
  45. package/js/editor.js +398 -415
  46. package/js/file-uploader.js +142 -128
  47. package/js/file-uploader.ts +364 -350
  48. package/js/gallery.js +22 -15
  49. package/js/gallery.ts +17 -12
  50. package/js/index.js +718 -720
  51. package/js/index.ts +7 -8
  52. package/js/push-menu.js +113 -101
  53. package/js/push-menu.ts +17 -2
  54. package/js/range-slider.js +26 -0
  55. package/js/range-slider.ts +33 -0
  56. package/js/timepicker.js +144 -98
  57. package/js/timepicker.ts +194 -131
  58. package/js/tree.js +56 -28
  59. package/js/tree.ts +239 -218
  60. package/package.json +1 -1
  61. package/css/accordion.css +0 -109
  62. package/css/accordion.css.map +0 -1
  63. package/css/alert.css +0 -57
  64. package/css/alert.css.map +0 -1
  65. package/css/button.css +0 -69
  66. package/css/button.css.map +0 -1
  67. package/css/card.css +0 -144
  68. package/css/card.css.map +0 -1
  69. package/css/carousel.css +0 -118
  70. package/css/carousel.css.map +0 -1
  71. package/css/chart.css +0 -159
  72. package/css/chart.css.map +0 -1
  73. package/css/chat-bubbles.css +0 -97
  74. package/css/chat-bubbles.css.map +0 -1
  75. package/css/checkbox.css +0 -77
  76. package/css/checkbox.css.map +0 -1
  77. package/css/chips.css +0 -72
  78. package/css/chips.css.map +0 -1
  79. package/css/code-viewer.css +0 -97
  80. package/css/code-viewer.css.map +0 -1
  81. package/css/colors.css +0 -63
  82. package/css/colors.css.map +0 -1
  83. package/css/datepicker.css +0 -264
  84. package/css/datepicker.css.map +0 -1
  85. package/css/defaults.css +0 -118
  86. package/css/defaults.css.map +0 -1
  87. package/css/dropdown.css +0 -146
  88. package/css/dropdown.css.map +0 -1
  89. package/css/editor.css +0 -413
  90. package/css/file-uploader.css +0 -194
  91. package/css/file-uploader.css.map +0 -1
  92. package/css/flyout-menu.css +0 -345
  93. package/css/flyout-menu.css.map +0 -1
  94. package/css/form-builder.css +0 -9
  95. package/css/form-builder.css.map +0 -1
  96. package/css/form-builder.scss +0 -11
  97. package/css/form.css +0 -130
  98. package/css/form.css.map +0 -1
  99. package/css/gallery.css +0 -91
  100. package/css/gallery.css.map +0 -1
  101. package/css/grid.css +0 -44
  102. package/css/grid.css.map +0 -1
  103. package/css/icons.css +0 -327
  104. package/css/icons.css.map +0 -1
  105. package/css/modal.css +0 -97
  106. package/css/modal.css.map +0 -1
  107. package/css/parameters.css +0 -1
  108. package/css/parameters.css.map +0 -1
  109. package/css/placeholder.css +0 -50
  110. package/css/placeholder.css.map +0 -1
  111. package/css/progress.css +0 -51
  112. package/css/progress.css.map +0 -1
  113. package/css/properties.css +0 -31
  114. package/css/properties.css.map +0 -1
  115. package/css/push-menu.css +0 -145
  116. package/css/push-menu.css.map +0 -1
  117. package/css/radiobutton.css +0 -91
  118. package/css/radiobutton.css.map +0 -1
  119. package/css/reset.css +0 -46
  120. package/css/reset.css.map +0 -1
  121. package/css/scrollbar.css +0 -91
  122. package/css/scrollbar.css.map +0 -1
  123. package/css/spinner.css +0 -118
  124. package/css/spinner.css.map +0 -1
  125. package/css/switch.css +0 -66
  126. package/css/switch.css.map +0 -1
  127. package/css/table.css +0 -201
  128. package/css/table.css.map +0 -1
  129. package/css/tabs.css +0 -135
  130. package/css/tabs.css.map +0 -1
  131. package/css/timeline.css +0 -69
  132. package/css/timeline.css.map +0 -1
  133. package/css/toast.css +0 -98
  134. package/css/toast.css.map +0 -1
  135. package/css/tooltip.css +0 -151
  136. package/css/tooltip.css.map +0 -1
  137. package/css/tree.css +0 -199
  138. package/css/tree.css.map +0 -1
  139. package/css/typography.css +0 -137
  140. package/css/typography.css.map +0 -1
  141. package/css/virtual-dropdown.css +0 -149
  142. package/css/virtual-dropdown.css.map +0 -1
@@ -1,350 +1,364 @@
1
- interface FileData {
2
- file: File;
3
- element: HTMLDivElement;
4
- }
5
-
6
- interface UploadCompletedDetail {
7
- fileCount: number;
8
- files: File[];
9
- results: PromiseSettledResult<unknown>[];
10
- }
11
-
12
- interface FileUploaderConfig {
13
- uploadUrl?: string;
14
- maxFileSize?: number;
15
- allowedTypes?: string[];
16
- }
17
-
18
- class FileUploader {
19
- private container: HTMLElement;
20
- private dropZone: HTMLElement;
21
- private fileInput: HTMLInputElement;
22
- private fileList: HTMLElement;
23
- private uploadBtn: HTMLButtonElement;
24
- private files: Map<string, FileData> = new Map();
25
- private uploadUrl: string;
26
- private maxFileSize?: number;
27
- private allowedTypes?: string[];
28
- private abortControllers: Map<string, AbortController> = new Map();
29
-
30
- constructor(elementOrSelector: string | HTMLElement, config: FileUploaderConfig = {}) {
31
- const container = typeof elementOrSelector === 'string'
32
- ? document.querySelector<HTMLElement>(elementOrSelector)
33
- : elementOrSelector;
34
-
35
- if (!container) {
36
- throw new Error(`FileUploader: Element not found for selector "${elementOrSelector}"`);
37
- }
38
-
39
- this.container = container;
40
-
41
- const dropZone = container.querySelector<HTMLElement>('#drop-zone');
42
- const fileInput = container.querySelector<HTMLInputElement>('#file-input');
43
- const fileList = container.querySelector<HTMLElement>('#file-list');
44
- const uploadBtn = container.querySelector<HTMLButtonElement>('#upload-btn');
45
-
46
- if (!dropZone || !fileInput || !fileList || !uploadBtn) {
47
- throw new Error('Required elements not found in container');
48
- }
49
-
50
- this.dropZone = dropZone;
51
- this.fileInput = fileInput;
52
- this.fileList = fileList;
53
- this.uploadBtn = uploadBtn;
54
-
55
- this.uploadUrl = config.uploadUrl ?? 'https://httpbin.org/post';
56
- this.maxFileSize = config.maxFileSize;
57
- this.allowedTypes = config.allowedTypes;
58
-
59
- this.init();
60
- }
61
-
62
- private init(): void {
63
- this.setupEventListeners();
64
- }
65
-
66
- private setupEventListeners(): void {
67
- // Drag & Drop - prevent default browser behavior
68
- (['dragenter', 'dragover', 'dragleave', 'drop'] as const).forEach(eventName => {
69
- this.dropZone.addEventListener(eventName, this.preventDefaults);
70
- });
71
-
72
- // Drag over effects
73
- (['dragenter', 'dragover'] as const).forEach(eventName => {
74
- this.dropZone.addEventListener(eventName, this.handleDragEnter);
75
- });
76
-
77
- (['dragleave', 'drop'] as const).forEach(eventName => {
78
- this.dropZone.addEventListener(eventName, this.handleDragLeave);
79
- });
80
-
81
- this.dropZone.addEventListener('drop', this.handleDrop);
82
- this.dropZone.addEventListener('click', this.handleDropZoneClick);
83
- this.fileInput.addEventListener('change', this.handleFileInputChange);
84
- this.uploadBtn.addEventListener('click', this.handleUploadClick);
85
- }
86
-
87
- private preventDefaults = (e: Event): void => {
88
- e.preventDefault();
89
- e.stopPropagation();
90
- };
91
-
92
- private handleDragEnter = (): void => {
93
- this.dropZone.classList.add('drag-over');
94
- };
95
-
96
- private handleDragLeave = (): void => {
97
- this.dropZone.classList.remove('drag-over');
98
- };
99
-
100
- private handleDrop = (e: DragEvent): void => {
101
- const droppedFiles = e.dataTransfer?.files;
102
- if (droppedFiles) {
103
- this.handleFiles(droppedFiles);
104
- }
105
- };
106
-
107
- private handleDropZoneClick = (): void => {
108
- this.fileInput.click();
109
- };
110
-
111
- private handleFileInputChange = (e: Event): void => {
112
- const target = e.target as HTMLInputElement;
113
- if (target.files) {
114
- this.handleFiles(target.files);
115
- target.value = ''; // Reset input so same file can be selected again
116
- }
117
- };
118
-
119
- private handleUploadClick = async (): Promise<void> => {
120
- if (this.files.size === 0) return;
121
-
122
- this.uploadBtn.disabled = true;
123
- this.uploadBtn.textContent = 'Uploading...';
124
-
125
- const uploadPromises = Array.from(this.files.values()).map(({ file, element }) =>
126
- this.uploadFile(file, element)
127
- );
128
-
129
- const results = await Promise.allSettled(uploadPromises);
130
-
131
- this.uploadBtn.textContent = 'Upload Complete';
132
-
133
- setTimeout(() => {
134
- this.dispatchUploadCompletedEvent(results);
135
- this.cleanupAfterUpload();
136
- this.resetUploadState();
137
- }, 1000);
138
- };
139
-
140
- private handleFiles(fileList: FileList): void {
141
- Array.from(fileList).forEach(file => {
142
- if (this.validateFile(file) && !this.files.has(file.name)) {
143
- const element = this.addFileToUI(file);
144
- this.files.set(file.name, { file, element });
145
- }
146
- });
147
- this.updateUploadButton();
148
- }
149
-
150
- private validateFile(file: File): boolean {
151
- if (this.maxFileSize && file.size > this.maxFileSize) {
152
- console.warn(`File ${file.name} exceeds maximum size`);
153
- return false;
154
- }
155
-
156
- if (this.allowedTypes && !this.allowedTypes.includes(file.type)) {
157
- console.warn(`File type ${file.type} is not allowed`);
158
- return false;
159
- }
160
-
161
- return true;
162
- }
163
-
164
- private addFileToUI(file: File): HTMLDivElement {
165
- const item = document.createElement('div');
166
- item.className = 'file-item';
167
-
168
- const escapedFileName = this.escapeHtml(file.name);
169
-
170
- item.innerHTML = `
171
- <div class="file-item-header">
172
- <div class="file-info">
173
- <div class="file-icon">
174
- <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
175
- <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
176
- <polyline points="13 2 13 9 20 9"></polyline>
177
- </svg>
178
- </div>
179
- <div class="file-details">
180
- <span class="file-name" title="${escapedFileName}">${escapedFileName}</span>
181
- <span class="file-size">${this.formatSize(file.size)}</span>
182
- </div>
183
- </div>
184
- <button class="remove-btn" type="button" aria-label="Remove file">
185
- <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
186
- <line x1="18" y1="6" x2="6" y2="18"></line>
187
- <line x1="6" y1="6" x2="18" y2="18"></line>
188
- </svg>
189
- </button>
190
- </div>
191
- <div class="progress-container" style="display: none;">
192
- <div class="progress-bar"></div>
193
- </div>
194
- <div class="status-text" style="display: none;">Waiting...</div>
195
- `;
196
-
197
- const removeBtn = item.querySelector<HTMLButtonElement>('.remove-btn');
198
- if (removeBtn) {
199
- removeBtn.addEventListener('click', (e) => {
200
- e.stopPropagation();
201
- this.removeFile(file.name);
202
- });
203
- }
204
-
205
- this.fileList.appendChild(item);
206
- return item;
207
- }
208
-
209
- private async uploadFile(file: File, element: HTMLDivElement): Promise<unknown> {
210
- const progressContainer = element.querySelector<HTMLElement>('.progress-container');
211
- const progressBar = element.querySelector<HTMLElement>('.progress-bar');
212
- const statusText = element.querySelector<HTMLElement>('.status-text');
213
- const removeBtn = element.querySelector<HTMLElement>('.remove-btn');
214
-
215
- if (!progressContainer || !progressBar || !statusText || !removeBtn) {
216
- throw new Error('Required UI elements not found');
217
- }
218
-
219
- // Show progress elements
220
- progressContainer.style.display = 'block';
221
- statusText.style.display = 'block';
222
- removeBtn.style.display = 'none';
223
-
224
- const abortController = new AbortController();
225
- this.abortControllers.set(file.name, abortController);
226
-
227
- try {
228
- const formData = new FormData();
229
- formData.append('file', file);
230
-
231
- const response = await fetch(this.uploadUrl, {
232
- method: 'POST',
233
- body: formData,
234
- signal: abortController.signal,
235
- });
236
-
237
- // Note: Fetch API doesn't support upload progress natively
238
- // For progress tracking, you'd need to use XMLHttpRequest or a library
239
- progressBar.style.width = '100%';
240
- statusText.textContent = '100%';
241
-
242
- if (response.ok) {
243
- statusText.textContent = 'Completed';
244
- statusText.classList.add('success');
245
- progressBar.style.backgroundColor = 'var(--success-color)';
246
- return await response.json();
247
- } else {
248
- throw new Error(`Upload failed: ${response.statusText}`);
249
- }
250
- } catch (error) {
251
- if (error instanceof Error && error.name === 'AbortError') {
252
- statusText.textContent = 'Cancelled';
253
- } else {
254
- statusText.textContent = error instanceof Error ? 'Error' : 'Network Error';
255
- }
256
- statusText.classList.add('error');
257
- progressBar.style.backgroundColor = 'var(--error-color)';
258
- removeBtn.style.display = 'flex';
259
- throw error;
260
- } finally {
261
- this.abortControllers.delete(file.name);
262
- }
263
- }
264
-
265
- private removeFile(fileName: string): void {
266
- // Cancel upload if in progress
267
- const abortController = this.abortControllers.get(fileName);
268
- if (abortController) {
269
- abortController.abort();
270
- }
271
-
272
- const fileData = this.files.get(fileName);
273
- if (fileData) {
274
- fileData.element.remove();
275
- this.files.delete(fileName);
276
- this.updateUploadButton();
277
- }
278
- }
279
-
280
- private updateUploadButton(): void {
281
- this.uploadBtn.disabled = this.files.size === 0;
282
- this.uploadBtn.textContent =
283
- this.files.size > 0
284
- ? `Upload ${this.files.size} File${this.files.size === 1 ? '' : 's'}`
285
- : 'Upload Files';
286
- }
287
-
288
- private dispatchUploadCompletedEvent(results: PromiseSettledResult<unknown>[]): void {
289
- const files = Array.from(this.files.values()).map(({ file }) => file);
290
-
291
- const event = new CustomEvent<UploadCompletedDetail>('upload-completed', {
292
- detail: {
293
- fileCount: this.files.size,
294
- files,
295
- results,
296
- },
297
- bubbles: true,
298
- });
299
-
300
- this.container.dispatchEvent(event);
301
- }
302
-
303
- private cleanupAfterUpload(): void {
304
- const progressContainers = this.fileList.querySelectorAll<HTMLElement>('.progress-container');
305
- progressContainers.forEach(el => el.remove());
306
-
307
- const statusTexts = this.fileList.querySelectorAll<HTMLElement>('.status-text');
308
- statusTexts.forEach(el => el.remove());
309
-
310
- const removeBtns = this.fileList.querySelectorAll<HTMLElement>('.remove-btn');
311
- removeBtns.forEach(btn => (btn.style.display = 'flex'));
312
- }
313
-
314
- private resetUploadState(): void {
315
- this.files.clear();
316
- this.updateUploadButton();
317
- }
318
-
319
- private formatSize(bytes: number): string {
320
- if (bytes === 0) return '0 Bytes';
321
-
322
- const k = 1024;
323
- const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] as const;
324
- const i = Math.floor(Math.log(bytes) / Math.log(k));
325
-
326
- return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
327
- }
328
-
329
- private escapeHtml(text: string): string {
330
- const div = document.createElement('div');
331
- div.textContent = text;
332
- return div.innerHTML;
333
- }
334
-
335
- public destroy(): void {
336
- // Cancel all ongoing uploads
337
- this.abortControllers.forEach(controller => controller.abort());
338
- this.abortControllers.clear();
339
-
340
- // Clear files
341
- this.files.clear();
342
-
343
- // Remove event listeners would require storing bound handlers
344
- // For now, removing elements will clean up
345
- this.fileList.innerHTML = '';
346
- }
347
- }
348
-
349
- export { FileUploader };
350
- export type { FileUploaderConfig, UploadCompletedDetail };
1
+ interface FileData {
2
+ file: File;
3
+ element: HTMLDivElement;
4
+ }
5
+
6
+ interface UploadCompletedDetail {
7
+ fileCount: number;
8
+ files: File[];
9
+ results: PromiseSettledResult<unknown>[];
10
+ }
11
+
12
+ interface FileValidationErrorDetail {
13
+ file: File;
14
+ reason: 'size' | 'type';
15
+ }
16
+
17
+ interface FileUploaderConfig {
18
+ uploadUrl?: string;
19
+ maxFileSize?: number;
20
+ allowedTypes?: string[];
21
+ }
22
+
23
+ class FileUploader {
24
+ private container: HTMLElement;
25
+ private dropZone: HTMLElement;
26
+ private fileInput: HTMLInputElement;
27
+ private fileList: HTMLElement;
28
+ private uploadBtn: HTMLButtonElement;
29
+ private files: Map<string, FileData> = new Map();
30
+ private uploadUrl: string;
31
+ private maxFileSize?: number;
32
+ private allowedTypes?: string[];
33
+ private abortControllers: Map<string, () => void> = new Map();
34
+
35
+ constructor(elementOrSelector: string | HTMLElement, config: FileUploaderConfig = {}) {
36
+ const container = typeof elementOrSelector === 'string'
37
+ ? document.querySelector<HTMLElement>(elementOrSelector)
38
+ : elementOrSelector;
39
+
40
+ if (!container) {
41
+ throw new Error(`FileUploader: Element not found for selector "${elementOrSelector}"`);
42
+ }
43
+
44
+ this.container = container;
45
+
46
+ const dropZone = container.querySelector<HTMLElement>('#drop-zone');
47
+ const fileInput = container.querySelector<HTMLInputElement>('#file-input');
48
+ const fileList = container.querySelector<HTMLElement>('#file-list');
49
+ const uploadBtn = container.querySelector<HTMLButtonElement>('#upload-btn');
50
+
51
+ if (!dropZone || !fileInput || !fileList || !uploadBtn) {
52
+ throw new Error('Required elements not found in container');
53
+ }
54
+
55
+ this.dropZone = dropZone;
56
+ this.fileInput = fileInput;
57
+ this.fileList = fileList;
58
+ this.uploadBtn = uploadBtn;
59
+
60
+ this.uploadUrl = config.uploadUrl ?? 'https://httpbin.org/post';
61
+ this.maxFileSize = config.maxFileSize;
62
+ this.allowedTypes = config.allowedTypes;
63
+
64
+ this.init();
65
+ }
66
+
67
+ private init(): void {
68
+ this.setupEventListeners();
69
+ }
70
+
71
+ private fileKey(file: File): string {
72
+ return `${file.name}-${file.size}-${file.lastModified}`;
73
+ }
74
+
75
+ private setupEventListeners(): void {
76
+ (['dragenter', 'dragover', 'dragleave', 'drop'] as const).forEach(event => {
77
+ this.dropZone.addEventListener(event, this.preventDefaults);
78
+ });
79
+
80
+ (['dragenter', 'dragover'] as const).forEach(event => {
81
+ this.dropZone.addEventListener(event, this.handleDragEnter);
82
+ });
83
+
84
+ (['dragleave', 'drop'] as const).forEach(event => {
85
+ this.dropZone.addEventListener(event, this.handleDragLeave);
86
+ });
87
+
88
+ this.dropZone.addEventListener('drop', this.handleDrop);
89
+ this.dropZone.addEventListener('click', this.handleDropZoneClick);
90
+ this.fileInput.addEventListener('change', this.handleFileInputChange);
91
+ this.uploadBtn.addEventListener('click', this.handleUploadClick);
92
+ }
93
+
94
+ private preventDefaults = (e: Event): void => {
95
+ e.preventDefault();
96
+ e.stopPropagation();
97
+ };
98
+
99
+ private handleDragEnter = (): void => {
100
+ this.dropZone.classList.add('drag-over');
101
+ };
102
+
103
+ private handleDragLeave = (): void => {
104
+ this.dropZone.classList.remove('drag-over');
105
+ };
106
+
107
+ private handleDrop = (e: DragEvent): void => {
108
+ const droppedFiles = e.dataTransfer?.files;
109
+ if (droppedFiles) {
110
+ this.handleFiles(droppedFiles);
111
+ }
112
+ };
113
+
114
+ private handleDropZoneClick = (): void => {
115
+ this.fileInput.click();
116
+ };
117
+
118
+ private handleFileInputChange = (e: Event): void => {
119
+ const target = e.target as HTMLInputElement;
120
+ if (target.files) {
121
+ this.handleFiles(target.files);
122
+ target.value = '';
123
+ }
124
+ };
125
+
126
+ private handleUploadClick = async (): Promise<void> => {
127
+ if (this.files.size === 0) return;
128
+
129
+ this.uploadBtn.disabled = true;
130
+ this.uploadBtn.textContent = 'Uploading...';
131
+
132
+ const uploadPromises = Array.from(this.files.values()).map(({ file, element }) =>
133
+ this.uploadFile(file, element)
134
+ );
135
+
136
+ const results = await Promise.allSettled(uploadPromises);
137
+
138
+ this.uploadBtn.textContent = 'Upload Complete';
139
+
140
+ setTimeout(() => {
141
+ this.dispatchUploadCompletedEvent(results);
142
+ this.fileList.innerHTML = '';
143
+ this.files.clear();
144
+ this.updateUploadButton();
145
+ }, 1500);
146
+ };
147
+
148
+ private handleFiles(fileList: FileList): void {
149
+ Array.from(fileList).forEach(file => {
150
+ const key = this.fileKey(file);
151
+ if (this.validateFile(file) && !this.files.has(key)) {
152
+ const element = this.addFileToUI(file);
153
+ this.files.set(key, { file, element });
154
+ }
155
+ });
156
+ this.updateUploadButton();
157
+ }
158
+
159
+ private validateFile(file: File): boolean {
160
+ if (this.maxFileSize && file.size > this.maxFileSize) {
161
+ this.container.dispatchEvent(new CustomEvent<FileValidationErrorDetail>('file-validation-error', {
162
+ detail: { file, reason: 'size' },
163
+ bubbles: true,
164
+ }));
165
+ return false;
166
+ }
167
+
168
+ if (this.allowedTypes && !this.allowedTypes.includes(file.type)) {
169
+ this.container.dispatchEvent(new CustomEvent<FileValidationErrorDetail>('file-validation-error', {
170
+ detail: { file, reason: 'type' },
171
+ bubbles: true,
172
+ }));
173
+ return false;
174
+ }
175
+
176
+ return true;
177
+ }
178
+
179
+ private addFileToUI(file: File): HTMLDivElement {
180
+ const key = this.fileKey(file);
181
+ const item = document.createElement('div');
182
+ item.className = 'file-item';
183
+
184
+ const escapedFileName = this.escapeHtml(file.name);
185
+
186
+ item.innerHTML = `
187
+ <div class="file-item-header">
188
+ <div class="file-info">
189
+ <div class="file-icon">
190
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
191
+ <path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
192
+ <polyline points="13 2 13 9 20 9"></polyline>
193
+ </svg>
194
+ </div>
195
+ <div class="file-details">
196
+ <span class="file-name" title="${escapedFileName}">${escapedFileName}</span>
197
+ <span class="file-size">${this.formatSize(file.size)}</span>
198
+ </div>
199
+ </div>
200
+ <button class="remove-btn" type="button" aria-label="Remove file">
201
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
202
+ <line x1="18" y1="6" x2="6" y2="18"></line>
203
+ <line x1="6" y1="6" x2="18" y2="18"></line>
204
+ </svg>
205
+ </button>
206
+ </div>
207
+ <div class="progress-container">
208
+ <div class="progress-bar"></div>
209
+ </div>
210
+ <div class="status-text">Waiting...</div>
211
+ `;
212
+
213
+ const removeBtn = item.querySelector<HTMLButtonElement>('.remove-btn');
214
+ if (removeBtn) {
215
+ removeBtn.addEventListener('click', (e) => {
216
+ e.stopPropagation();
217
+ this.removeFile(key);
218
+ });
219
+ }
220
+
221
+ this.fileList.appendChild(item);
222
+ return item;
223
+ }
224
+
225
+ private uploadFile(file: File, element: HTMLDivElement): Promise<unknown> {
226
+ return new Promise((resolve, reject) => {
227
+ const progressContainer = element.querySelector<HTMLElement>('.progress-container');
228
+ const progressBar = element.querySelector<HTMLElement>('.progress-bar');
229
+ const statusText = element.querySelector<HTMLElement>('.status-text');
230
+ const removeBtn = element.querySelector<HTMLElement>('.remove-btn');
231
+
232
+ if (!progressContainer || !progressBar || !statusText || !removeBtn) {
233
+ reject(new Error('Required UI elements not found'));
234
+ return;
235
+ }
236
+
237
+ progressContainer.style.display = 'block';
238
+ statusText.style.display = 'block';
239
+ statusText.textContent = '0%';
240
+ removeBtn.style.display = 'none';
241
+
242
+ const xhr = new XMLHttpRequest();
243
+ const key = this.fileKey(file);
244
+ this.abortControllers.set(key, () => xhr.abort());
245
+
246
+ xhr.upload.addEventListener('progress', (e) => {
247
+ if (e.lengthComputable) {
248
+ const pct = Math.round((e.loaded / e.total) * 100);
249
+ progressBar.style.width = pct + '%';
250
+ statusText.textContent = pct + '%';
251
+ }
252
+ });
253
+
254
+ xhr.addEventListener('load', () => {
255
+ this.abortControllers.delete(key);
256
+ if (xhr.status >= 200 && xhr.status < 300) {
257
+ progressBar.style.width = '100%';
258
+ progressBar.style.backgroundColor = 'var(--success)';
259
+ statusText.textContent = 'Completed';
260
+ statusText.classList.add('success');
261
+ try {
262
+ resolve(JSON.parse(xhr.responseText));
263
+ } catch {
264
+ resolve(xhr.responseText);
265
+ }
266
+ } else {
267
+ progressBar.style.backgroundColor = 'var(--error)';
268
+ statusText.textContent = 'Failed';
269
+ statusText.classList.add('error');
270
+ removeBtn.style.display = 'flex';
271
+ reject(new Error(`Upload failed: ${xhr.statusText}`));
272
+ }
273
+ });
274
+
275
+ xhr.addEventListener('error', () => {
276
+ this.abortControllers.delete(key);
277
+ progressBar.style.backgroundColor = 'var(--error)';
278
+ statusText.textContent = 'Network Error';
279
+ statusText.classList.add('error');
280
+ removeBtn.style.display = 'flex';
281
+ reject(new Error('Network error'));
282
+ });
283
+
284
+ xhr.addEventListener('abort', () => {
285
+ this.abortControllers.delete(key);
286
+ statusText.textContent = 'Cancelled';
287
+ statusText.classList.add('error');
288
+ removeBtn.style.display = 'flex';
289
+ reject(new Error('Upload aborted'));
290
+ });
291
+
292
+ const formData = new FormData();
293
+ formData.append('file', file);
294
+ xhr.open('POST', this.uploadUrl);
295
+ xhr.send(formData);
296
+ });
297
+ }
298
+
299
+ private removeFile(key: string): void {
300
+ const abort = this.abortControllers.get(key);
301
+ if (abort) abort();
302
+
303
+ const fileData = this.files.get(key);
304
+ if (fileData) {
305
+ fileData.element.remove();
306
+ this.files.delete(key);
307
+ this.updateUploadButton();
308
+ }
309
+ }
310
+
311
+ private updateUploadButton(): void {
312
+ this.uploadBtn.disabled = this.files.size === 0;
313
+ this.uploadBtn.textContent = this.files.size > 0
314
+ ? `Upload ${this.files.size} File${this.files.size === 1 ? '' : 's'}`
315
+ : 'Upload Files';
316
+ }
317
+
318
+ private dispatchUploadCompletedEvent(results: PromiseSettledResult<unknown>[]): void {
319
+ const files = Array.from(this.files.values()).map(({ file }) => file);
320
+ this.container.dispatchEvent(new CustomEvent<UploadCompletedDetail>('upload-completed', {
321
+ detail: { fileCount: this.files.size, files, results },
322
+ bubbles: true,
323
+ }));
324
+ }
325
+
326
+ private formatSize(bytes: number): string {
327
+ if (bytes === 0) return '0 Bytes';
328
+ const k = 1024;
329
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] as const;
330
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
331
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
332
+ }
333
+
334
+ private escapeHtml(text: string): string {
335
+ const div = document.createElement('div');
336
+ div.textContent = text;
337
+ return div.innerHTML;
338
+ }
339
+
340
+ public destroy(): void {
341
+ this.abortControllers.forEach(abort => abort());
342
+ this.abortControllers.clear();
343
+
344
+ (['dragenter', 'dragover', 'dragleave', 'drop'] as const).forEach(event => {
345
+ this.dropZone.removeEventListener(event, this.preventDefaults);
346
+ });
347
+ (['dragenter', 'dragover'] as const).forEach(event => {
348
+ this.dropZone.removeEventListener(event, this.handleDragEnter);
349
+ });
350
+ (['dragleave', 'drop'] as const).forEach(event => {
351
+ this.dropZone.removeEventListener(event, this.handleDragLeave);
352
+ });
353
+ this.dropZone.removeEventListener('drop', this.handleDrop);
354
+ this.dropZone.removeEventListener('click', this.handleDropZoneClick);
355
+ this.fileInput.removeEventListener('change', this.handleFileInputChange);
356
+ this.uploadBtn.removeEventListener('click', this.handleUploadClick);
357
+
358
+ this.files.clear();
359
+ this.fileList.innerHTML = '';
360
+ }
361
+ }
362
+
363
+ export { FileUploader };
364
+ export type { FileUploaderConfig, UploadCompletedDetail, FileValidationErrorDetail };