@use 'sprucecss/scss/spruce' as *;
.dropzone {
@include layout-stack('s');
&__input {
@include visually-hidden;
}
&__area {
align-items: center;
background-color: color('surface-muted');
border: 2px dashed color('border-strong');
border-radius: radius('lg');
cursor: pointer;
display: flex;
flex-direction: column;
gap: spacer('xs');
padding: spacer('l') spacer('m');
text-align: center;
@include transition($property: 'background-color, border-color');
&:hover,
&.is-dragging {
background-color: color-alpha('primary', 0.06);
border-color: color('primary');
}
}
&__input:focus-visible + &__area {
outline: 2px solid color('focus-ring');
outline-offset: 2px;
}
&__icon {
block-size: 2.5rem;
color: color('primary');
inline-size: 2.5rem;
}
&__title {
color: color('heading');
strong {
color: color('primary');
}
}
&__hint {
color: color('muted');
font-size: config('font-size-sm', $typography);
}
&__files {
list-style: none;
padding: 0;
@include layout-stack('xs');
&:empty {
display: none;
}
}
&__file {
align-items: center;
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('md');
display: flex;
gap: spacer('s');
padding: spacer('xs') spacer('xs') spacer('xs') spacer('s');
}
&__name {
color: color('heading');
flex-grow: 1;
min-inline-size: 0;
@include text-ellipsis;
}
&__size {
color: color('muted');
flex-shrink: 0;
font-size: config('font-size-sm', $typography);
}
}
<div class="dropzone" data-dropzone>
<input class="dropzone__input" id="dropzone-input" type="file" multiple accept="image/*,.pdf" data-dropzone-input>
<label class="dropzone__area" for="dropzone-input" data-dropzone-area>
<svg class="dropzone__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m17 8-5-5-5 5"/><path d="M12 3v12"/></svg>
<span class="dropzone__title"><strong>Click to upload</strong> or drag and drop</span>
<span class="dropzone__hint" id="dropzone-hint">Images or PDF, up to 10 MB each</span>
</label>
<ul class="dropzone__files" role="list" aria-label="Selected files" data-dropzone-files></ul>
<p class="sr-only" aria-live="polite" data-dropzone-status></p>
</div>
(() => {
const formatSize = (bytes) => (bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`);
document.querySelectorAll('[data-dropzone]').forEach((dropzone) => {
const input = dropzone.querySelector('[data-dropzone-input]');
const area = dropzone.querySelector('[data-dropzone-area]');
const list = dropzone.querySelector('[data-dropzone-files]');
const status = dropzone.querySelector('[data-dropzone-status]');
const render = () => {
list.replaceChildren(...[...input.files].map((file, index) => {
const item = document.createElement('li');
const name = document.createElement('span');
const size = document.createElement('span');
const remove = document.createElement('button');
item.className = 'dropzone__file';
name.className = 'dropzone__name';
name.textContent = file.name;
size.className = 'dropzone__size';
size.textContent = formatSize(file.size);
remove.className = 'btn btn--ghost-danger btn--sm';
remove.type = 'button';
remove.textContent = 'Remove';
remove.setAttribute('aria-label', `Remove ${file.name}`);
remove.addEventListener('click', () => {
const transfer = new DataTransfer();
[...input.files].forEach((other, otherIndex) => otherIndex !== index && transfer.items.add(other));
input.files = transfer.files;
status.textContent = `${file.name} removed.`;
render();
input.focus();
});
item.append(name, size, remove);
return item;
}));
};
input.addEventListener('change', () => {
status.textContent = `${input.files.length} ${input.files.length === 1 ? 'file' : 'files'} selected.`;
render();
});
['dragenter', 'dragover'].forEach((type) => area.addEventListener(type, (event) => {
event.preventDefault();
area.classList.add('is-dragging');
}));
['dragleave', 'drop'].forEach((type) => area.addEventListener(type, () => area.classList.remove('is-dragging')));
area.addEventListener('drop', (event) => {
event.preventDefault();
const transfer = new DataTransfer();
[...input.files, ...event.dataTransfer.files].forEach((file) => transfer.items.add(file));
input.files = input.multiple ? transfer.files : event.dataTransfer.files;
input.dispatchEvent(new Event('change'));
});
});
})();