Reactivity:** The component observes specific attributes to allow dynamic updates if the preview source changes via Turbo streams.
Implementation
Create the component in app/javascript/components/media_dropzone.js.
class MediaDropzone extends HTMLElement {
static get observedAttributes() {
return ['data-src', 'form'];
}
#previewEl: HTMLImageElement | null = null;
#fileInput: HTMLInputElement | null = null;
#currentObjectUrl: string | null = null;
connectedCallback() {
this.#initializeStructure();
this.#bindEvents();
this.#loadInitialPreview();
}
disconnectedCallback() {
this.#revokeObjectUrl();
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name === 'data-src' && oldValue !== newValue) {
this.#loadInitialPreview();
}
}
#initializeStructure() {
// Create hidden file input
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.hidden = true;
const name = this.getAttribute('name');
if (!name) {
console.warn('media-dropzone: "name" attribute is required for form submission.');
return;
}
input.name = name;
// Inject input into the associated form
const formId = this.getAttribute('form');
const form = this.closest('form') ||
(formId ? document.getElementById(formId) : null);
if (form) {
form.appendChild(input);
} else {
this.appendChild(input);
}
this.#fileInput = input;
// Create preview container if needed
if (this.hasAttribute('data-src')) {
const img = document.createElement('img');
img.className = 'media-dropzone__preview';
this.prepend(img);
this.#previewEl = img;
}
}
#bindEvents() {
if (!this.#fileInput) return;
// File selection
this.#fileInput.addEventListener('change', (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) this.#handleFile(file);
});
// Drag and Drop
this.addEventListener('dragover', (e) => {
e.preventDefault();
this.classList.add('is-dragging');
});
this.addEventListener('dragleave', (e) => {
e.preventDefault();
this.classList.remove('is-dragging');
});
this.addEventListener('drop', (e) => {
e.preventDefault();
this.classList.remove('is-dragging');
const files = e.dataTransfer?.files;
if (files?.length) {
const imageFile = Array.from(files).find(f => f.type.startsWith('image/'));
if (imageFile) this.#handleFile(imageFile);
}
});
// Click to upload
this.addEventListener('click', () => {
this.#fileInput?.click();
});
// Remove trigger
const removeBtn = this.querySelector('[data-action-remove]');
if (removeBtn) {
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.#clearUpload();
});
}
}
#handleFile(file: File) {
// Validate MIME type strictly
if (!file.type.startsWith('image/')) {
console.error('media-dropzone: Invalid file type.');
return;
}
this.#revokeObjectUrl();
const url = URL.createObjectURL(file);
this.#currentObjectUrl = url;
if (this.#previewEl) {
this.#previewEl.src = url;
this.#previewEl.style.display = 'block';
}
// Trigger the input change manually to sync state
// This ensures Rails form helpers recognize the change
this.#fileInput?.dispatchEvent(new Event('change', { bubbles: true }));
}
#loadInitialPreview() {
const src = this.getAttribute('data-src');
if (src && this.#previewEl) {
this.#previewEl.src = src;
this.#previewEl.style.display = 'block';
}
}
#clearUpload() {
if (this.#fileInput) {
this.#fileInput.value = '';
}
this.#revokeObjectUrl();
this.#currentObjectUrl = null;
if (this.#previewEl) {
this.#previewEl.removeAttribute('src');
this.#previewEl.style.display = 'none';
}
}
#revokeObjectUrl() {
if (this.#currentObjectUrl) {
URL.revokeObjectURL(this.#currentObjectUrl);
this.#currentObjectUrl = null;
}
}
}
customElements.define('media-dropzone', MediaDropzone);
export default MediaDropzone;
Usage in Rails Views
The component integrates directly into ERB templates. No data attributes for controllers are needed.
Inline Form Usage:
<%= form_with(model: @user) do |f| %>
<div class="field">
<%= f.label :avatar %>
<media-dropzone
name="user[avatar]"
data-src="<%= url_for(@user.avatar) if @user.avatar.attached? %>">
<button type="button" data-action-remove>Remove</button>
</media-dropzone>
</div>
<%= f.submit %>
<% end %>
External Form Usage:
For uploads outside the form tag, use the form attribute to associate the component with the form ID.
<media-dropzone
name="user[header]"
form="user_profile_form"
data-src="<%= url_for(@user.header) if @user.header.attached? %>">
<button type="button" data-action-remove>Remove</button>
</media-dropzone>
<%= form_with(model: @user, id: "user_profile_form") do |f| %>
<!-- Other fields -->
<% end %>
Pitfall Guide
-
Turbo Cache Persistence
- Issue: Turbo caches the DOM state. When navigating back,
connectedCallback may fire on a cached element that already has listeners or state, leading to duplicate inputs or event handlers.
- Fix: Implement
disconnectedCallback to clean up resources. Ensure connectedCallback is idempotent by checking for existing state before re-initializing. The provided implementation uses private fields and checks, but for complex state, consider resetting the component in turbo:before-cache events.
-
Form Association Edge Cases
- Issue: If the component is placed outside a form and no
form attribute is provided, the file input becomes orphaned and won't submit.
- Fix: The component includes a fallback to append the input to itself, but this breaks form submission. Always verify form association. Add a console warning if no form is found, as implemented in
#initializeStructure.
-
MIME Type Validation Bypass
- Issue: Users can rename malicious files to
.jpg to bypass client-side checks.
- Fix: Never trust the file extension. The component checks
file.type via the File API. For production security, always validate the file content (magic bytes) on the server side using Active Storage analyzers or similar tools.
-
Memory Leaks with Object URLs
- Issue:
URL.createObjectURL creates references that persist until revoked. Creating many previews without revoking can exhaust memory.
- Fix: The component tracks
#currentObjectUrl and revokes it in #clearUpload, #handleFile (before creating a new one), and disconnectedCallback. This ensures garbage collection.
-
Accessibility Gaps
- Issue: Custom elements are not natively focusable or actionable by screen readers.
- Fix: Add
role="button", tabindex="0", and aria-label to the component in CSS or JS. Ensure keyboard users can trigger the file dialog via Enter/Space. The click listener should also handle keydown events for accessibility compliance.
-
Rails has_one Removal Behavior
- Issue: Submitting an empty file input for a
has_one_attached association may not remove the existing attachment depending on Rails version and configuration.
- Fix: Ensure the input value is cleared (
input.value = ''). In Rails, submitting a blank file input typically signals removal for has_one associations. Verify this behavior in your specific Rails version and add a hidden checkbox for explicit removal if necessary.
-
Event Propagation Conflicts
- Issue: Click events on the remove button might bubble up and trigger the file dialog.
- Fix: The remove button listener calls
e.stopPropagation() to prevent the parent click handler from firing. This is critical for UX.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Avatar Upload | <media-dropzone> Custom Element | Zero boilerplate, semantic HTML, instant preview. | Low dev time, high maintainability. |
| Multi-File Gallery | Stimulus Controller + Library | Custom elements are best for single widgets; galleries require complex state management. | Higher dev time, but necessary for complexity. |
| External Upload (Header) | <media-dropzone> with form attr | Handles external form association cleanly without JS wiring. | Low dev time, robust form integration. |
| Legacy Browser Support | Stimulus + Polyfills | Custom elements require modern browsers; polyfills add bundle size. | Higher bundle cost, broader compatibility. |
Configuration Template
CSS Theming:
Use CSS custom properties to theme the dropzone without modifying component code.
media-dropzone {
--mdz-border-color: #cbd5e1;
--mdz-border-active: #3b82f6;
--mdz-bg-hover: #f8fafc;
--mdz-preview-size: 150px;
display: inline-block;
border: 2px dashed var(--mdz-border-color);
border-radius: 0.5rem;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
media-dropzone.is-dragging {
border-color: var(--mdz-border-active);
background-color: var(--mdz-bg-hover);
}
media-dropzone .media-dropzone__preview {
width: var(--mdz-preview-size);
height: var(--mdz-preview-size);
object-fit: cover;
border-radius: 0.25rem;
display: none;
}
media-dropzone [data-action-remove] {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #ef4444;
}
HTML Snippet:
<media-dropzone
name="product[cover_image]"
data-src="/assets/placeholder.jpg"
role="button"
tabindex="0"
aria-label="Upload cover image">
<span class="dropzone-label">Drag & drop or click to upload</span>
<button type="button" data-action-remove>Remove Image</button>
</media-dropzone>
Quick Start Guide
- Create Component: Save the TypeScript code to
app/javascript/components/media_dropzone.js.
- Register Element: Import and register the element in your entry point (e.g.,
app/javascript/application.js).
import MediaDropzone from './components/media_dropzone';
// Element is auto-registered via customElements.define
- Add Styles: Include the CSS template in your stylesheet or Tailwind config.
- Drop in View: Insert
<media-dropzone name="model[attribute]"> into your form. Add data-src for existing images.
- Verify: Check that the hidden input is injected into the form and that drag-and-drop triggers the preview.