File Dropzone
stimeo--file-dropzone
Pick files by click, keyboard, or drag-and-drop, with image previews.
The stimeo--file-dropzone controller bridges a native file input and a drop zone. The trigger button opens the native dialog (keyboard-operable); dragging files over the zone adds them, with a data-dragover flag and a spoken hint (drag state is conveyed in words, not color alone). Each file is validated against accept, maxSize, and the file count (maxFiles, or 1 when the input is not multiple); rejects fire stimeo--file-dropzone:reject and set a data-…-invalid flag. Accepted files render from a template with a Remove {name} button and an image thumbnail (objectURL); every change dispatches stimeo--file-dropzone:change with the current File list. Removing a file revokes its objectURL and re-homes focus to a neighbor, else the trigger; disconnect revokes every outstanding objectURL. Upload transport stays with your app.
Keyboard
| Key | Action |
|---|---|
| Enter / Space | Open the native file dialog from the trigger button. |
<%# Markup for the file-dropzone (file drag & drop / image preview) demo.
Click or keyboard launches the native file input, and drag & drop also adds files.
It validates accept / maxSize / count and shows image thumbnails via objectURL.
The library handles the drop bridge, validation, preview create/revoke, focus
handoff on removal, and live announcements. Drag & drop is only an aid — selection
is always possible via click / keyboard. %>
<div class="file-dropzone" data-controller="stimeo--file-dropzone"
data-stimeo--file-dropzone-max-size-value="5242880"
data-stimeo--file-dropzone-max-files-value="4"
data-stimeo--file-dropzone-drag-label-value="<%= t(
'components.file_dropzone.demo.drag_label'
) %>">
<div
class="file-dropzone__zone"
data-stimeo--file-dropzone-target="zone"
data-action="dragover->stimeo--file-dropzone#onDragOver
dragleave->stimeo--file-dropzone#onDragLeave
drop->stimeo--file-dropzone#onDrop">
<button
type="button"
class="file-dropzone__trigger"
data-stimeo--file-dropzone-target="trigger"
data-action="click->stimeo--file-dropzone#openDialog">
<%= t("components.file_dropzone.demo.trigger") %>
</button>
<input
type="file"
accept="image/*"
multiple
aria-label="<%= t('components.file_dropzone.demo.input_label') %>"
class="file-dropzone__input visually-hidden"
data-stimeo--file-dropzone-target="input"
data-action="change->stimeo--file-dropzone#onChange" />
</div>
<ul
class="file-dropzone__list"
aria-label="<%= t('components.file_dropzone.demo.list_label') %>"
data-stimeo--file-dropzone-target="list"></ul>
<%# Demo-only: the library accepts only images within the limits (5 MB / 4 files) and
fires stimeo--file-dropzone:reject for the rest — accepted images get a thumbnail
preview. Surface the reason visibly so a rejected file (e.g. an over-size photo) is
not mistaken for a broken preview. demo.js fills this from the reject event; the
reason copy is localized here. %>
<p class="file-dropzone__error"
data-file-dropzone-error
hidden
data-reason-type="<%= t('components.file_dropzone.demo.reject_type') %>"
data-reason-size="<%= t('components.file_dropzone.demo.reject_size') %>"
data-reason-count="<%= t('components.file_dropzone.demo.reject_count') %>"></p>
<span
role="status"
aria-live="polite"
class="file-dropzone__status visually-hidden"
data-stimeo--file-dropzone-target="status"></span>
<template data-stimeo--file-dropzone-target="itemTemplate">
<li class="file-dropzone__item" data-stimeo--file-dropzone-target="item">
<img class="file-dropzone__thumb" data-file-dropzone-slot="thumb" alt="" hidden />
<span class="file-dropzone__name" data-file-dropzone-slot="name"></span>
<%# Removal is handled by a delegated listener on the list container, so it
works instantly without waiting on Stimulus wiring a data-action onto the
dynamically-added button. %>
<button
type="button"
class="file-dropzone__remove">
<%= t("components.file_dropzone.demo.remove") %>
</button>
</li>
</template>
</div>
/*
* Presentation-only styles for the file-dropzone demo.
* The library toggles the zone's data-dragover for drag state and
* data-stimeo--file-dropzone-invalid for a rejected validation (announced in a
* live region too, never via color alone). Creating/revoking previews and moving
* focus on removal are also the library's responsibility.
*/
.file-dropzone {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 30rem;
}
.file-dropzone__zone {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
border: 2px dashed var(--border-strong);
border-radius: 0.625rem;
background: var(--surface-subtle);
text-align: center;
}
.file-dropzone__zone[data-dragover] {
border-color: var(--accent, var(--color-primary));
background: var(--color-primary-soft);
}
.file-dropzone__zone[data-stimeo--file-dropzone-invalid] {
border-color: var(--danger-500);
background: var(--danger-50);
}
.file-dropzone__trigger {
padding: 0.5rem 0.9rem;
border: 1px solid var(--border-strong);
border-radius: 0.375rem;
background: var(--surface, var(--surface-card));
color: var(--fg, var(--color-text));
font: inherit;
font-weight: 600;
cursor: pointer;
}
.file-dropzone__list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.file-dropzone__item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border-default);
border-radius: 0.375rem;
}
.file-dropzone__thumb {
width: 2.5rem;
height: 2.5rem;
object-fit: cover;
border-radius: 0.25rem;
}
.file-dropzone__name {
flex: 1;
font-size: 0.875rem;
color: var(--fg, var(--color-text));
word-break: break-all;
}
.file-dropzone__remove {
border: 0;
background: transparent;
color: var(--danger-500);
font: inherit;
cursor: pointer;
}
.file-dropzone__remove:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
border-radius: 0.25rem;
}
/* Visible reason for a rejected file (filled by demo.js from the reject event). */
.file-dropzone__error {
margin: 0;
font-size: 0.875rem;
color: var(--danger-500);
}
// Demo: the library validates every file against accept / maxSize / maxFiles and fires
// stimeo--file-dropzone:reject for the ones it turns away — accepted images get a
// thumbnail preview. Surface the reason both visibly and in the live status region, so a
// rejected file (e.g. an image over the 5 MB limit) is not mistaken for a broken preview.
// The reason copy is localized in the view; reason is one of type / size / count and is
// read straight off data-reason-<reason> (no dataset camelCase round-trip).
document.querySelectorAll('[data-controller~="stimeo--file-dropzone"]').forEach((root) => {
const error = root.querySelector("[data-file-dropzone-error]");
const status = root.querySelector('[data-stimeo--file-dropzone-target="status"]');
if (!error) return;
root.addEventListener("stimeo--file-dropzone:reject", (event) => {
const { file, reason } = event.detail;
const detail = error.getAttribute(`data-reason-${reason}`);
if (!detail) return;
const message = `${file.name}: ${detail}`;
error.textContent = message;
error.hidden = false;
// Mirror the reason into the live region so screen-reader users hear *why* a file was
// rejected, not just the file name the library announces there.
if (status) status.textContent = message;
});
// A successful add clears the last rejection notice.
root.addEventListener("stimeo--file-dropzone:change", () => {
error.hidden = true;
error.textContent = "";
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--file-dropzone"
Targets
| Name | Description | Attribute |
|---|---|---|
zone
|
The drop-target region; gets data-dragover while dragging and data-…-invalid on rejection. |
data-stimeo--file-dropzone-target="zone" |
trigger
required
|
Button that opens the native file dialog and serves as a focus fallback after removals. | data-stimeo--file-dropzone-target="trigger" |
input
required
|
The native <input type=file>, the primary keyboard-operable upload path. |
data-stimeo--file-dropzone-target="input" |
list
|
Container into which accepted-file preview items are rendered. | data-stimeo--file-dropzone-target="list" |
item
|
A rendered preview item for one accepted file. | data-stimeo--file-dropzone-target="item" |
itemTemplate
|
<template> cloned to build each file item (name, thumbnail, remove button). |
data-stimeo--file-dropzone-target="itemTemplate" |
status
|
Live region announcing drag hints, added files, and removals. | data-stimeo--file-dropzone-target="status" |
Values
| Name | Description | Attribute |
|---|---|---|
maxSize
|
Maximum allowed file size in bytes (0 = unlimited; default 0). | data-stimeo--file-dropzone-max-size-value |
maxFiles
|
Maximum number of files (0 = use the input's multiple/single rule; default 0). | data-stimeo--file-dropzone-max-files-value |
dragLabel
|
Spoken hint announced while dragging over the zone (default "Drop files to add them"). | data-stimeo--file-dropzone-drag-label-value |
Actions
| Name | Description | Action |
|---|---|---|
onChange
|
Adds the files chosen through the native dialog. | stimeo--file-dropzone#onChange |
onDragLeave
|
Clears the drag-over flag when the pointer leaves the zone. | stimeo--file-dropzone#onDragLeave |
onDragOver
|
Marks the zone as a drop target and announces the affordance in words. | stimeo--file-dropzone#onDragOver |
onDrop
|
Accepts the dropped files and clears the drag-over state. | stimeo--file-dropzone#onDrop |
openDialog
|
Opens the native file dialog. | stimeo--file-dropzone#openDialog |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires when the accepted file set changes; detail { files } (current File[]). |
stimeo--file-dropzone:change |
reject
|
Fires when a file is rejected; detail { file, reason } (type/size/count). |
stimeo--file-dropzone:reject |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-dragover |
Zone | Present while a drag is over the zone. |
data-stimeo--file-dropzone-invalid |
Zone | Present after a validation rejection. |
hidden |
Item thumbnail | Present for non-image files (no preview). |
text |
Status | Live-region notice of drag state, validation, and add/remove. |