Refactor dees-input-fileupload component and styles
- Updated demo.ts to enhance layout and styling, including renaming classes and adjusting spacing. - Removed unused template rendering logic from template.ts. - Simplified index.ts by removing the export of renderFileupload. - Revamped styles in styles.ts for improved design consistency and responsiveness. - Enhanced file upload functionality with better descriptions and validation messages.
This commit is contained in:
@@ -1,15 +1,15 @@
|
||||
import { DeesContextmenu } from '../dees-contextmenu.js';
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
import { demoFunc } from './demo.js';
|
||||
import { fileuploadStyles } from './styles.js';
|
||||
import { renderFileupload } from './template.js';
|
||||
import '../dees-icon.js';
|
||||
import '../dees-label.js';
|
||||
|
||||
import {
|
||||
customElement,
|
||||
type TemplateResult,
|
||||
html,
|
||||
property,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
@@ -22,22 +22,17 @@ declare global {
|
||||
export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
public static demo = demoFunc;
|
||||
|
||||
|
||||
@property({
|
||||
attribute: false,
|
||||
})
|
||||
@property({ attribute: false })
|
||||
public value: File[] = [];
|
||||
|
||||
@property()
|
||||
@state()
|
||||
public state: 'idle' | 'dragOver' | 'dropped' | 'uploading' | 'completed' = 'idle';
|
||||
|
||||
@property({ type: Boolean })
|
||||
@state()
|
||||
public isLoading: boolean = false;
|
||||
|
||||
@property({
|
||||
type: String,
|
||||
})
|
||||
public buttonText: string = 'Upload File...';
|
||||
@property({ type: String })
|
||||
public buttonText: string = 'Select files';
|
||||
|
||||
@property({ type: String })
|
||||
public accept: string = '';
|
||||
@@ -54,24 +49,258 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
@property({ type: String, reflect: true })
|
||||
public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
public validationMessage: string = '';
|
||||
|
||||
private previewUrlMap: WeakMap<File, string> = new WeakMap();
|
||||
private dropArea: HTMLElement | null = null;
|
||||
|
||||
public static styles = fileuploadStyles;
|
||||
|
||||
public render(): TemplateResult {
|
||||
return renderFileupload(this);
|
||||
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' : ''}"
|
||||
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>
|
||||
</div>
|
||||
${this.renderFileList()}
|
||||
${this.validationMessage
|
||||
? html`<div class="validation-message" aria-live="polite">${this.validationMessage}</div>`
|
||||
: html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public validationMessage: string = '';
|
||||
private renderFileList(): TemplateResult {
|
||||
if (this.value.length === 0) {
|
||||
return html`
|
||||
<div class="file-empty">
|
||||
<div class="file-empty__title">No files selected</div>
|
||||
<div class="file-empty__hint">
|
||||
Selected files will be listed here
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
public formatFileSize(bytes: number): string {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const units = ['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];
|
||||
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 {
|
||||
@@ -88,176 +317,257 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
}
|
||||
|
||||
public 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'
|
||||
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[type] || 'lucide:file';
|
||||
return iconMap[fileType] ?? 'lucide:File';
|
||||
}
|
||||
|
||||
public canShowPreview(file: File): boolean {
|
||||
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews
|
||||
return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024;
|
||||
}
|
||||
|
||||
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.validationMessage = `File "${file.name}" exceeds the 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())) {
|
||||
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;
|
||||
}
|
||||
} 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;
|
||||
|
||||
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;
|
||||
|
||||
// Set loading state
|
||||
if (this.disabled || this.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]');
|
||||
|
||||
// Set up a focus handler to detect when the dialog is closed without selection
|
||||
|
||||
// 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(() => {
|
||||
// Check if no file was selected
|
||||
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();
|
||||
this.validate();
|
||||
this.requestUpdate('value');
|
||||
void this.validate();
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
}
|
||||
|
||||
public clearAll() {
|
||||
const existingFiles = [...this.value];
|
||||
this.value = [];
|
||||
this.requestUpdate();
|
||||
this.validate();
|
||||
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: any = eventArg.target;
|
||||
this.value = target.value;
|
||||
const target = eventArg.target as HTMLInputElement;
|
||||
this.value = Array.from(target.files ?? []);
|
||||
this.changeSubject.next(this);
|
||||
}
|
||||
|
||||
public firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
|
||||
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);
|
||||
|
||||
// Always reset loading state when file dialog interaction completes
|
||||
this.isLoading = false;
|
||||
|
||||
await this.addFiles(newFiles);
|
||||
// Reset the input value to allow selecting the same file again if needed
|
||||
target.value = '';
|
||||
});
|
||||
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();
|
||||
}
|
||||
|
||||
// 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);
|
||||
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) return;
|
||||
if (filesToAdd.length === 0) {
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check max files limit
|
||||
if (this.maxFiles > 0) {
|
||||
const totalFiles = this.value.length + filesToAdd.length;
|
||||
if (totalFiles > this.maxFiles) {
|
||||
@@ -265,6 +575,7 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
if (allowedCount <= 0) {
|
||||
this.validationMessage = `Maximum ${this.maxFiles} files allowed`;
|
||||
this.validationState = 'invalid';
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
filesToAdd.splice(allowedCount);
|
||||
@@ -273,62 +584,43 @@ export class DeesInputFileupload extends DeesInputBase<DeesInputFileupload> {
|
||||
}
|
||||
}
|
||||
|
||||
// Add files
|
||||
if (!this.multiple && filesToAdd.length > 0) {
|
||||
this.value.forEach((file) => this.releasePreview(file));
|
||||
this.value = [filesToAdd[0]];
|
||||
} else {
|
||||
this.value.push(...filesToAdd);
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
this.validate();
|
||||
this.validationMessage = '';
|
||||
this.validationState = null;
|
||||
this.requestUpdate('value');
|
||||
await this.validate();
|
||||
this.changeSubject.next(this);
|
||||
|
||||
// Update button text
|
||||
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;
|
||||
}
|
||||
|
||||
// Validate all files
|
||||
|
||||
for (const file of this.value) {
|
||||
if (!this.validateFile(file)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
this.validationState = 'valid';
|
||||
|
||||
this.validationState = this.value.length > 0 ? 'valid' : null;
|
||||
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<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('value')) {
|
||||
this.validate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { html, css, cssManager } from '@design.estate/dees-element';
|
||||
import { css, cssManager, html } from '@design.estate/dees-element';
|
||||
import './component.js';
|
||||
import '../dees-panel.js';
|
||||
|
||||
@@ -6,201 +6,154 @@ export const demoFunc = () => html`
|
||||
<dees-demowrapper>
|
||||
<style>
|
||||
${css`
|
||||
.demo-container {
|
||||
.demo-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
gap: 32px;
|
||||
padding: 24px;
|
||||
max-width: 1200px;
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.upload-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@media (min-width: 960px) {
|
||||
.demo-grid--two {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fff', '#2a2a2a')};
|
||||
border-radius: 4px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444')};
|
||||
|
||||
.demo-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.upload-box h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
color: ${cssManager.bdTheme('#333', '#fff')};
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-top: 32px;
|
||||
|
||||
.demo-note {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fff3cd', '#332701')};
|
||||
border: 1px solid ${cssManager.bdTheme('#ffeaa7', '#664400')};
|
||||
border-radius: 4px;
|
||||
color: ${cssManager.bdTheme('#856404', '#ffecb5')};
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(215 20% 26%)')};
|
||||
background: ${cssManager.bdTheme('hsl(213 100% 97%)', 'hsl(215 20% 12%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215 25% 32%)', 'hsl(215 20% 82%)')};
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.demo-note strong {
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 45%)', 'hsl(213 93% 68%)')};
|
||||
font-weight: 600;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div class="demo-container">
|
||||
<dees-panel .title=${'1. Basic File Upload'} .subtitle=${'Simple file upload with drag and drop support'}>
|
||||
<dees-input-fileupload
|
||||
.label=${'Attachments'}
|
||||
.description=${'Upload any files by clicking or dragging them here'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Single File Only'}
|
||||
.description=${'Only one file can be uploaded at a time'}
|
||||
.multiple=${false}
|
||||
.buttonText=${'Choose File'}
|
||||
></dees-input-fileupload>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'2. File Type Restrictions'} .subtitle=${'Upload areas with specific file type requirements'}>
|
||||
<div class="upload-grid">
|
||||
<div class="upload-box">
|
||||
<h4>Images Only</h4>
|
||||
|
||||
<div class="demo-shell">
|
||||
<dees-panel
|
||||
.title=${'Modern file uploader'}
|
||||
.subtitle=${'Shadcn-inspired layout with drag & drop, previews and validation'}
|
||||
>
|
||||
<div class="demo-grid demo-grid--two">
|
||||
<div class="demo-stack">
|
||||
<dees-input-fileupload
|
||||
.label=${'Profile Picture'}
|
||||
.description=${'JPG, PNG or GIF (max 5MB)'}
|
||||
.accept=${'image/jpeg,image/png,image/gif'}
|
||||
.maxSize=${5 * 1024 * 1024}
|
||||
.label=${'Attachments'}
|
||||
.description=${'Upload supporting documents for your request'}
|
||||
.accept=${'image/*,.pdf,.zip'}
|
||||
.maxSize=${10 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Brand assets'}
|
||||
.description=${'Upload high-resolution imagery (JPG/PNG)'}
|
||||
.accept=${'image/jpeg,image/png'}
|
||||
.multiple=${false}
|
||||
.buttonText=${'Select Image'}
|
||||
.maxSize=${5 * 1024 * 1024}
|
||||
.buttonText=${'Select cover image'}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
|
||||
<div class="upload-box">
|
||||
<h4>Documents Only</h4>
|
||||
|
||||
<div class="demo-stack">
|
||||
<dees-input-fileupload
|
||||
.label=${'Resume'}
|
||||
.description=${'PDF or Word documents only'}
|
||||
.accept=${".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
|
||||
.buttonText=${'Select Document'}
|
||||
.label=${'Audio uploads'}
|
||||
.description=${'Share podcast drafts (MP3/WAV, max 25MB each)'}
|
||||
.accept=${'audio/*'}
|
||||
.maxSize=${25 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Disabled example'}
|
||||
.description=${'Uploader is disabled while moderation is pending'}
|
||||
.disabled=${true}
|
||||
></dees-input-fileupload>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'3. Validation & Limits'} .subtitle=${'File size limits and validation examples'}>
|
||||
<dees-input-fileupload
|
||||
.label=${'Small Files Only'}
|
||||
.description=${'Maximum file size: 1MB'}
|
||||
.maxSize=${1024 * 1024}
|
||||
.buttonText=${'Upload Small File'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Limited Upload'}
|
||||
.description=${'Maximum 3 files, each up to 2MB'}
|
||||
.maxFiles=${3}
|
||||
.maxSize=${2 * 1024 * 1024}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Required Upload'}
|
||||
.description=${'This field is required'}
|
||||
.required=${true}
|
||||
></dees-input-fileupload>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'4. States & Styling'} .subtitle=${'Different states and validation feedback'}>
|
||||
<dees-input-fileupload
|
||||
.label=${'Disabled Upload'}
|
||||
.description=${'File upload is currently disabled'}
|
||||
.disabled=${true}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Pre-filled Example'}
|
||||
.description=${'Component with pre-loaded files'}
|
||||
.value=${[
|
||||
new File(['Hello World'], 'example.txt', { type: 'text/plain' }),
|
||||
new File(['Test Data'], 'data.json', { type: 'application/json' })
|
||||
]}
|
||||
></dees-input-fileupload>
|
||||
</dees-panel>
|
||||
|
||||
<dees-panel .title=${'5. Form Integration'} .subtitle=${'Complete form with various file upload scenarios'}>
|
||||
<dees-form>
|
||||
<h3 style="margin-top: 0; margin-bottom: 24px; color: ${cssManager.bdTheme('#333', '#fff')};">Job Application Form</h3>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Full Name'}
|
||||
.required=${true}
|
||||
.key=${'fullName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Email'}
|
||||
.inputType=${'email'}
|
||||
.required=${true}
|
||||
.key=${'email'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Resume'}
|
||||
.description=${'Required: PDF format only (max 10MB)'}
|
||||
.required=${true}
|
||||
.accept=${'application/pdf'}
|
||||
.maxSize=${10 * 1024 * 1024}
|
||||
.multiple=${false}
|
||||
.key=${'resume'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Portfolio'}
|
||||
.description=${'Optional: Upload up to 5 work samples (images or PDFs, max 5MB each)'}
|
||||
.accept=${'image/*,application/pdf'}
|
||||
.maxFiles=${5}
|
||||
.maxSize=${5 * 1024 * 1024}
|
||||
.key=${'portfolio'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'References'}
|
||||
.description=${'Upload reference letters (optional)'}
|
||||
.accept=${".pdf,.doc,.docx"}
|
||||
.key=${'references'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Additional Comments'}
|
||||
.inputType=${'textarea'}
|
||||
.description=${'Any additional information you would like to share'}
|
||||
.key=${'comments'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-form-submit .text=${'Submit Application'}></dees-form-submit>
|
||||
</dees-form>
|
||||
|
||||
<div class="info-section">
|
||||
<h4 style="margin-top: 0;">Enhanced Features:</h4>
|
||||
<ul style="margin: 0; padding-left: 20px;">
|
||||
<li>Drag & drop with visual feedback</li>
|
||||
<li>File type restrictions via accept attribute</li>
|
||||
<li>File size validation with custom limits</li>
|
||||
<li>Maximum file count restrictions</li>
|
||||
<li>Image preview thumbnails</li>
|
||||
<li>File type-specific icons</li>
|
||||
<li>Clear all button for multiple files</li>
|
||||
<li>Proper validation states and messages</li>
|
||||
<li>Keyboard accessible</li>
|
||||
<li>Single or multiple file modes</li>
|
||||
</ul>
|
||||
|
||||
<dees-panel
|
||||
.title=${'Form integration'}
|
||||
.subtitle=${'Combine file uploads with the rest of the DEES form ecosystem'}
|
||||
>
|
||||
<div class="demo-grid">
|
||||
<dees-form>
|
||||
<div class="demo-stack">
|
||||
<dees-input-text
|
||||
.label=${'Project name'}
|
||||
.description=${'How should we refer to this project internally?'}
|
||||
.required=${true}
|
||||
.key=${'projectName'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Contact email'}
|
||||
.inputType=${'email'}
|
||||
.required=${true}
|
||||
.key=${'contactEmail'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Statement of work'}
|
||||
.description=${'Upload a signed statement of work (PDF, max 15MB)'}
|
||||
.required=${true}
|
||||
.accept=${'application/pdf'}
|
||||
.maxSize=${15 * 1024 * 1024}
|
||||
.multiple=${false}
|
||||
.key=${'sow'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-fileupload
|
||||
.label=${'Creative references'}
|
||||
.description=${'Optional. Upload up to five visual references'}
|
||||
.accept=${'image/*'}
|
||||
.maxFiles=${5}
|
||||
.maxSize=${8 * 1024 * 1024}
|
||||
.key=${'references'}
|
||||
></dees-input-fileupload>
|
||||
|
||||
<dees-input-text
|
||||
.label=${'Notes'}
|
||||
.description=${'Add optional context for reviewers'}
|
||||
.inputType=${'textarea'}
|
||||
.key=${'notes'}
|
||||
></dees-input-text>
|
||||
|
||||
<dees-form-submit .text=${'Submit briefing'}></dees-form-submit>
|
||||
</div>
|
||||
</dees-form>
|
||||
|
||||
<div class="demo-note">
|
||||
<strong>Good to know:</strong>
|
||||
<ul>
|
||||
<li>Drag & drop highlights the dropzone and supports keyboard activation.</li>
|
||||
<li>Accepted file types are summarised automatically from the <code>accept</code> attribute.</li>
|
||||
<li>Image uploads show live previews generated via <code>URL.createObjectURL</code>.</li>
|
||||
<li>File size and file-count limits surface inline validation messages.</li>
|
||||
<li>The component stays compatible with <code>dees-form</code> value accessors.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</dees-panel>
|
||||
</div>
|
||||
</dees-demowrapper>
|
||||
`;
|
||||
`;
|
||||
|
@@ -1,3 +1,2 @@
|
||||
export * from './component.js';
|
||||
export { fileuploadStyles } from './styles.js';
|
||||
export { renderFileupload } from './template.js';
|
||||
|
@@ -2,308 +2,332 @@ import { css, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesInputBase } from '../dees-input-base.js';
|
||||
|
||||
export const fileuploadStyles = [
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
color: ${cssManager.bdTheme('hsl(0 0% 15%)', 'hsl(0 0% 90%)')};
|
||||
}
|
||||
...DeesInputBase.baseStyles,
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.dropzone {
|
||||
position: relative;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
border: 1.5px dashed ${cssManager.bdTheme('hsl(215 16% 80%)', 'hsl(217 20% 25%)')};
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')};
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.maincontainer:hover {
|
||||
border-color: ${cssManager.bdTheme('hsl(215 20.2% 55.1%)', 'hsl(215 20.2% 45.1%)')};
|
||||
}
|
||||
.dropzone:focus-visible {
|
||||
box-shadow: 0 0 0 2px ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 12%)')},
|
||||
0 0 0 4px ${cssManager.bdTheme('hsl(217 91% 60% / 0.5)', 'hsl(213 93% 68% / 0.4)')};
|
||||
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
}
|
||||
|
||||
:host([disabled]) .maincontainer {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dropzone--active {
|
||||
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
box-shadow: 0 12px 32px ${cssManager.bdTheme('rgba(15, 23, 42, 0.12)', 'rgba(0, 0, 0, 0.35)')};
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.06)', 'hsl(213 93% 68% / 0.12)')};
|
||||
}
|
||||
|
||||
:host([validationState="invalid"]) .maincontainer {
|
||||
border-color: ${cssManager.bdTheme('hsl(0 72.2% 50.6%)', 'hsl(0 62.8% 30.6%)')};
|
||||
}
|
||||
.dropzone--disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:host([validationState="valid"]) .maincontainer {
|
||||
border-color: ${cssManager.bdTheme('hsl(142.1 70.6% 45.3%)', 'hsl(142.1 76.2% 36.3%)')};
|
||||
}
|
||||
.dropzone__body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
:host([validationState="warn"]) .maincontainer {
|
||||
border-color: ${cssManager.bdTheme('hsl(45.4 93.4% 47.5%)', 'hsl(45.4 93.4% 47.5%)')};
|
||||
}
|
||||
.dropzone__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 60% / 0.12)', 'hsl(213 93% 68% / 0.12)')};
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.dropzone__icon dees-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.dropzone__loader {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid ${cssManager.bdTheme('rgba(15, 23, 42, 0.15)', 'rgba(255, 255, 255, 0.15)')};
|
||||
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
animation: loader-spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.dropzone__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.uploadButton:active {
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 91%)', 'hsl(0 0% 11%)')};
|
||||
}
|
||||
.dropzone__headline {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
|
||||
}
|
||||
|
||||
.uploadButton dees-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.dropzone__subline {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.files-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dropzone__browse {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
margin-left: 4px;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.dropzone__browse:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.dropzone__browse:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.dropzone__meta {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 72%)')};
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.dropzone__meta span {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(213 93% 18%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(217 91% 90%)', 'hsl(213 93% 24%)')};
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.file-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.uploadCandidate .actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.file-empty__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 40%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.file-empty__hint {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 50%)', 'hsl(215 16% 65%)')};
|
||||
line-height: 1.5;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: ${cssManager.bdTheme('hsl(210 20% 97%)', 'hsl(215 20% 15%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(213 27% 92%)', 'hsl(217 20% 22%)')};
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
|
||||
.clear-all-button {
|
||||
margin-bottom: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
.file-list__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 40%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.file-list__clear {
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: none;
|
||||
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.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%)')};
|
||||
}
|
||||
.file-list__clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.file-list__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(215 20% 16%)')};
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(213 27% 90%)', 'hsl(217 25% 28%)')};
|
||||
border-radius: 10px;
|
||||
box-shadow: ${cssManager.bdTheme('0 1px 2px rgba(15, 23, 42, 0.04)', '0 1px 2px rgba(0, 0, 0, 0.24)')};
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.drop-hint dees-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.2;
|
||||
}
|
||||
.file-row:hover {
|
||||
border-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
||||
box-shadow: ${cssManager.bdTheme('0 10px 24px rgba(15, 23, 42, 0.08)', '0 12px 30px rgba(0, 0, 0, 0.35)')};
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.file-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 32% 18%)')};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.file-thumb dees-icon {
|
||||
font-size: 20px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 70%)')};
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Loading state styles */
|
||||
.uploadButton.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.uploadButton .button-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.thumb-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
||||
border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
.file-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('hsl(222 47% 11%)', 'hsl(210 20% 96%)')};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.02);
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.file-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
}
|
||||
|
||||
.uploadButton.loading {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
`,
|
||||
];
|
||||
.file-size {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.file-type {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 32% 28%)')};
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 46%)', 'hsl(215 16% 70%)')};
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.15s ease, transform 0.15s ease, color 0.15s ease;
|
||||
color: ${cssManager.bdTheme('hsl(215 16% 52%)', 'hsl(215 16% 68%)')};
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background: ${cssManager.bdTheme('hsl(0 72% 50% / 0.12)', 'hsl(0 62% 32% / 0.2)')};
|
||||
color: ${cssManager.bdTheme('hsl(0 72% 46%)', 'hsl(0 70% 70%)')};
|
||||
}
|
||||
|
||||
.remove-button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.remove-button dees-icon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
font-size: 13px;
|
||||
color: ${cssManager.bdTheme('hsl(0 72% 40%)', 'hsl(0 70% 68%)')};
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes loader-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@@ -1,84 +0,0 @@
|
||||
import { html, type TemplateResult } from '@design.estate/dees-element';
|
||||
import type { DeesInputFileupload } from './component.js';
|
||||
|
||||
export const renderFileupload = (component: DeesInputFileupload): TemplateResult => {
|
||||
const hasFiles = component.value.length > 0;
|
||||
const showClearAll = hasFiles && component.value.length > 1;
|
||||
|
||||
return html`
|
||||
<div class="input-wrapper">
|
||||
${component.label ? html`
|
||||
<dees-label .label=${component.label}></dees-label>
|
||||
` : ''}
|
||||
<div class="hidden">
|
||||
<input
|
||||
type="file"
|
||||
?multiple=${component.multiple}
|
||||
accept="${component.accept}"
|
||||
>
|
||||
</div>
|
||||
<div class="maincontainer ${component.state === 'dragOver' ? 'dragOver' : ''}">
|
||||
${hasFiles ? html`
|
||||
${showClearAll ? html`
|
||||
<div class="clear-all-button">
|
||||
<button @click=${component.clearAll}>Clear All</button>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="files-container">
|
||||
${component.value.map((fileArg) => {
|
||||
const fileType = component.getFileType(fileArg);
|
||||
const isImage = fileType === 'image';
|
||||
return html`
|
||||
<div class="uploadCandidate ${fileType}-file">
|
||||
<div class="icon">
|
||||
${isImage && component.canShowPreview(fileArg) ? html`
|
||||
<img class="image-preview" src="${URL.createObjectURL(fileArg)}" alt="${fileArg.name}">
|
||||
` : html`
|
||||
<dees-icon .icon=${component.getFileIcon(fileArg)}></dees-icon>
|
||||
`}
|
||||
</div>
|
||||
<div class="info">
|
||||
<div class="filename" title="${fileArg.name}">${fileArg.name}</div>
|
||||
<div class="filesize">${component.formatFileSize(fileArg.size)}</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="remove-button"
|
||||
@click=${() => component.removeFile(fileArg)}
|
||||
title="Remove file"
|
||||
>
|
||||
<dees-icon .icon=${'lucide:x'}></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
` : html`
|
||||
<div class="drop-hint">
|
||||
<dees-icon .icon=${'lucide:cloud-upload'}></dees-icon>
|
||||
<div>Drag files here or click to browse</div>
|
||||
</div>
|
||||
`}
|
||||
<div class="uploadButton ${component.isLoading ? 'loading' : ''}" @click=${component.openFileSelector}>
|
||||
<div class="button-content">
|
||||
${component.isLoading ? html`
|
||||
<div class="loading-spinner"></div>
|
||||
<span>Opening...</span>
|
||||
` : html`
|
||||
<dees-icon .icon=${'lucide:upload'}></dees-icon>
|
||||
${component.buttonText}
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${component.description ? html`
|
||||
<div class="description-text">${component.description}</div>
|
||||
` : ''}
|
||||
${component.validationState === 'invalid' && component.validationMessage ? html`
|
||||
<div class="validation-message">${component.validationMessage}</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
};
|
Reference in New Issue
Block a user