File Dropzone
stimeo--file-dropzone
Pick files three ways: the button, the keyboard, or dropping them in. Images preview first.
Connects an ordinary file input with an area you can drop onto. The button opens the standard file chooser, so the keyboard alone is enough. Holding files over the area says so in words rather than by colour alone. Each file is checked for type, size, duplication and count, in that order, and you are told the one reason it was turned away. Accepted files are laid out from your template, so the look is yours, and images can be seen before sending. The result is written back into the file input, so an ordinary form submission carries them. Adding, rejecting and removing all reach screen readers. Removing hands focus to the neighbouring file, so the keyboard never loses its place.
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 / duplicates / count and shows image thumbnails via
objectURL. The library handles the drop bridge, validation, preview create/revoke,
focus handoff on removal, mirroring the accepted files back onto the native input,
and the announcements read by the shared stimeo--announcer your app seats once,
in its layout (additions, rejections, removals, and the drag affordance are the
transitions worth reading; the dropzone owns no live region of its own).
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-announce-drag-text-value="<%= t(
'components.file_dropzone.demo.drag_label'
) %>"
data-stimeo--file-dropzone-announce-added-text-value="<%= t(
'components.file_dropzone.demo.announce_added'
) %>"
data-stimeo--file-dropzone-announce-removed-text-value="<%= t(
'components.file_dropzone.demo.announce_removed'
) %>"
data-stimeo--file-dropzone-announce-rejected-type-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_type'
) %>"
data-stimeo--file-dropzone-announce-rejected-size-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_size'
) %>"
data-stimeo--file-dropzone-announce-rejected-duplicate-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_duplicate'
) %>"
data-stimeo--file-dropzone-announce-rejected-count-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_count'
) %>">
<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="demo-trigger 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 unique images within the limits (5 MB / 4 files)
and fires stimeo--file-dropzone:reject for the rest — accepted images get a thumbnail
preview. The reason is already spoken by the shared announcer; this repeats it on
screen 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-duplicate="<%= t('components.file_dropzone.demo.reject_duplicate') %>"
data-reason-count="<%= t('components.file_dropzone.demo.reject_count') %>"></p>
<template data-stimeo--file-dropzone-target="itemTemplate">
<li class="file-dropzone__item" data-stimeo--file-dropzone-target="item">
<img class="file-dropzone__thumb" data-stimeo--file-dropzone-target="thumb" alt="" hidden />
<span class="file-dropzone__name" data-stimeo--file-dropzone-target="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. The library keeps this aria-label and only
expands {name}, so the accessible name stays in the page's language. %>
<button
type="button"
class="file-dropzone__remove"
aria-label="<%= t('components.file_dropzone.demo.remove_label') %>"
data-stimeo--file-dropzone-target="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 (both are also
* spoken through the shared announcer, never signalled 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);
}
/* Weightier than a plain launcher: it is the zone's own call to action. */
.file-dropzone__trigger {
font-weight: 600;
}
.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 / duplicates / maxFiles
// and fires stimeo--file-dropzone:reject for the ones it turns away — accepted images get a
// thumbnail preview. The reason is already spoken through the shared announcer (the
// announce-rejected-*-text values on the root); this repeats it on screen 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 / duplicate / count and is read
// straight off data-reason-<reason> (no dataset camelCase round-trip). A drop that
// mixes accepted and rejected files reports the accepted set first, so clearing on
// change never wipes the notice raised by that same drop.
document.querySelectorAll('[data-controller~="stimeo--file-dropzone"]').forEach((root) => {
const error = root.querySelector("[data-file-dropzone-error]");
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;
error.textContent = `${file.name}: ${detail}`;
error.hidden = false;
});
const clear = () => {
error.hidden = true;
error.textContent = "";
};
// A successful add clears the last rejection notice. So does the cache rewind:
// it reports the selection it discards as reconcile, and a notice about a file
// that is no longer listed must not ride into the Turbo snapshot.
root.addEventListener("stimeo--file-dropzone:change", clear);
root.addEventListener("stimeo--file-dropzone:reconcile", clear);
});
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; the accepted set is mirrored back onto it. |
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 (required inside the template). | data-stimeo--file-dropzone-target="item" |
itemTemplate
|
<template> cloned to build each file item. |
data-stimeo--file-dropzone-target="itemTemplate" |
name
|
Element the file name is written into (required inside the template). | data-stimeo--file-dropzone-target="name" |
thumb
|
<img> for the image thumbnail (optional; hidden for non-images). |
data-stimeo--file-dropzone-target="thumb" |
remove
|
<button> that removes its item (required inside the template, with a non-empty aria-label). |
data-stimeo--file-dropzone-target="remove" |
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 |
allowDuplicates
|
Set to true to allow the same file (name, size, and last-modified all matching) more than once (default false). | data-stimeo--file-dropzone-allow-duplicates-value |
announceDragText
|
Message sent to the shared announcer once as a drag enters the zone; expands {total} (empty by default = silent). |
data-stimeo--file-dropzone-announce-drag-text-value |
announceAddedText
|
Message sent on acceptance; expands {name} / {count} / {total} (empty by default = silent). |
data-stimeo--file-dropzone-announce-added-text-value |
announceRemovedText
|
Message sent on removal; expands {name} / {total} (empty by default = silent). |
data-stimeo--file-dropzone-announce-removed-text-value |
announceRejectedTypeText
|
Message sent when a file fails the accept filter; expands {name} / {count} / {total}. |
data-stimeo--file-dropzone-announce-rejected-type-text-value |
announceRejectedSizeText
|
Message sent when a file exceeds maxSize; same placeholders. |
data-stimeo--file-dropzone-announce-rejected-size-text-value |
announceRejectedDuplicateText
|
Message sent when a file is already selected; same placeholders. | data-stimeo--file-dropzone-announce-rejected-duplicate-text-value |
announceRejectedCountText
|
Message sent when the file count limit is reached; same placeholders. | data-stimeo--file-dropzone-announce-rejected-count-text-value |
Actions
| Name | Description | Action |
|---|---|---|
onChange
|
Adds the files chosen through the native dialog. | stimeo--file-dropzone#onChange |
onDragLeave
|
Clears the drag-over flag once the pointer leaves the zone. | stimeo--file-dropzone#onDragLeave |
onDragOver
|
Marks the zone as a drop target and announces the drag once. | 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/duplicate/count). |
stimeo--file-dropzone:reject |
reconcile
|
Fires when the Turbo cache rewind discards the selection; same detail shape as change (always empty). |
stimeo--file-dropzone:reconcile |
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, including over its own children. |
data-stimeo--file-dropzone-invalid |
Zone | Present after a validation rejection; cleared by the next batch. |
hidden |
Item thumbnail | Present for non-image files (no preview). |