| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  | import { DeesInputBase } from '../dees-input-base.js'; | 
					
						
							|  |  |  | import { demoFunc } from './demo.js'; | 
					
						
							|  |  |  | import { fileuploadStyles } from './styles.js'; | 
					
						
							|  |  |  | import '../dees-icon.js'; | 
					
						
							|  |  |  | import '../dees-label.js'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | import { | 
					
						
							|  |  |  |   customElement, | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   html, | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   property, | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   state, | 
					
						
							|  |  |  |   type TemplateResult, | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  | } from '@design.estate/dees-element'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | declare global { | 
					
						
							|  |  |  |   interface HTMLElementTagNameMap { | 
					
						
							|  |  |  |     'dees-input-fileupload': DeesInputFileupload; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | @customElement('dees-input-fileupload') | 
					
						
							|  |  |  | export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> { | 
					
						
							|  |  |  |   public static demo = demoFunc; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   @property({ attribute: false }) | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   public value: File[] = []; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   @state() | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle'; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   @state() | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   public isLoading: boolean = false; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   @property({ type: String }) | 
					
						
							|  |  |  |   public buttonText: string = 'Select files'; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |   @property({ type: String }) | 
					
						
							|  |  |  |   public accept: string = ''; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @property({ type: Boolean }) | 
					
						
							|  |  |  |   public multiple: boolean = true; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @property({ type: Number }) | 
					
						
							|  |  |  |   public maxSize: number = 0; // 0 means no limit
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @property({ type: Number }) | 
					
						
							|  |  |  |   public maxFiles: number = 0; // 0 means no limit
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   @property({ type: String, reflect: true }) | 
					
						
							|  |  |  |   public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   public validationMessage: string = ''; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private previewUrlMap: WeakMap<File, string> = new WeakMap(); | 
					
						
							|  |  |  |   private dropArea: HTMLElement | null = null; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |   public static styles = fileuploadStyles; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public render(): TemplateResult { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     const acceptedSummary = this.getAcceptedSummary(); | 
					
						
							|  |  |  |     const metaEntries: string[] = [ | 
					
						
							|  |  |  |       this.multiple ? 'Multiple files supported' : 'Single file only', | 
					
						
							|  |  |  |       this.maxSize > 0 ? `Max ${this.formatFileSize(this.maxSize)}` : 'No size limit', | 
					
						
							|  |  |  |     ]; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (acceptedSummary) { | 
					
						
							|  |  |  |       metaEntries.push(`Accepts ${acceptedSummary}`); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return html`
 | 
					
						
							|  |  |  |       <div class="input-wrapper"> | 
					
						
							|  |  |  |         <dees-label | 
					
						
							|  |  |  |           .label=${this.label} | 
					
						
							|  |  |  |           .description=${this.description} | 
					
						
							|  |  |  |           .required=${this.required} | 
					
						
							|  |  |  |         ></dees-label> | 
					
						
							|  |  |  |         <div | 
					
						
							| 
									
										
										
										
											2025-09-19 17:35:58 +00:00
										 |  |  |           class="dropzone ${this.state === 'dragOver' ? 'dropzone--active' : ''} ${this.disabled ? 'dropzone--disabled' : ''} ${this.value.length > 0 ? 'dropzone--has-files' : ''}" | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |           role="button" | 
					
						
							|  |  |  |           tabindex=${this.disabled ? -1 : 0} | 
					
						
							|  |  |  |           aria-disabled=${this.disabled} | 
					
						
							|  |  |  |           aria-label=${`Select files${acceptedSummary ? ` (${acceptedSummary})` : ''}`} | 
					
						
							|  |  |  |           @click=${this.handleDropzoneClick} | 
					
						
							|  |  |  |           @keydown=${this.handleDropzoneKeydown} | 
					
						
							|  |  |  |         > | 
					
						
							|  |  |  |           <input | 
					
						
							|  |  |  |             class="file-input" | 
					
						
							|  |  |  |             style="position: absolute; opacity: 0; pointer-events: none; width: 1px; height: 1px; top: 0; left: 0; overflow: hidden;" | 
					
						
							|  |  |  |             type="file" | 
					
						
							|  |  |  |             ?multiple=${this.multiple} | 
					
						
							|  |  |  |             accept=${this.accept || ''} | 
					
						
							|  |  |  |             ?disabled=${this.disabled} | 
					
						
							|  |  |  |             @change=${this.handleFileInputChange} | 
					
						
							|  |  |  |             tabindex="-1" | 
					
						
							|  |  |  |           /> | 
					
						
							|  |  |  |           <div class="dropzone__body"> | 
					
						
							|  |  |  |             <div class="dropzone__icon"> | 
					
						
							|  |  |  |               ${this.isLoading | 
					
						
							|  |  |  |                 ? html`<span class="dropzone__loader" aria-hidden="true"></span>` | 
					
						
							|  |  |  |                 : html`<dees-icon icon="lucide:FolderOpen"></dees-icon>`} | 
					
						
							|  |  |  |             </div> | 
					
						
							|  |  |  |             <div class="dropzone__content"> | 
					
						
							|  |  |  |               <span class="dropzone__headline">${this.buttonText || 'Select files'}</span> | 
					
						
							|  |  |  |               <span class="dropzone__subline"> | 
					
						
							|  |  |  |                 Drag and drop files here or | 
					
						
							|  |  |  |                 <button | 
					
						
							|  |  |  |                   type="button" | 
					
						
							|  |  |  |                   class="dropzone__browse" | 
					
						
							|  |  |  |                   @click=${this.handleBrowseClick} | 
					
						
							|  |  |  |                   ?disabled=${this.disabled} | 
					
						
							|  |  |  |                 > | 
					
						
							|  |  |  |                   browse | 
					
						
							|  |  |  |                 </button> | 
					
						
							|  |  |  |               </span> | 
					
						
							|  |  |  |             </div> | 
					
						
							|  |  |  |           </div> | 
					
						
							|  |  |  |           <div class="dropzone__meta"> | 
					
						
							|  |  |  |             ${metaEntries.map((entry) => html`<span>${entry}</span>`)} | 
					
						
							|  |  |  |           </div> | 
					
						
							| 
									
										
										
										
											2025-09-19 17:35:58 +00:00
										 |  |  |           ${this.renderFileList()} | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |         </div> | 
					
						
							|  |  |  |         ${this.validationMessage | 
					
						
							|  |  |  |           ? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>` | 
					
						
							|  |  |  |           : html``} | 
					
						
							|  |  |  |       </div> | 
					
						
							|  |  |  |     `;
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   private renderFileList(): TemplateResult { | 
					
						
							|  |  |  |     if (this.value.length === 0) { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:35:58 +00:00
										 |  |  |       return html``; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return html`
 | 
					
						
							|  |  |  |       <div class="file-list"> | 
					
						
							|  |  |  |         <div class="file-list__header"> | 
					
						
							|  |  |  |           <span>${this.value.length} file${this.value.length === 1 ? '' : 's'} selected</span> | 
					
						
							|  |  |  |           ${this.value.length > 0 | 
					
						
							|  |  |  |             ? html`<button type="button" class="file-list__clear" @click=${this.handleClearAll}>Clear ${this.value.length > 1 ? 'all' : ''}</button>` | 
					
						
							|  |  |  |             : html``} | 
					
						
							|  |  |  |         </div> | 
					
						
							|  |  |  |         <div class="file-list__items"> | 
					
						
							|  |  |  |           ${this.value.map((file) => this.renderFileRow(file))} | 
					
						
							|  |  |  |         </div> | 
					
						
							|  |  |  |       </div> | 
					
						
							|  |  |  |     `;
 | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private renderFileRow(file: File): TemplateResult { | 
					
						
							|  |  |  |     const fileType = this.getFileType(file); | 
					
						
							|  |  |  |     const previewUrl = this.canShowPreview(file) ? this.getPreviewUrl(file) : null; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return html`
 | 
					
						
							|  |  |  |       <div class="file-row ${fileType}-file"> | 
					
						
							|  |  |  |         <div class="file-thumb" aria-hidden="true"> | 
					
						
							|  |  |  |           ${previewUrl | 
					
						
							|  |  |  |             ? html`<img class="thumb-image" src=${previewUrl} alt=${`Preview of ${file.name}`}>` | 
					
						
							|  |  |  |             : html`<dees-icon icon=${this.getFileIcon(file)}></dees-icon>`} | 
					
						
							|  |  |  |         </div> | 
					
						
							|  |  |  |         <div class="file-meta"> | 
					
						
							|  |  |  |           <div class="file-name" title=${file.name}>${file.name}</div> | 
					
						
							|  |  |  |           <div class="file-details"> | 
					
						
							|  |  |  |             <span class="file-size">${this.formatFileSize(file.size)}</span> | 
					
						
							|  |  |  |             ${fileType !== 'file' ? html`<span class="file-type">${fileType}</span>` : html``} | 
					
						
							|  |  |  |           </div> | 
					
						
							|  |  |  |         </div> | 
					
						
							|  |  |  |         <div class="file-actions"> | 
					
						
							|  |  |  |           <button | 
					
						
							|  |  |  |             type="button" | 
					
						
							|  |  |  |             class="remove-button" | 
					
						
							|  |  |  |             @click=${() => this.removeFile(file)} | 
					
						
							|  |  |  |             aria-label=${`Remove ${file.name}`} | 
					
						
							|  |  |  |           > | 
					
						
							|  |  |  |             <dees-icon icon="lucide:X"></dees-icon> | 
					
						
							|  |  |  |           </button> | 
					
						
							|  |  |  |         </div> | 
					
						
							|  |  |  |       </div> | 
					
						
							|  |  |  |     `;
 | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private handleFileInputChange = async (event: Event) => { | 
					
						
							|  |  |  |     this.isLoading = false; | 
					
						
							|  |  |  |     const target = event.target as HTMLInputElement; | 
					
						
							|  |  |  |     const files = Array.from(target.files ?? []); | 
					
						
							|  |  |  |     if (files.length > 0) { | 
					
						
							|  |  |  |       await this.addFiles(files); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     target.value = ''; | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private handleDropzoneClick = (event: MouseEvent) => { | 
					
						
							|  |  |  |     if (this.disabled) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2025-09-19 17:35:58 +00:00
										 |  |  |     // Don't open file selector if clicking on the browse button or file list
 | 
					
						
							|  |  |  |     if ((event.target as HTMLElement).closest('.dropzone__browse, .file-list')) { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     this.openFileSelector(); | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private handleBrowseClick = (event: MouseEvent) => { | 
					
						
							|  |  |  |     if (this.disabled) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     event.stopPropagation(); // Stop propagation to prevent double trigger
 | 
					
						
							|  |  |  |     this.openFileSelector(); | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private handleDropzoneKeydown = (event: KeyboardEvent) => { | 
					
						
							|  |  |  |     if (this.disabled) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     if (event.key === 'Enter' || event.key === ' ') { | 
					
						
							|  |  |  |       event.preventDefault(); | 
					
						
							|  |  |  |       this.openFileSelector(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private handleClearAll = (event: MouseEvent) => { | 
					
						
							|  |  |  |     event.preventDefault(); | 
					
						
							|  |  |  |     this.clearAll(); | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private handleDragEvent = async (event: DragEvent) => { | 
					
						
							|  |  |  |     event.preventDefault(); | 
					
						
							|  |  |  |     event.stopPropagation(); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (this.disabled) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (event.type === 'dragenter' || event.type === 'dragover') { | 
					
						
							|  |  |  |       if (event.dataTransfer) { | 
					
						
							|  |  |  |         event.dataTransfer.dropEffect = 'copy'; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       this.state = 'dragOver'; | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (event.type === 'dragleave') { | 
					
						
							|  |  |  |       if (!this.dropArea) { | 
					
						
							|  |  |  |         this.state = 'idle'; | 
					
						
							|  |  |  |         return; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       const rect = this.dropArea.getBoundingClientRect(); | 
					
						
							|  |  |  |       const { clientX = 0, clientY = 0 } = event; | 
					
						
							|  |  |  |       if (clientX <= rect.left || clientX >= rect.right || clientY <= rect.top || clientY >= rect.bottom) { | 
					
						
							|  |  |  |         this.state = 'idle'; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (event.type === 'drop') { | 
					
						
							|  |  |  |       this.state = 'idle'; | 
					
						
							|  |  |  |       const files = Array.from(event.dataTransfer?.files ?? []); | 
					
						
							|  |  |  |       if (files.length > 0) { | 
					
						
							|  |  |  |         await this.addFiles(files); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private attachDropListeners(): void { | 
					
						
							|  |  |  |     if (!this.dropArea) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => { | 
					
						
							|  |  |  |       this.dropArea!.addEventListener(eventName, this.handleDragEvent); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private detachDropListeners(): void { | 
					
						
							|  |  |  |     if (!this.dropArea) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     ['dragenter', 'dragover', 'dragleave', 'drop'].forEach((eventName) => { | 
					
						
							|  |  |  |       this.dropArea!.removeEventListener(eventName, this.handleDragEvent); | 
					
						
							|  |  |  |     }); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private rebindInteractiveElements(): void { | 
					
						
							|  |  |  |     const newDropArea = this.shadowRoot?.querySelector('.dropzone') as HTMLElement | null; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (newDropArea !== this.dropArea) { | 
					
						
							|  |  |  |       this.detachDropListeners(); | 
					
						
							|  |  |  |       this.dropArea = newDropArea; | 
					
						
							|  |  |  |       this.attachDropListeners(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |   public formatFileSize(bytes: number): string { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     const units = ['Bytes', 'KB', 'MB', 'GB']; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     if (bytes === 0) return '0 Bytes'; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); | 
					
						
							|  |  |  |     const size = bytes / Math.pow(1024, exponent); | 
					
						
							|  |  |  |     return `${Math.round(size * 100) / 100} ${units[exponent]}`; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public getFileType(file: File): string { | 
					
						
							|  |  |  |     const type = file.type.toLowerCase(); | 
					
						
							|  |  |  |     if (type.startsWith('image/')) return 'image'; | 
					
						
							|  |  |  |     if (type === 'application/pdf') return 'pdf'; | 
					
						
							|  |  |  |     if (type.includes('word') || type.includes('document')) return 'doc'; | 
					
						
							|  |  |  |     if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet'; | 
					
						
							|  |  |  |     if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation'; | 
					
						
							|  |  |  |     if (type.startsWith('video/')) return 'video'; | 
					
						
							|  |  |  |     if (type.startsWith('audio/')) return 'audio'; | 
					
						
							|  |  |  |     if (type.includes('zip') || type.includes('compressed')) return 'archive'; | 
					
						
							|  |  |  |     return 'file'; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public getFileIcon(file: File): string { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     const fileType = this.getFileType(file); | 
					
						
							|  |  |  |     const iconMap: Record<string, string> = { | 
					
						
							|  |  |  |       image: 'lucide:FileImage', | 
					
						
							|  |  |  |       pdf: 'lucide:FileText', | 
					
						
							|  |  |  |       doc: 'lucide:FileText', | 
					
						
							|  |  |  |       spreadsheet: 'lucide:FileSpreadsheet', | 
					
						
							|  |  |  |       presentation: 'lucide:FileBarChart', | 
					
						
							|  |  |  |       video: 'lucide:FileVideo', | 
					
						
							|  |  |  |       audio: 'lucide:FileAudio', | 
					
						
							|  |  |  |       archive: 'lucide:FileArchive', | 
					
						
							|  |  |  |       file: 'lucide:File', | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     }; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     return iconMap[fileType] ?? 'lucide:File'; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public canShowPreview(file: File): boolean { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private validateFile(file: File): boolean { | 
					
						
							|  |  |  |     if (this.maxSize > 0 && file.size > this.maxSize) { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |       this.validationMessage = `File "${file.name}" exceeds the maximum size of ${this.formatFileSize(this.maxSize)}`; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |       this.validationState = 'invalid'; | 
					
						
							|  |  |  |       return false; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (this.accept) { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |       const acceptedTypes = this.accept | 
					
						
							|  |  |  |         .split(',') | 
					
						
							|  |  |  |         .map((entry) => entry.trim()) | 
					
						
							|  |  |  |         .filter((entry) => entry.length > 0); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |       if (acceptedTypes.length > 0) { | 
					
						
							|  |  |  |         let isAccepted = false; | 
					
						
							|  |  |  |         for (const acceptType of acceptedTypes) { | 
					
						
							|  |  |  |           if (acceptType.startsWith('.')) { | 
					
						
							|  |  |  |             if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) { | 
					
						
							|  |  |  |               isAccepted = true; | 
					
						
							|  |  |  |               break; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |           } else if (acceptType.endsWith('/*')) { | 
					
						
							|  |  |  |             const prefix = acceptType.slice(0, -2); | 
					
						
							|  |  |  |             if (file.type.startsWith(prefix)) { | 
					
						
							|  |  |  |               isAccepted = true; | 
					
						
							|  |  |  |               break; | 
					
						
							|  |  |  |             } | 
					
						
							|  |  |  |           } else if (file.type === acceptType) { | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |             isAccepted = true; | 
					
						
							|  |  |  |             break; | 
					
						
							|  |  |  |           } | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |         if (!isAccepted) { | 
					
						
							|  |  |  |           this.validationMessage = `File type not accepted. Allowed: ${acceptedTypes.join(', ')}`; | 
					
						
							|  |  |  |           this.validationState = 'invalid'; | 
					
						
							|  |  |  |           return false; | 
					
						
							|  |  |  |         } | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return true; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   private getPreviewUrl(file: File): string { | 
					
						
							|  |  |  |     let url = this.previewUrlMap.get(file); | 
					
						
							|  |  |  |     if (!url) { | 
					
						
							|  |  |  |       url = URL.createObjectURL(file); | 
					
						
							|  |  |  |       this.previewUrlMap.set(file, url); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     return url; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private releasePreview(file: File): void { | 
					
						
							|  |  |  |     const url = this.previewUrlMap.get(file); | 
					
						
							|  |  |  |     if (url) { | 
					
						
							|  |  |  |       URL.revokeObjectURL(url); | 
					
						
							|  |  |  |       this.previewUrlMap.delete(file); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private getAcceptedSummary(): string | null { | 
					
						
							|  |  |  |     if (!this.accept) { | 
					
						
							|  |  |  |       return null; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const formatted = Array.from( | 
					
						
							|  |  |  |       new Set( | 
					
						
							|  |  |  |         this.accept | 
					
						
							|  |  |  |           .split(',') | 
					
						
							|  |  |  |           .map((token) => token.trim()) | 
					
						
							|  |  |  |           .filter((token) => token.length > 0) | 
					
						
							|  |  |  |           .map((token) => this.formatAcceptToken(token)) | 
					
						
							|  |  |  |       ) | 
					
						
							|  |  |  |     ).filter(Boolean); | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (formatted.length === 0) { | 
					
						
							|  |  |  |       return null; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (formatted.length === 1) { | 
					
						
							|  |  |  |       return formatted[0]; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (formatted.length === 2) { | 
					
						
							|  |  |  |       return `${formatted[0]}, ${formatted[1]}`; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     return `${formatted.slice(0, 2).join(', ')}…`; | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private formatAcceptToken(token: string): string { | 
					
						
							|  |  |  |     if (token === '*/*') { | 
					
						
							|  |  |  |       return 'All files'; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (token.endsWith('/*')) { | 
					
						
							|  |  |  |       const family = token.split('/')[0]; | 
					
						
							|  |  |  |       if (!family) { | 
					
						
							|  |  |  |         return 'All files'; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |       return `${family.charAt(0).toUpperCase()}${family.slice(1)} files`; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (token.startsWith('.')) { | 
					
						
							|  |  |  |       return token.slice(1).toUpperCase(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (token.includes('pdf')) return 'PDF'; | 
					
						
							|  |  |  |     if (token.includes('zip')) return 'ZIP'; | 
					
						
							|  |  |  |     if (token.includes('json')) return 'JSON'; | 
					
						
							|  |  |  |     if (token.includes('msword')) return 'DOC'; | 
					
						
							|  |  |  |     if (token.includes('wordprocessingml')) return 'DOCX'; | 
					
						
							|  |  |  |     if (token.includes('excel')) return 'XLS'; | 
					
						
							|  |  |  |     if (token.includes('presentation')) return 'PPT'; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     const segments = token.split('/'); | 
					
						
							|  |  |  |     const lastSegment = segments.pop() ?? token; | 
					
						
							|  |  |  |     return lastSegment.toUpperCase(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private attachLifecycleListeners(): void { | 
					
						
							|  |  |  |     this.rebindInteractiveElements(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public firstUpdated(changedProperties: Map<string, unknown>) { | 
					
						
							|  |  |  |     super.firstUpdated(changedProperties); | 
					
						
							|  |  |  |     this.attachLifecycleListeners(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public updated(changedProperties: Map<string, unknown>) { | 
					
						
							|  |  |  |     super.updated(changedProperties); | 
					
						
							|  |  |  |     if (changedProperties.has('value')) { | 
					
						
							|  |  |  |       void this.validate(); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     this.rebindInteractiveElements(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public async disconnectedCallback(): Promise<void> { | 
					
						
							|  |  |  |     this.detachDropListeners(); | 
					
						
							|  |  |  |     this.value.forEach((file) => this.releasePreview(file)); | 
					
						
							|  |  |  |     this.previewUrlMap = new WeakMap(); | 
					
						
							|  |  |  |     await super.disconnectedCallback(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   public async openFileSelector() { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     if (this.disabled || this.isLoading) { | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     this.isLoading = true; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |     // Ensure we have the latest reference to the file input
 | 
					
						
							|  |  |  |     const inputFile = this.shadowRoot?.querySelector('.file-input') as HTMLInputElement | null; | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (!inputFile) { | 
					
						
							|  |  |  |       this.isLoading = false; | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     const handleFocus = () => { | 
					
						
							|  |  |  |       setTimeout(() => { | 
					
						
							|  |  |  |         if (!inputFile.files || inputFile.files.length === 0) { | 
					
						
							|  |  |  |           this.isLoading = false; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         window.removeEventListener('focus', handleFocus); | 
					
						
							|  |  |  |       }, 300); | 
					
						
							|  |  |  |     }; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     window.addEventListener('focus', handleFocus); | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |     // Click the input to open file selector
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     inputFile.click(); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public removeFile(file: File) { | 
					
						
							|  |  |  |     const index = this.value.indexOf(file); | 
					
						
							|  |  |  |     if (index > -1) { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |       this.releasePreview(file); | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |       this.value.splice(index, 1); | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |       this.requestUpdate('value'); | 
					
						
							|  |  |  |       void this.validate(); | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |       this.changeSubject.next(this); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public clearAll() { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     const existingFiles = [...this.value]; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     this.value = []; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     existingFiles.forEach((file) => this.releasePreview(file)); | 
					
						
							|  |  |  |     this.requestUpdate('value'); | 
					
						
							|  |  |  |     void this.validate(); | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     this.changeSubject.next(this); | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     this.buttonText = 'Select files'; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public async updateValue(eventArg: Event) { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     const target = eventArg.target as HTMLInputElement; | 
					
						
							|  |  |  |     this.value = Array.from(target.files ?? []); | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     this.changeSubject.next(this); | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   public setValue(value: File[]): void { | 
					
						
							|  |  |  |     this.value.forEach((file) => this.releasePreview(file)); | 
					
						
							|  |  |  |     this.value = value; | 
					
						
							|  |  |  |     if (value.length > 0) { | 
					
						
							|  |  |  |       this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; | 
					
						
							|  |  |  |     } else { | 
					
						
							|  |  |  |       this.buttonText = 'Select files'; | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |     this.requestUpdate('value'); | 
					
						
							|  |  |  |     void this.validate(); | 
					
						
							|  |  |  |   } | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |   public getValue(): File[] { | 
					
						
							|  |  |  |     return this.value; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   private async addFiles(files: File[]) { | 
					
						
							|  |  |  |     const filesToAdd: File[] = []; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     for (const file of files) { | 
					
						
							|  |  |  |       if (this.validateFile(file)) { | 
					
						
							|  |  |  |         filesToAdd.push(file); | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     if (filesToAdd.length === 0) { | 
					
						
							|  |  |  |       this.isLoading = false; | 
					
						
							|  |  |  |       return; | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  | 
 | 
					
						
							|  |  |  |     if (this.maxFiles > 0) { | 
					
						
							|  |  |  |       const totalFiles = this.value.length + filesToAdd.length; | 
					
						
							|  |  |  |       if (totalFiles > this.maxFiles) { | 
					
						
							|  |  |  |         const allowedCount = this.maxFiles - this.value.length; | 
					
						
							|  |  |  |         if (allowedCount <= 0) { | 
					
						
							|  |  |  |           this.validationMessage = `Maximum ${this.maxFiles} files allowed`; | 
					
						
							|  |  |  |           this.validationState = 'invalid'; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |           this.isLoading = false; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |           return; | 
					
						
							|  |  |  |         } | 
					
						
							|  |  |  |         filesToAdd.splice(allowedCount); | 
					
						
							|  |  |  |         this.validationMessage = `Only ${allowedCount} more file(s) can be added`; | 
					
						
							|  |  |  |         this.validationState = 'warn'; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |     if (!this.multiple && filesToAdd.length > 0) { | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |       this.value.forEach((file) => this.releasePreview(file)); | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |       this.value = [filesToAdd[0]]; | 
					
						
							|  |  |  |     } else { | 
					
						
							|  |  |  |       this.value.push(...filesToAdd); | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     this.validationMessage = ''; | 
					
						
							|  |  |  |     this.validationState = null; | 
					
						
							|  |  |  |     this.requestUpdate('value'); | 
					
						
							|  |  |  |     await this.validate(); | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     this.changeSubject.next(this); | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     this.isLoading = false; | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     if (this.value.length > 0) { | 
					
						
							|  |  |  |       this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     } else { | 
					
						
							|  |  |  |       this.buttonText = 'Select files'; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     } | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   public async validate(): Promise<boolean> { | 
					
						
							|  |  |  |     this.validationMessage = ''; | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     if (this.required && this.value.length === 0) { | 
					
						
							|  |  |  |       this.validationState = 'invalid'; | 
					
						
							|  |  |  |       this.validationMessage = 'Please select at least one file'; | 
					
						
							|  |  |  |       return false; | 
					
						
							|  |  |  |     } | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |     for (const file of this.value) { | 
					
						
							|  |  |  |       if (!this.validateFile(file)) { | 
					
						
							|  |  |  |         return false; | 
					
						
							|  |  |  |       } | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  | 
 | 
					
						
							| 
									
										
										
										
											2025-09-19 17:31:26 +00:00
										 |  |  |     this.validationState = this.value.length > 0 ? 'valid' : null; | 
					
						
							|  |  |  |     return true; | 
					
						
							| 
									
										
										
										
											2025-09-19 15:26:21 +00:00
										 |  |  |   } | 
					
						
							|  |  |  | } |