diff --git a/demo/partials/forms.html b/demo/partials/forms.html index 68737f9..72361b9 100644 --- a/demo/partials/forms.html +++ b/demo/partials/forms.html @@ -113,6 +113,41 @@ +
+
+
+
+
+

Upload files

+

Attach documents, archives, screenshots or product images for review.

+
+ Max 12 MB +
+ + + + + +
+ + +
+
+
+
+
diff --git a/src/js/components/input-patterns.js b/src/js/components/input-patterns.js index cab3bd5..728eb76 100644 --- a/src/js/components/input-patterns.js +++ b/src/js/components/input-patterns.js @@ -1,4 +1,161 @@ const initializedRoots = new WeakSet(); +const fileUploadState = new WeakMap(); + +function getFileKey(file) { + return `${file.name}:${file.size}:${file.lastModified}`; +} + +function clearFilePreviews(previewNode) { + if(!previewNode) { + return; + } + + previewNode.querySelectorAll("img[data-object-url]").forEach(image => { + URL.revokeObjectURL(image.dataset.objectUrl); + }); + previewNode.innerHTML = ""; + previewNode.hidden = true; +} + +function getStoredFiles(input) { + return fileUploadState.get(input) || []; +} + +function setStoredFiles(input, files) { + fileUploadState.set(input, files); + + const transfer = new DataTransfer(); + files.forEach(file => transfer.items.add(file)); + input.files = transfer.files; +} + +function addStoredFiles(input, files) { + const storedFiles = getStoredFiles(input); + const knownKeys = new Set(storedFiles.map(getFileKey)); + const nextFiles = [...storedFiles]; + + files.forEach(file => { + const key = getFileKey(file); + + if(!knownKeys.has(key)) { + knownKeys.add(key); + nextFiles.push(file); + } + }); + + setStoredFiles(input, nextFiles); + return nextFiles; +} + +function removeStoredFile(input, index) { + const nextFiles = getStoredFiles(input).filter((file, fileIndex) => fileIndex !== index); + setStoredFiles(input, nextFiles); + return nextFiles; +} + +function getFileType(file) { + const nameParts = file.name.split("."); + const extension = nameParts.length > 1 ? nameParts.pop().trim() : ""; + + if(extension) { + return extension.slice(0, 6).toUpperCase(); + } + + if(file.type) { + return file.type.split("/").pop().slice(0, 6).toUpperCase(); + } + + return "FILE"; +} + +function formatBytes(bytes) { + if(!Number.isFinite(bytes)) { + return ""; + } + + if(bytes === 0) { + return "0 B"; + } + + const units = ["B", "KB", "MB", "GB"]; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / Math.pow(1024, index); + + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function updateFileUpload(input) { + const container = input.closest(".file-upload-panel, .file-upload"); + const previewNode = container?.querySelector("[data-file-upload-preview]"); + + if(!container || !previewNode) { + return; + } + + const files = getStoredFiles(input); + + if(!files.length) { + clearFilePreviews(previewNode); + return; + } + + updateFilePreviews(previewNode, files); +} + +function updateFilePreviews(previewNode, files) { + if(!previewNode) { + return; + } + + clearFilePreviews(previewNode); + + files.forEach((file, index) => { + const figure = document.createElement("figure"); + figure.className = "file-upload-preview-item"; + figure.dataset.fileUploadIndex = String(index); + + const preview = document.createElement("div"); + preview.className = "file-upload-preview-visual"; + + if(file.type.startsWith("image/")) { + const image = document.createElement("img"); + const objectUrl = URL.createObjectURL(file); + image.src = objectUrl; + image.dataset.objectUrl = objectUrl; + image.alt = ""; + image.loading = "lazy"; + preview.append(image); + } else { + const type = document.createElement("span"); + type.className = "file-upload-preview-type"; + type.textContent = getFileType(file); + preview.append(type); + } + + const caption = document.createElement("figcaption"); + + const name = document.createElement("span"); + name.className = "file-upload-preview-name"; + name.textContent = file.name; + + const meta = document.createElement("span"); + meta.className = "file-upload-preview-meta"; + meta.textContent = `${getFileType(file)} / ${formatBytes(file.size)}`; + + const remove = document.createElement("button"); + remove.className = "file-upload-preview-remove"; + remove.type = "button"; + remove.dataset.fileUploadRemove = String(index); + remove.setAttribute("aria-label", `Remove ${file.name}`); + remove.innerHTML = ``; + + caption.append(name, meta); + figure.append(remove, preview, caption); + previewNode.append(figure); + }); + + previewNode.hidden = false; +} function init(root = document) { if(initializedRoots.has(root)) { @@ -24,9 +181,55 @@ input.focus(); }); + root.addEventListener("click", event => { + const removeButton = event.target.closest("[data-file-upload-remove]"); + + if(!removeButton) { + return; + } + + const container = removeButton.closest(".file-upload-panel, .file-upload"); + const input = container?.querySelector("[data-file-upload-input]"); + + if(!input) { + return; + } + + removeStoredFile(input, Number(removeButton.dataset.fileUploadRemove)); + updateFileUpload(input); + input.dispatchEvent(new Event("change", { bubbles: true })); + }); + + root.addEventListener("change", event => { + const input = event.target.closest("[data-file-upload-input]"); + + if(!input) { + return; + } + + addStoredFiles(input, Array.from(input.files || [])); + updateFileUpload(input); + }); + + root.addEventListener("reset", event => { + const form = event.target.closest("form"); + + if(!form) { + return; + } + + setTimeout(() => { + form.querySelectorAll("[data-file-upload-input]").forEach(input => { + setStoredFiles(input, []); + updateFileUpload(input); + }); + }, 0); + }); + initializedRoots.add(root); } export default { - init + init, + updateFileUpload }; diff --git a/src/scss/components/_cards.scss b/src/scss/components/_cards.scss index bc330ab..cb2dcb7 100644 --- a/src/scss/components/_cards.scss +++ b/src/scss/components/_cards.scss @@ -11,17 +11,6 @@ transition-timing-function: $motion-ease; transition-property: border-color, background, box-shadow, transform; - &::after { - content: ""; - position: absolute; - inset: 0 auto 0 0; - width: 34%; - background: linear-gradient(90deg, transparent, rgba($color-text-light, 0.08), transparent); - opacity: 0; - pointer-events: none; - transform: translateX(-120%); - } - .card-title { color: $color-black; background: $color-text-light; @@ -60,11 +49,6 @@ box-shadow: 0 14px 32px rgba($color-black, 0.34); transform: translateY(-2px); - &::after { - opacity: 1; - animation: terminal_scan_x 0.85s $motion-ease; - } - .card-thumb { filter: saturate(1.12); transform: translateY(-2px); diff --git a/src/scss/components/_forms.scss b/src/scss/components/_forms.scss index 59dad21..b9594af 100644 --- a/src/scss/components/_forms.scss +++ b/src/scss/components/_forms.scss @@ -246,6 +246,228 @@ } } +.file-upload-panel { + width: 100%; + max-width: 760px; + @include hard_panel($border-color-muted, $border-width-accent); +} + +.file-upload-form { + display: flex; + flex-direction: column; + gap: $space-4; + margin: 0; +} + +.file-upload-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $space-4; + padding: $space-4 $space-4 0; +} + +.file-upload-heading { + display: flex; + flex-direction: column; + gap: $space-1; + min-width: 0; +} + +.file-upload-title { + margin: 0; + color: $color-text-light; + font-size: $font-size-lg; + font-weight: $font-weight-bold; + line-height: $line-height-snug; + text-transform: uppercase; +} + +.file-upload-description { + margin: 0; + color: $color-text-medium; + font-size: $font-size-sm; + line-height: $line-height-relaxed; +} + +.file-upload-dropzone { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: $space-4; + margin: 0 $space-4; + padding: $space-5; + border: $border-width-base dashed $color-secondary; + background: rgba($color-secondary, 0.08); + cursor: pointer; + transition-duration: $motion-base; + transition-timing-function: $motion-ease; + transition-property: background, border-color; + + input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + @include hover_touch { + border-color: $color-primary; + background: rgba($color-primary, 0.1); + } + + &:focus-within { + @include focus_ring; + } +} + +.file-upload-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: $control-height-lg; + height: $control-height-lg; + color: $color-black; + background: $color-secondary; + font-size: $icon-size-lg; +} + +.file-upload-body { + display: flex; + flex-direction: column; + gap: $space-1; + min-width: 0; +} + +.file-upload-primary { + color: $color-text-light; + font-size: $font-size-base; + font-weight: $font-weight-bold; + line-height: $line-height-snug; + text-transform: uppercase; +} + +.file-upload-secondary { + color: $color-text-medium; + font-size: $font-size-sm; + line-height: $line-height-normal; +} + +.file-upload-preview { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + gap: $space-3; + margin: 0 $space-4; + + &[hidden] { + display: none; + } +} + +.file-upload-preview-item { + position: relative; + min-width: 0; + margin: 0; + border: $border-width-base solid $border-color-muted; + background: $surface-panel-muted; +} + +.file-upload-preview-remove { + position: absolute; + top: $space-2; + right: $space-2; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: $control-icon-rail-sm; + height: $control-icon-rail-sm; + padding: 0; + border: $border-width-base solid $color-error; + color: $color-error; + background: $surface-panel; + font-size: $icon-size-sm; + cursor: pointer; + transition-duration: $motion-base; + transition-timing-function: $motion-ease; + transition-property: color, background, border-color; + + @include hover_touch { + color: $color-black; + background: $color-error; + } + + &:focus-visible { + @include focus_ring; + } +} + +.file-upload-preview-visual { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + background: $surface-panel; +} + +.file-upload-preview-visual img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.file-upload-preview-type { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: $control-height-lg; + min-height: $control-height-lg; + padding: $space-2; + color: $color-black; + background: $color-secondary; + font-size: $font-size-sm; + font-weight: $font-weight-bold; + line-height: $line-height-base; + text-transform: uppercase; +} + +.file-upload-preview-item figcaption { + display: flex; + flex-direction: column; + gap: $space-1; + overflow: hidden; + padding: $space-2; +} + +.file-upload-preview-name { + overflow: hidden; + color: $color-text-light; + font-size: $font-size-xs; + font-weight: $font-weight-bold; + line-height: $line-height-snug; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-upload-preview-meta { + color: $color-text-medium; + font-size: $font-size-xs; + font-weight: $font-weight-bold; + line-height: $line-height-snug; + text-transform: uppercase; +} + +.file-upload-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: $space-2; + padding: 0 $space-4 $space-4; +} + .range { width: 100%; max-width: 600px; @@ -295,6 +517,23 @@ .form-grid { grid-template-columns: 1fr; } + + .file-upload-header { + flex-direction: column; + align-items: stretch; + } + + .file-upload-dropzone { + grid-template-columns: 1fr; + } + + .file-upload-actions { + justify-content: stretch; + + .btn { + width: 100%; + } + } } .radio {