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 @@
+
+
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 {