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
|
|
|
}
|
|
|
|
}
|