Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
32 changes: 32 additions & 0 deletions docs/content/components/file-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
+++
title = "File Drop"
weight = 85
description = "Dropzone-style file picker with drag and drop support."

[extra]
webcomponent = true
+++

Use `<ot-file-drop>` 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 `<input type="file">`.

{% demo() %}

```html
<ot-file-drop id="drop-demo" accept="image/*" multiple>
<span class="content">
<strong>Drop images here</strong>
<small>or click to browse PNG, JPG, GIF, or WebP files</small>
</span>
</ot-file-drop>

<script>
document.querySelector("#drop-demo").addEventListener("ot-file-drop", (e) => {
console.log("accepted", e.detail.files);
console.log("rejected", e.detail.rejected);
});
</script>
```

{% end %}

The component dispatches `ot-file-drop` with `{ files, accepted, rejected }` whenever files are selected or dropped.
58 changes: 58 additions & 0 deletions docs/templates/demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,30 @@ <h3>Account Settings</h3>
</div>
</div>

<div data-field>
<label for="settings-attachments">Attachments</label>
<ot-file-drop id="settings-attachments" accept="image/*,.pdf" multiple>
<span class="content">
<strong>Drop images or PDFs here</strong>
<small>or click to browse multiple files</small>
</span>
</ot-file-drop>
<small id="settings-attachments-output" data-hint>No files selected.</small>
<div class="table mt-4" id="settings-attachments-table" hidden>
<table>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>File type</th>
<th>Created at</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>

<label data-field>
Notification Volume
<input type="range" min="0" max="100" value="65">
Expand Down Expand Up @@ -612,6 +636,40 @@ <h5>Loading placeholder</h5>
// document.getElementById('theme-menu').hidePopover();
}
setDemoTheme(localStorage.getItem('demo-theme') || 'default');

document.getElementById('settings-attachments')?.addEventListener('ot-file-drop', e => {
const { files, rejected } = e.detail;
const out = document.getElementById('settings-attachments-output');
const table = document.getElementById('settings-attachments-table');
const tbody = table.querySelector('tbody');

out.textContent = files.length ? `${files.length} file(s) selected.` : 'No files selected.';
if (rejected.length) {
out.textContent += ` ${rejected.length} file(s) rejected.`;
}

tbody.replaceChildren(...files.map(file => {
const row = document.createElement('tr');
const createdAt = file.lastModified ? new Date(file.lastModified).toLocaleString() : 'Unknown';
const type = file.type || 'Unknown';
const size = new Intl.NumberFormat(undefined, {
style: 'unit',
unit: 'byte',
notation: 'compact',
unitDisplay: 'narrow'
}).format(file.size);

[file.name, size, type, createdAt].forEach(value => {
const cell = document.createElement('td');
cell.textContent = value;
row.appendChild(cell);
});

return row;
}));

table.hidden = files.length === 0;
});
</script>

{% endblock %}
66 changes: 66 additions & 0 deletions src/css/file-drop.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
213 changes: 213 additions & 0 deletions src/js/file-drop.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* oat - File Drop Component
* Drag files onto an area or click it to open the native file picker.
*
* Usage:
* <ot-file-drop accept="image/*" multiple>
* <strong>Drop images here</strong>
* <small>or click to browse</small>
* </ot-file-drop>
*
* 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 = '<strong>Drop files here</strong><small>or click to browse</small>';
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);
Loading