DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com on

Reusable drag-and-drop image preview in Rails

Custom elements have been covered here berfore. If you have used Hotwire in Rails, you have already used them. Both <turbo-frame> and <turbo-stream> are custom elements. They are just HTML tags with JavaScript behavior attached.

This article walks through building a drag-and-drop image upload custom element that works great in Rails forms. Starting with a simple avatar and ending with a reusable component that handles both inline and external forms. The code is, as usual, available on GitHub.

So first, why not use a regular file input or a Stimulus controller? The answer is that custom elements are perfect for self-contained components. They work anywhere in your HTML without needing to wire up data attributes or controller targets. Drop the tag in your view and it works. No configuration. No boilerplate.

With Stimulus you write:

<div data-controller="image-upload">
  <input type="file" data-image-upload-target="input" />

  <button data-action="click->image-upload#remove">Remove</button>
</div>

Enter fullscreen mode Exit fullscreen mode

With the custom element I want to explore today, you write:

<image-upload name="user[avatar]" data-preview-image-url="...">
  <button type="button" data-remove-image>Remove</button>
</image-upload>

Enter fullscreen mode Exit fullscreen mode

The component creates its own file input. It finds the form automatically. It handles drag-and-drop, preview rendering and removal. All wrapped in a clean, semantic HTML tag.

Lets get started, by creating the custom element in app/javascript/components/image-uploads.js:

class ImageUpload extends HTMLElement {
  #img = null;
  #input = null;

  connectedCallback() {
    this.#img = this.#image();
    this.#input = this.#fileInput();

    this.#load(this.#img);
    this.#dragAndDropListeners();
    this.#removeButtonListener();
    this.#clickToSelectListener();
  }
}

customElements.define('image-upload', ImageUpload);

Enter fullscreen mode Exit fullscreen mode

The connectedCallback runs when the element is added to the page. It sets up the image element, creates the file input, loads any existing image and wires up all the event listeners.

The component creates an <img> tag if one does not already exist (only if the data-preview-image-url attribute is present):

#image() {
  if (!this.hasAttribute('data-preview-image-url')) return null;

  const img = document.createElement('img');
  this.insertBefore(img, this.firstChild);

  return img;
}

Enter fullscreen mode Exit fullscreen mode

This means you do not need to add an <img> tag to your HTML. If you want to show an existing image, just add the data-preview-image-url attribute with the image URL.

Now it gets interesting. The component creates a hidden file input and appends it to the form , not to the component itself:

#fileInput() {
  const name = this.getAttribute('name');

  if (!name) {
    console.error('image-upload requires a "name" attribute');
    return null;
  }

  const input = document.createElement('input');
  input.type = 'file';
  input.name = name;
  input.accept = 'image/*';
  input.style.display = 'none';

  const formId = this.getAttribute('form');
  const form = formId ? document.getElementById(formId) : document.querySelector('form');

  if (form) {
    form.appendChild(input);
  } else {
    this.appendChild(input);
  }

  input.addEventListener('change', event => this.#fileSelected(event));

  return input;
}

Enter fullscreen mode Exit fullscreen mode

This is crucial. The file input must be part of the form for Rails to receive it in the params. The component looks for a form in two ways: if a form attribute is present, it uses that ID to find the form. Otherwise, it finds the first form in the DOM. Use it inside a form (it finds the form automatically) or outside a form (pass the form ID via the form attribute).

When a file is selected (either via click or drag-and-drop), the component renders a preview:

#fileSelected(event) {
  const imageFile = event.target.files[0];

  if (imageFile) this.#process(imageFile);
}

#process(file) {
  if (this.#img) this.#render(file);
}

#render(file) {
  const reader = new FileReader();

  reader.onload = (event) => {
    this.#img.src = event.target.result;
  };

  reader.readAsDataURL(file);
}

Enter fullscreen mode Exit fullscreen mode

The FileReader converts the file to a data URL which is displayed in the image element. The user sees the preview immediately.

The component listens for drag-and-drop events and adds a visual indicator:

#dragAndDropListeners() {
  this.addEventListener('dragover', event => this.#dragOver(event));
  this.addEventListener('dragleave', event => this.#dragLeave(event));
  this.addEventListener('drop', event => this.#drop(event));
}

#dragOver = (event) => {
  event.preventDefault();
  event.stopPropagation();

  this.setAttribute('data-drag-active', '');
}

#dragLeave = (event) => {
  event.preventDefault();
  event.stopPropagation();

  this.removeAttribute('data-drag-active');
}

#drop = (event) => {
  event.preventDefault();
  event.stopPropagation();

  this.removeAttribute('data-drag-active');

  const files = event.dataTransfer.files;
  const imageFile = Array.from(files).find(file => file.type.startsWith('image/'));

  if (imageFile) this.#process(imageFile);
}

Enter fullscreen mode Exit fullscreen mode

When the user drags a file over the component, it sets a data-drag-active attribute. Style this with CSS to show visual feedback like a blue border or background color.

The component finds a button with data-remove-image and wires it up:

#removeButtonListener() {
  const removeButton = this.querySelector('[data-remove-image]');

  if (removeButton) removeButton.addEventListener('click', () => this.#remove());
}

#remove() {
  this.#input.value = '';

  if (this.#img) this.#img.removeAttribute('src');
}

Enter fullscreen mode Exit fullscreen mode

When clicked, it clears the file input and removes the image preview. For has_one_attached in Rails, submitting an empty file input removes the attachment.

Using the component

Here is the avatar example inside a form:

<%= form_with(model: user) do |form| %>
  <div>
    <%= form.label :avatar, style: "display: block" %>
    <image-upload name="user[avatar]" data-preview-image-url="<%= url_for(@user.avatar) if @user.avatar.attached? %>">
      <button type="button" data-remove-image>Remove</button>
    </image-upload>
  </div>

  <div>
    <%= form.label :email_address, style: "display: block" %>
    <%= form.text_field :email_address %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>

Enter fullscreen mode Exit fullscreen mode

And here is the header example outside the form:

<image-upload name="user[header]" form="user_form" data-preview-image-url="<%= url_for(@user.header) if @user.header.attached? %>">
  <button type="button" data-remove-image>Remove</button>
</image-upload>

<%= form_with(model: user, id: "user_form") do |form| %>
  <!-- form fields -->
<% end %>

Enter fullscreen mode Exit fullscreen mode

The form="user_form" attribute associates the component with the form by ID. The file input gets appended to that form and submits with it.

Custom elements work anywhere in your HTML without needing to wire up data attributes or controller targets. I’ve been enjoying custom elements more and more and for really contained examples like this one, or for static sites built with Perron I think they are wonderful tool to have.

Top comments (0)