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