Files
breadcrumb-the-shire/web/js/components/app-file-upload.js
fs 9a4d59eb4c feat: add reusable app-file-upload component with drag-and-drop and preview
Replace bare <input type="file"> across all upload locations with a
card-based dropzone component via shared partial. Three visual states:
current server file (thumbnail + Replace/Delete), empty dropzone, and
pending file preview (local FileReader thumbnail + metadata). Delete
actions use data-confirm-message for confirmation dialog.

Centralized as templates/partials/app-file-upload.phtml to prevent
markup drift — documented as deliberate exception to plain-HTML inputs
convention in CLAUDE.md and developer checklist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 11:58:14 +02:00

274 lines
8.2 KiB
JavaScript

/**
* File upload — card-based dropzone with drag-and-drop, current file preview, and pending file preview.
*
* Markup contract:
* <div data-app-component="file-upload" class="app-file-upload"
* data-current-src="…" data-current-label="…"> <!-- optional: existing server file -->
* <div class="app-file-upload-current"> <!-- shown when has existing file -->
* <img class="app-file-upload-current-image" src="" alt="">
* <div class="app-file-upload-current-meta">
* <span class="app-file-upload-current-label"></span>
* <span class="app-file-upload-current-actions">
* <button type="button" class="app-file-upload-replace-button">Replace</button>
* <button type="submit" class="app-file-upload-delete-button"
* formaction="…" formmethod="post"
* data-confirm-message="…">Delete</button>
* </span>
* </div>
* </div>
* <label class="app-file-upload-dropzone"> <!-- shown when no file -->
* <input type="file" name="…" accept="…">
* <span class="app-file-upload-dropzone-icon">…</span>
* <span class="app-file-upload-dropzone-label">…</span>
* <span class="app-file-upload-dropzone-hint">…</span>
* </label>
* <div class="app-file-upload-preview"> <!-- shown when new file selected -->
* <img class="app-file-upload-thumbnail" hidden>
* <span class="app-file-upload-file-icon" hidden>…</span>
* <span class="app-file-upload-meta">
* <span class="app-file-upload-filename"></span>
* <span class="app-file-upload-filesize"></span>
* </span>
* <button type="button" class="app-file-upload-clear">…</button>
* </div>
* </div>
*/
const MAX_PREVIEW_SIZE = 10 * 1024 * 1024; // 10 MB
const isImageFile = (file) => file.type.startsWith('image/');
const formatFileSize = (bytes) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
export function initFileUpload(root) {
if (!(root instanceof HTMLElement)) {
return { destroy: () => {} };
}
if (root.dataset.fileUploadBound === '1') {
return { destroy: () => {} };
}
root.dataset.fileUploadBound = '1';
const input = root.querySelector('input[type="file"]');
if (!input) {
return { destroy: () => {} };
}
const thumbnail = root.querySelector('.app-file-upload-thumbnail');
const fileIcon = root.querySelector('.app-file-upload-file-icon');
const filenameEl = root.querySelector('.app-file-upload-filename');
const filesizeEl = root.querySelector('.app-file-upload-filesize');
const clearButton = root.querySelector('.app-file-upload-clear');
const currentImage = root.querySelector('.app-file-upload-current-image');
const currentLabel = root.querySelector('.app-file-upload-current-label');
const replaceButton = root.querySelector('.app-file-upload-replace-button');
const cleanupFns = [];
let dragCounter = 0;
// ── Initialize current file state from data attributes ──
const currentSrc = (root.dataset.currentSrc || '').trim();
const currentLabelText = (root.dataset.currentLabel || '').trim();
if (currentSrc) {
root.classList.add('has-current');
if (currentImage) {
currentImage.src = currentSrc;
currentImage.alt = currentLabelText;
}
if (currentLabel) {
currentLabel.textContent = currentLabelText;
}
}
// ── Show pending file (new selection) ──
const showFile = (file) => {
root.classList.add('has-file');
root.classList.remove('has-current');
if (filenameEl) {
filenameEl.textContent = file.name;
}
if (filesizeEl) {
filesizeEl.textContent = formatFileSize(file.size);
}
if (thumbnail) {
thumbnail.hidden = true;
}
if (fileIcon) {
fileIcon.hidden = true;
}
if (isImageFile(file) && file.size <= MAX_PREVIEW_SIZE && thumbnail) {
const reader = new FileReader();
reader.onload = (e) => {
thumbnail.src = /** @type {string} */ (e.target?.result ?? '');
thumbnail.hidden = false;
if (fileIcon) {
fileIcon.hidden = true;
}
};
reader.onerror = () => {
if (fileIcon) {
fileIcon.hidden = false;
}
};
reader.readAsDataURL(file);
} else if (fileIcon) {
fileIcon.hidden = false;
}
};
// ── Clear pending file → go back to current (if exists) or dropzone ──
const clearFile = () => {
root.classList.remove('has-file');
input.value = '';
if (thumbnail) {
thumbnail.src = '';
thumbnail.hidden = true;
}
if (fileIcon) {
fileIcon.hidden = true;
}
if (filenameEl) {
filenameEl.textContent = '';
}
if (filesizeEl) {
filesizeEl.textContent = '';
}
// Restore current state if a server file exists
if (currentSrc) {
root.classList.add('has-current');
}
};
// ── Input change ──
const onInputChange = () => {
const file = input.files?.[0];
if (file) {
showFile(file);
} else {
clearFile();
}
};
input.addEventListener('change', onInputChange);
cleanupFns.push(() => input.removeEventListener('change', onInputChange));
// ── Clear button ──
if (clearButton) {
const onClear = (e) => {
e.preventDefault();
e.stopPropagation();
clearFile();
};
clearButton.addEventListener('click', onClear);
cleanupFns.push(() => clearButton.removeEventListener('click', onClear));
}
// ── Replace button → open file dialog ──
if (replaceButton) {
const onReplace = (e) => {
e.preventDefault();
e.stopPropagation();
input.click();
};
replaceButton.addEventListener('click', onReplace);
cleanupFns.push(() => replaceButton.removeEventListener('click', onReplace));
}
// ── Drag and drop ──
const onDragEnter = (e) => {
e.preventDefault();
dragCounter++;
root.classList.add('is-dragover');
};
const onDragOver = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
};
const onDragLeave = (e) => {
e.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
root.classList.remove('is-dragover');
}
};
const onDrop = (e) => {
e.preventDefault();
dragCounter = 0;
root.classList.remove('is-dragover');
const file = e.dataTransfer?.files?.[0];
if (!file) {
return;
}
// Validate against accept attribute
const accept = input.accept;
if (accept && !fileMatchesAccept(file, accept)) {
root.classList.add('is-rejected');
setTimeout(() => root.classList.remove('is-rejected'), 600);
return;
}
// Transfer file to the native input via DataTransfer
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
showFile(file);
};
root.addEventListener('dragenter', onDragEnter);
root.addEventListener('dragover', onDragOver);
root.addEventListener('dragleave', onDragLeave);
root.addEventListener('drop', onDrop);
cleanupFns.push(() => {
root.removeEventListener('dragenter', onDragEnter);
root.removeEventListener('dragover', onDragOver);
root.removeEventListener('dragleave', onDragLeave);
root.removeEventListener('drop', onDrop);
});
const destroy = () => {
cleanupFns.forEach((fn) => fn());
cleanupFns.length = 0;
delete root.dataset.fileUploadBound;
};
return { destroy };
}
/**
* Check if a File matches an accept attribute value.
* Supports MIME types (image/*), extensions (.csv), and exact types (image/png).
*/
function fileMatchesAccept(file, accept) {
const tokens = accept.split(',').map((t) => t.trim().toLowerCase());
const fileName = file.name.toLowerCase();
const fileType = file.type.toLowerCase();
return tokens.some((token) => {
if (token.startsWith('.')) {
return fileName.endsWith(token);
}
if (token.endsWith('/*')) {
return fileType.startsWith(token.slice(0, -1));
}
return fileType === token;
});
}