620 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			620 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| 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<DeesInputFileupload> {
 | |
|   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<File, string> = 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`
 | |
|       <div class="input-wrapper">
 | |
|         <dees-label
 | |
|           .label=${this.label}
 | |
|           .description=${this.description}
 | |
|           .required=${this.required}
 | |
|         ></dees-label>
 | |
|         <div
 | |
|           class="dropzone ${this.state === 'dragOver' ? 'dropzone--active' : ''} ${this.disabled ? 'dropzone--disabled' : ''} ${this.value.length > 0 ? 'dropzone--has-files' : ''}"
 | |
|           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>
 | |
|           ${this.renderFileList()}
 | |
|         </div>
 | |
|         ${this.validationMessage
 | |
|           ? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>`
 | |
|           : html``}
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   private renderFileList(): TemplateResult {
 | |
|     if (this.value.length === 0) {
 | |
|       return html``;
 | |
|     }
 | |
| 
 | |
|     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;
 | |
|     }
 | |
|     // Don't open file selector if clicking on the browse button or file list
 | |
|     if ((event.target as HTMLElement).closest('.dropzone__browse, .file-list')) {
 | |
|       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<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',
 | |
|     };
 | |
|     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<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();
 | |
|   }
 | |
| 
 | |
|   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<boolean> {
 | |
|     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;
 | |
|   }
 | |
| }
 |