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, html, property, state, type TemplateResult, } from '@design.estate/dees-element'; declare global { interface HTMLElementTagNameMap { 'dees-input-fileupload': DeesInputFileupload; } } @customElement('dees-input-fileupload') export class DeesInputFileupload extends DeesInputBase { public static demo = demoFunc; @property({ attribute: false }) public value: File[] = []; @state() public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle'; @state() public isLoading: boolean = false; @property({ type: String }) public buttonText: string = 'Select files'; @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; public validationMessage: string = ''; private previewUrlMap: WeakMap = new WeakMap(); private dropArea: HTMLElement | null = null; public static styles = fileuploadStyles; public render(): TemplateResult { 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`
${this.isLoading ? html`` : html``}
${this.buttonText || 'Select files'} Drag and drop files here or
${metaEntries.map((entry) => html`${entry}`)}
${this.renderFileList()} ${this.validationMessage ? html`
${this.validationMessage}
` : html``}
`; } private renderFileList(): TemplateResult { if (this.value.length === 0) { return html`
No files selected
Selected files will be listed here
`; } return html`
${this.value.length} file${this.value.length === 1 ? '' : 's'} selected ${this.value.length > 0 ? html`` : html``}
${this.value.map((file) => this.renderFileRow(file))}
`; } private renderFileRow(file: File): TemplateResult { const fileType = this.getFileType(file); const previewUrl = this.canShowPreview(file) ? this.getPreviewUrl(file) : null; return html`
${file.name}
${this.formatFileSize(file.size)} ${fileType !== 'file' ? html`${fileType}` : html``}
`; } 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; } // Don't prevent default - let the click bubble if ((event.target as HTMLElement).closest('.dropzone__browse')) { 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(); } } public formatFileSize(bytes: number): string { const units = ['Bytes', 'KB', 'MB', 'GB']; if (bytes === 0) return '0 Bytes'; 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]}`; } 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 { const fileType = this.getFileType(file); const iconMap: Record = { 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', }; return iconMap[fileType] ?? 'lucide:File'; } public canShowPreview(file: File): boolean { return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; } private validateFile(file: File): boolean { if (this.maxSize > 0 && file.size > this.maxSize) { this.validationMessage = `File "${file.name}" exceeds the maximum size of ${this.formatFileSize(this.maxSize)}`; this.validationState = 'invalid'; return false; } if (this.accept) { 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) { isAccepted = true; break; } } if (!isAccepted) { this.validationMessage = `File type not accepted. Allowed: ${acceptedTypes.join(', ')}`; this.validationState = 'invalid'; return false; } } } return true; } 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) { super.firstUpdated(changedProperties); this.attachLifecycleListeners(); } public updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('value')) { void this.validate(); } this.rebindInteractiveElements(); } public async disconnectedCallback(): Promise { this.detachDropListeners(); this.value.forEach((file) => this.releasePreview(file)); this.previewUrlMap = new WeakMap(); await super.disconnectedCallback(); } public async openFileSelector() { if (this.disabled || this.isLoading) { return; } this.isLoading = true; // 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; } const handleFocus = () => { setTimeout(() => { if (!inputFile.files || inputFile.files.length === 0) { this.isLoading = false; } window.removeEventListener('focus', handleFocus); }, 300); }; window.addEventListener('focus', handleFocus); // Click the input to open file selector inputFile.click(); } public removeFile(file: File) { const index = this.value.indexOf(file); if (index > -1) { this.releasePreview(file); this.value.splice(index, 1); this.requestUpdate('value'); void this.validate(); this.changeSubject.next(this); } } public clearAll() { const existingFiles = [...this.value]; this.value = []; existingFiles.forEach((file) => this.releasePreview(file)); this.requestUpdate('value'); void this.validate(); this.changeSubject.next(this); this.buttonText = 'Select files'; } public async updateValue(eventArg: Event) { const target = eventArg.target as HTMLInputElement; this.value = Array.from(target.files ?? []); this.changeSubject.next(this); } 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(); } public getValue(): File[] { return this.value; } private async addFiles(files: File[]) { const filesToAdd: File[] = []; for (const file of files) { if (this.validateFile(file)) { filesToAdd.push(file); } } if (filesToAdd.length === 0) { this.isLoading = false; return; } 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'; this.isLoading = false; return; } filesToAdd.splice(allowedCount); this.validationMessage = `Only ${allowedCount} more file(s) can be added`; this.validationState = 'warn'; } } if (!this.multiple && filesToAdd.length > 0) { this.value.forEach((file) => this.releasePreview(file)); this.value = [filesToAdd[0]]; } else { this.value.push(...filesToAdd); } this.validationMessage = ''; this.validationState = null; this.requestUpdate('value'); await this.validate(); this.changeSubject.next(this); this.isLoading = false; if (this.value.length > 0) { this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; } else { this.buttonText = 'Select files'; } } public async validate(): Promise { this.validationMessage = ''; if (this.required && this.value.length === 0) { this.validationState = 'invalid'; this.validationMessage = 'Please select at least one file'; return false; } for (const file of this.value) { if (!this.validateFile(file)) { return false; } } this.validationState = this.value.length > 0 ? 'valid' : null; return true; } }