import * as colors from './00colors.js'; import * as plugins from './00plugins.js'; import { DeesContextmenu } from './dees-contextmenu.js'; import { DeesInputBase } from './dees-input-base.js'; import { demoFunc } from './dees-input-fileupload.demo.js'; import { customElement, DeesElement, type TemplateResult, property, html, css, unsafeCSS, cssManager, type CSSResult, domtools, } 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[] = []; @property() public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle'; @property({ type: String, }) public buttonText: string = 'Upload File...'; @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; constructor() { super(); } public static styles = [ ...DeesInputBase.baseStyles, cssManager.defaultStyles, css` :host { position: relative; display: block; color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')}; } .hidden { display: none; } .input-wrapper { display: flex; flex-direction: column; gap: 8px; } .maincontainer { position: relative; border-radius: 6px; padding: 16px; background: ${cssManager.bdTheme('hsl(210 40% 98%)', 'hsl(215 20.2% 11.8%)')}; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; border: 1px solid ${cssManager.bdTheme('hsl(215 20.2% 65.1%)', 'hsl(215 20.2% 35.1%)')}; transition: all 0.15s ease; } .maincontainer:hover { border-color: ${cssManager.bdTheme('hsl(215 20.2% 55.1%)', 'hsl(215 20.2% 45.1%)')}; } :host([disabled]) .maincontainer { opacity: 0.5; cursor: not-allowed; pointer-events: none; } :host([validationState="invalid"]) .maincontainer { border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; } :host([validationState="valid"]) .maincontainer { border-color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')}; } :host([validationState="warn"]) .maincontainer { border-color: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')}; } .maincontainer::after { top: 1px; right: 1px; left: 1px; bottom: 1px; transform: scale3d(0.98, 0.95, 1); position: absolute; content: ''; display: block; border: 2px dashed transparent; border-radius: 5px; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); pointer-events: none; background: transparent; } .maincontainer.dragOver { border-color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')}; background: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8% / 0.05)', 'hsl(213.1 93.9% 67.8% / 0.05)')}; } .maincontainer.dragOver::after { transform: scale3d(1, 1, 1); border: 2px dashed ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')}; } .uploadButton { position: relative; padding: 10px 20px; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(0 0% 7.8%)')}; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; border-radius: 6px; text-align: center; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; display: flex; align-items: center; justify-content: center; gap: 8px; line-height: 20px; } .uploadButton:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(0 0% 14.9%)')}; border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; } .uploadButton:active { background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 11%)')}; } .uploadButton dees-icon { font-size: 16px; } .files-container { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; } .uploadCandidate { display: grid; grid-template-columns: 40px 1fr auto; background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20.2% 16.8%)')}; padding: 12px; text-align: left; border-radius: 6px; color: ${cssManager.bdTheme('hsl(0 0% 9%)', 'hsl(0 0% 95%)')}; cursor: default; transition: all 0.15s ease; border: 1px solid ${cssManager.bdTheme('hsl(0 0% 89.8%)', 'hsl(0 0% 14.9%)')}; position: relative; overflow: hidden; } .uploadCandidate:hover { background: ${cssManager.bdTheme('hsl(0 0% 95.1%)', 'hsl(215 20.2% 20.8%)')}; border-color: ${cssManager.bdTheme('hsl(0 0% 79.8%)', 'hsl(0 0% 20.9%)')}; } .uploadCandidate .icon { display: flex; align-items: center; justify-content: center; font-size: 20px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; } .uploadCandidate.image-file .icon { color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')}; } .uploadCandidate.pdf-file .icon { color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; } .uploadCandidate.doc-file .icon { color: ${cssManager.bdTheme('hsl(217.2 91.2% 59.8%)', 'hsl(213.1 93.9% 67.8%)')}; } .uploadCandidate .info { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .uploadCandidate .filename { font-weight: 500; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .uploadCandidate .filesize { font-size: 12px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; } .uploadCandidate .actions { display: flex; align-items: center; gap: 8px; } .remove-button { width: 32px; height: 32px; border-radius: 4px; background: transparent; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; } .remove-button:hover { background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')}; color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; } .clear-all-button { margin-bottom: 8px; text-align: right; } .clear-all-button button { background: none; border: none; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; cursor: pointer; font-size: 12px; padding: 4px 8px; border-radius: 4px; transition: all 0.15s ease; } .clear-all-button button:hover { background: ${cssManager.bdTheme('hsl(0 72.2% 50.6% / 0.1)', 'hsl(0 62.8% 30.6% / 0.1)')}; color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; } .validation-message { font-size: 13px; margin-top: 6px; color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')}; line-height: 1.5; } .drop-hint { text-align: center; padding: 40px 20px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; font-size: 14px; } .drop-hint dees-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.2; } .image-preview { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; } .description-text { font-size: 13px; color: ${cssManager.bdTheme('hsl(215.4 16.3% 56.9%)', 'hsl(215 20.2% 55.1%)')}; margin-top: 6px; line-height: 1.5; } `, ]; public render(): TemplateResult { const hasFiles = this.value.length > 0; const showClearAll = hasFiles && this.value.length > 1; return html`
${this.label ? html` ` : ''}
${hasFiles ? html` ${showClearAll ? html`
` : ''}
${this.value.map((fileArg) => { const fileType = this.getFileType(fileArg); const isImage = fileType === 'image'; return html`
${isImage && this.canShowPreview(fileArg) ? html` ${fileArg.name} ` : html` `}
${fileArg.name}
${this.formatFileSize(fileArg.size)}
`; })}
` : html`
Drag files here or click to browse
`}
${this.buttonText}
${this.description ? html`
${this.description}
` : ''} ${this.validationState === 'invalid' && this.validationMessage ? html`
${this.validationMessage}
` : ''}
`; } private validationMessage: string = ''; // Utility methods private formatFileSize(bytes: number): string { const sizes = ['Bytes', 'KB', 'MB', 'GB']; if (bytes === 0) return '0 Bytes'; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } private 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'; } private getFileIcon(file: File): string { const type = this.getFileType(file); const iconMap = { 'image': 'lucide:image', 'pdf': 'lucide:file-text', 'doc': 'lucide:file-text', 'spreadsheet': 'lucide:table', 'presentation': 'lucide:presentation', 'video': 'lucide:video', 'audio': 'lucide:music', 'archive': 'lucide:archive', 'file': 'lucide:file' }; return iconMap[type] || 'lucide:file'; } private canShowPreview(file: File): boolean { return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews } private validateFile(file: File): boolean { // Check file size if (this.maxSize > 0 && file.size > this.maxSize) { this.validationMessage = `File "${file.name}" exceeds maximum size of ${this.formatFileSize(this.maxSize)}`; this.validationState = 'invalid'; return false; } // Check file type if (this.accept) { const acceptedTypes = this.accept.split(',').map(s => s.trim()); let isAccepted = false; for (const acceptType of acceptedTypes) { if (acceptType.startsWith('.')) { // Extension check if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) { isAccepted = true; break; } } else if (acceptType.endsWith('/*')) { // MIME type wildcard check const mimePrefix = acceptType.slice(0, -2); if (file.type.startsWith(mimePrefix)) { isAccepted = true; break; } } else if (file.type === acceptType) { // Exact MIME type check isAccepted = true; break; } } if (!isAccepted) { this.validationMessage = `File type not accepted. Please upload: ${this.accept}`; this.validationState = 'invalid'; return false; } } return true; } public async openFileSelector() { if (this.disabled) return; const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); inputFile.click(); } private removeFile(file: File) { const index = this.value.indexOf(file); if (index > -1) { this.value.splice(index, 1); this.requestUpdate(); this.validate(); this.changeSubject.next(this); } } private clearAll() { this.value = []; this.requestUpdate(); this.validate(); this.changeSubject.next(this); } public async updateValue(eventArg: Event) { const target: any = eventArg.target; this.value = target.value; this.changeSubject.next(this); } public firstUpdated(_changedProperties: Map) { super.firstUpdated(_changedProperties); const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); inputFile.addEventListener('change', async (event: Event) => { const target = event.target as HTMLInputElement; const newFiles = Array.from(target.files); await this.addFiles(newFiles); // Reset the input value to allow selecting the same file again if needed target.value = ''; }); // Handle drag and drop const dropArea = this.shadowRoot.querySelector('.maincontainer'); const handlerFunction = async (eventArg: DragEvent) => { eventArg.preventDefault(); eventArg.stopPropagation(); switch (eventArg.type) { case 'dragenter': case 'dragover': this.state = 'dragOver'; break; case 'dragleave': // Check if we're actually leaving the drop area const rect = dropArea.getBoundingClientRect(); const x = eventArg.clientX; const y = eventArg.clientY; if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { this.state = 'idle'; } break; case 'drop': this.state = 'idle'; const files = Array.from(eventArg.dataTransfer.files); await this.addFiles(files); break; } }; dropArea.addEventListener('dragenter', handlerFunction, false); dropArea.addEventListener('dragleave', handlerFunction, false); dropArea.addEventListener('dragover', handlerFunction, false); dropArea.addEventListener('drop', handlerFunction, false); } private async addFiles(files: File[]) { const filesToAdd: File[] = []; for (const file of files) { if (this.validateFile(file)) { filesToAdd.push(file); } } if (filesToAdd.length === 0) return; // Check max files limit 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'; return; } filesToAdd.splice(allowedCount); this.validationMessage = `Only ${allowedCount} more file(s) can be added`; this.validationState = 'warn'; } } // Add files if (!this.multiple && filesToAdd.length > 0) { this.value = [filesToAdd[0]]; } else { this.value.push(...filesToAdd); } this.requestUpdate(); this.validate(); this.changeSubject.next(this); // Update button text if (this.value.length > 0) { this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; } } 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; } // Validate all files for (const file of this.value) { if (!this.validateFile(file)) { return false; } } this.validationState = 'valid'; return true; } public getValue(): File[] { return this.value; } public setValue(value: File[]): void { this.value = value; this.requestUpdate(); if (value.length > 0) { this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; } else { this.buttonText = 'Upload File...'; } } public updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has('value')) { this.validate(); } } }