From 819c68062d900278c2f5983c8fb25ee77b2126a5 Mon Sep 17 00:00:00 2001 From: Steve Perkins Date: Fri, 24 Apr 2026 08:01:34 -0400 Subject: [PATCH 1/2] file drop component codex 5.5 did it --- Makefile | 1 + src/css/file-drop.css | 66 +++++++++++++ src/js/file-drop.js | 213 ++++++++++++++++++++++++++++++++++++++++++ src/js/index.js | 1 + 4 files changed, 281 insertions(+) create mode 100644 src/css/file-drop.css create mode 100644 src/js/file-drop.js diff --git a/Makefile b/Makefile index 660a7c0..2508a28 100644 --- a/Makefile +++ b/Makefile @@ -20,6 +20,7 @@ CSS_FILES = src/css/00-base.css \ src/css/tabs.css \ src/css/dialog.css \ src/css/dropdown.css \ + src/css/file-drop.css \ src/css/toast.css \ src/css/sidebar.css \ src/css/skeleton.css \ diff --git a/src/css/file-drop.css b/src/css/file-drop.css new file mode 100644 index 0000000..cd2810f --- /dev/null +++ b/src/css/file-drop.css @@ -0,0 +1,66 @@ +@layer components { + ot-file-drop { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 8rem; + padding: var(--space-6); + text-align: center; + color: var(--muted-foreground); + background-color: var(--faint); + border: 1px dashed var(--border); + border-radius: var(--radius-medium); + transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast); + + &:not([disabled]) { + cursor: pointer; + } + + &:hover:not([disabled]), + &[data-dragging] { + color: var(--foreground); + background-color: var(--accent); + border-color: var(--ring); + } + + &[data-dragging] { + border-style: solid; + box-shadow: 0 0 0 2px rgb(from var(--ring) r g b / 0.2); + } + + &[data-invalid] { + border-color: var(--danger); + box-shadow: 0 0 0 2px rgb(from var(--danger) r g b / 0.15); + } + + &[disabled] { + opacity: 0.5; + cursor: not-allowed; + } + + & > input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + & > .content { + display: grid; + gap: var(--space-1); + } + + & strong { + color: var(--foreground); + font-weight: var(--font-medium); + } + + & small { + color: var(--muted-foreground); + } + } +} diff --git a/src/js/file-drop.js b/src/js/file-drop.js new file mode 100644 index 0000000..c819bec --- /dev/null +++ b/src/js/file-drop.js @@ -0,0 +1,213 @@ +/** + * oat - File Drop Component + * Drag files onto an area or click it to open the native file picker. + * + * Usage: + * + * Drop images here + * or click to browse + * + * + * Events: + * - ot-file-drop: detail = { files, accepted, rejected } + */ + +import { OtBase } from './base.js'; + +class OtFileDrop extends OtBase { + #input; + #dragDepth = 0; + + static get observedAttributes() { + return ['accept', 'multiple', 'disabled', 'name']; + } + + init() { + this.#input = this.querySelector(':scope > input[type="file"]') || document.createElement('input'); + const hadContent = [...this.childNodes].some(node => { + return node !== this.#input && (node.nodeType === Node.ELEMENT_NODE || node.textContent.trim()); + }); + + if (!this.#input.parentElement) { + this.#input.type = 'file'; + this.prepend(this.#input); + } + + this.#input.tabIndex = -1; + this.#input.setAttribute('aria-hidden', 'true'); + + if (!this.hasAttribute('role')) this.setAttribute('role', 'button'); + if (!this.hasAttribute('tabindex')) this.tabIndex = 0; + if (!this.hasAttribute('aria-label') && !this.textContent.trim()) { + this.setAttribute('aria-label', 'Choose files'); + } + + if (!hadContent) { + const content = document.createElement('span'); + content.className = 'content'; + content.innerHTML = 'Drop files hereor click to browse'; + this.append(content); + } + + this.#syncInput(); + + this.addEventListener('click', this); + this.addEventListener('keydown', this); + this.addEventListener('dragenter', this); + this.addEventListener('dragover', this); + this.addEventListener('dragleave', this); + this.addEventListener('drop', this); + this.#input.addEventListener('change', this); + } + + cleanup() { + this.removeEventListener('click', this); + this.removeEventListener('keydown', this); + this.removeEventListener('dragenter', this); + this.removeEventListener('dragover', this); + this.removeEventListener('dragleave', this); + this.removeEventListener('drop', this); + this.#input?.removeEventListener('change', this); + } + + attributeChangedCallback() { + this.#syncInput(); + } + + onclick(e) { + if (this.disabled || e.target === this.#input) return; + this.#input.click(); + } + + onkeydown(e) { + if (this.disabled) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.#input.click(); + } + } + + ondragenter(e) { + if (!this.#hasFiles(e) || this.disabled) return; + e.preventDefault(); + this.#dragDepth += 1; + this.dataset.dragging = ''; + } + + ondragover(e) { + if (!this.#hasFiles(e) || this.disabled) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + } + + ondragleave(e) { + if (!this.#hasFiles(e) || this.disabled) return; + this.#dragDepth -= 1; + if (this.#dragDepth <= 0) this.#clearDragState(); + } + + ondrop(e) { + if (!this.#hasFiles(e) || this.disabled) return; + e.preventDefault(); + this.#clearDragState(); + this.#setFiles(e.dataTransfer.files); + } + + onchange() { + this.#setFiles(this.#input.files); + } + + #setFiles(fileList) { + const files = [...fileList]; + const { accepted, rejected } = this.#partitionFiles(files); + const selected = this.multiple ? accepted : accepted.slice(0, 1); + const overflow = this.multiple ? [] : accepted.slice(1); + const allRejected = [...rejected, ...overflow]; + + try { + const dt = new DataTransfer(); + selected.forEach(file => dt.items.add(file)); + this.#input.files = dt.files; + } catch { + // Some older browsers do not allow assigning input.files. The emitted + // event still exposes the selected files. + } + + this.#emitFiles(selected, allRejected); + } + + #emitFiles(files, rejected = []) { + this.toggleAttribute('data-invalid', rejected.length > 0); + this.emit('ot-file-drop', { files, accepted: files, rejected }); + } + + #partitionFiles(files) { + const tests = this.accept.split(',').map(t => t.trim().toLowerCase()).filter(Boolean); + if (tests.length === 0) return { accepted: files, rejected: [] }; + + return files.reduce((acc, file) => { + (this.#matchesAccept(file, tests) ? acc.accepted : acc.rejected).push(file); + return acc; + }, { accepted: [], rejected: [] }); + } + + #matchesAccept(file, tests) { + const name = file.name.toLowerCase(); + const type = file.type.toLowerCase(); + + return tests.some(test => { + if (test.startsWith('.')) return name.endsWith(test); + if (test.endsWith('/*')) return type.startsWith(test.slice(0, -1)); + return type === test; + }); + } + + #hasFiles(e) { + return [...(e.dataTransfer?.types || [])].includes('Files'); + } + + #clearDragState() { + this.#dragDepth = 0; + delete this.dataset.dragging; + } + + #syncInput() { + if (!this.#input) return; + + this.#input.accept = this.accept; + this.#input.multiple = this.multiple; + this.#input.disabled = this.disabled; + this.#input.name = this.getAttribute('name') || ''; + this.setAttribute('aria-disabled', String(this.disabled)); + } + + get files() { + return this.#input ? [...this.#input.files] : []; + } + + get accept() { + return this.getAttribute('accept') || ''; + } + + set accept(value) { + value ? this.setAttribute('accept', value) : this.removeAttribute('accept'); + } + + get multiple() { + return this.hasAttribute('multiple'); + } + + set multiple(value) { + this.toggleAttribute('multiple', Boolean(value)); + } + + get disabled() { + return this.hasAttribute('disabled'); + } + + set disabled(value) { + this.toggleAttribute('disabled', Boolean(value)); + } +} + +customElements.define('ot-file-drop', OtFileDrop); diff --git a/src/js/index.js b/src/js/index.js index 0b2975b..544ee66 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -1,6 +1,7 @@ import './base.js'; import './tabs.js'; import './dropdown.js'; +import './file-drop.js'; import './tooltip.js'; import './sidebar.js'; import { toast, toastEl, toastClear } from './toast.js'; From 2e361308fca2b28b17a7bcc903080c1d038e1939 Mon Sep 17 00:00:00 2001 From: Steve Perkins Date: Fri, 24 Apr 2026 12:57:48 -0400 Subject: [PATCH 2/2] Add file-drop to docs + demo screen. The docs script tag doesn't run on the docs page... which is fine. The demo.html is able to render the dropped files. --- docs/content/components/file-drop.md | 32 +++++++++++++++ docs/templates/demo.html | 58 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 docs/content/components/file-drop.md diff --git a/docs/content/components/file-drop.md b/docs/content/components/file-drop.md new file mode 100644 index 0000000..cff9517 --- /dev/null +++ b/docs/content/components/file-drop.md @@ -0,0 +1,32 @@ ++++ +title = "File Drop" +weight = 85 +description = "Dropzone-style file picker with drag and drop support." + +[extra] +webcomponent = true ++++ + +Use `` to let users drag files onto a target or click it to open the native file picker. Add `multiple` to accept more than one file and `accept` to restrict MIME types or extensions, just like ``. + +{% demo() %} + +```html + + + Drop images here + or click to browse PNG, JPG, GIF, or WebP files + + + + +``` + +{% end %} + +The component dispatches `ot-file-drop` with `{ files, accepted, rejected }` whenever files are selected or dropped. diff --git a/docs/templates/demo.html b/docs/templates/demo.html index e82b66d..d49d537 100644 --- a/docs/templates/demo.html +++ b/docs/templates/demo.html @@ -541,6 +541,30 @@

Account Settings

+
+ + + + Drop images or PDFs here + or click to browse multiple files + + + No files selected. + +
+