Skip to content

Instantly share code, notes, and snippets.

@fractaledmind
Last active June 6, 2024 16:41
Show Gist options
  • Save fractaledmind/c820b83a1df183f1c0d433a5ffdd828a to your computer and use it in GitHub Desktop.
Save fractaledmind/c820b83a1df183f1c0d433a5ffdd828a to your computer and use it in GitHub Desktop.
Create a simple, beautiful Rails-integrated multi-file input that behaves like: https://codepen.io/smargh/pen/mdGLpEz. Uses TailwindCSS, StimulusJS, and ActiveStorage.
class Application < Rails::Application
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.0
# ensure that `update(files: [uploaded_file])` will append, not replace
config.active_storage.replace_on_assign_to_many = false
end
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ['controller', 'checkbox']
initialize () {
this.toggle = this.toggle.bind(this)
this.refresh = this.refresh.bind(this)
}
connect () {
if (!this.hasControllerTarget) return
this.controllerTarget.addEventListener('change', this.toggle)
this.checkboxTargets.forEach(checkbox => checkbox.addEventListener('change', this.refresh))
this.refresh()
}
disconnect () {
if (!this.hasCheckboxAllTarget) return
this.controllerTarget.removeEventListener('change', this.toggle)
this.checkboxTargets.forEach(checkbox => checkbox.removeEventListener('change', this.refresh))
}
toggle (e) {
e.preventDefault()
this.checkboxTargets.forEach(checkbox => {
checkbox.checked = this.controllerTarget.checked
this.triggerInputEvent(checkbox)
})
}
refresh () {
const checkboxesCount = this.checkboxTargets.length
const checkboxesCheckedCount = this.checked.length
this.controllerTarget.indeterminate = (checkboxesCheckedCount > 0 && checkboxesCheckedCount < checkboxesCount)
this.controllerTarget.checked = (checkboxesCheckedCount == checkboxesCount)
}
triggerInputEvent(checkbox) {
const event = document.createEvent('HTMLEvents')
event.initEvent('input', false, true)
checkbox.dispatchEvent(event)
}
get checked () {
return this.checkboxTargets.filter(checkbox => checkbox.checked)
}
get unchecked () {
return this.checkboxTargets.filter(checkbox => !checkbox.checked)
}
}
class ModelsController < ApplicationController
def create
@model = Model.new(model_params)
if @model.save
redirect_to @model
else
render :new, status: :unprocessable_entity
end
end
def update
if @model.update(model_params)
redirect_to @model
else
render :edit, status: :unprocessable_entity
end
end
private
def model_params
params
.require(:model)
.permit(
:field_one,
files_attachment_ids: [],
files: []
)
end
end
<%= form_with(model: model, class: "contents") do |form| %>
<div class="my-5"
data-controller="multiple-file-input"
<!-- Progressively enhance the page by hiding the <input type="file"> *only when* the Stimulus controller connects -->
data-multiple-file-input-supported-input-class="absolute opacity-0 inset-0">
<%= form.label :files, class: form_label_classes %>
<% files_error_messages = form.object.errors&.messages_for(:files) %>
<!-- Only show the "Saved uploads" when there are `files` already present (e.g. on #edit) -->
<% if form.object.files.present? %>
<section class="mb-4" data-controller="checkboxes">
<div class="flex items-center justify-between flex-wrap sm:flex-nowrap mb-2">
<h3 class="text-md">
Saved uploads
</h3>
<!-- The "parent" checkbox that controls the checkboxes bound to each file -->
<label for="<%= form.field_id(:attachments, :controller) %>"
class="cursor-pointer rounded bg-white py-1 px-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50">
<input type="checkbox"
id="<%= form.field_id(:attachments, :controller) %>"
checked="checked"
class="peer sr-only"
data-checkboxes-target="controller" />
<span class="peer-checked:hidden inline">Restore all</span>
<span class="peer-checked:inline hidden">Delete all</span>
</label>
</div>
<ul role="list" class="grid grid-cols-2 gap-x-2 gap-y-4 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
<%= form.collection_check_boxes(:files_attachment_ids, form.object.files_attachments.includes(:blob), :id, :filename) do |builder| %>
<li class="relative flex flex-col items-center text-center border rounded select-none overflow-hidden border-red-500 bg-red-100 [&:has(:checked)]:bg-white [&:has(:checked)]:border-gray-200"
data-multiple-file-input-target="persisted">
<%= builder.check_box(class: "peer sr-only", data: { checkboxes_target: "checkbox" }) %>
<%= builder.label(class: "group hidden peer-checked:flex items-center gap-1 cursor-pointer hover:text-red-700 text-gray-700 absolute top-0 right-0 z-50 p-1 bg-white rounded-bl focus:outline-none") do %>
<span class="text-sm opacity-0 w-0 group-hover:w-auto group-hover:opacity-100">Delete</span>
<svg class="w-4 h-4 bi bi-trash3" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
</svg>
<% end %>
<%= builder.label(class: "group flex peer-checked:hidden items-center gap-1 cursor-pointer hover:text-blue-700 text-gray-700 absolute top-0 right-0 z-50 p-1 bg-white rounded-bl focus:outline-none") do %>
<span class="text-sm opacity-0 w-0 group-hover:w-auto group-hover:opacity-100">Restore</span>
<svg class="w-4 h-4 bi bi-arrow-clockwise" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2v1z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466z"/>
</svg>
<% end %>
<div class="aspect-h-7 aspect-w-10 w-full opacity-50 peer-checked:opacity-100">
<%= image_tag(builder.object, class: "border-4 border-white pointer-events-none object-contain") %>
</div>
<p class="w-full flex flex-col items-center bg-gray-100 text-gray-900 px-2 py-1 line-through peer-checked:no-underline">
<span class="w-full font-bold truncate" data-target="name">
<%= builder.text %>
</span>
<span class="text-xs" data-target="size">
<%= number_to_human_size builder.object.byte_size %>
</span>
</p>
</li>
<% end %>
</ul>
</section>
<% end %>
<div class="relative mb-2">
<%= form.label :files, class: "flex flex-col justify-center gap-1 text-center text-gray-600 rounded border border-gray-300 border-dashed p-6 mb-2" do %>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="mx-auto h-12 w-12 fill-current text-gray-400 bi bi-image" viewBox="0 0 16 16">
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
</svg>
<p class="">
Drag and drop your files or
<span class="underline cursor-pointer hover:text-black">
Browse
</span>
</p>
<p class="text-sm text-gray-500">
PNG, JPG, GIF, or MP4 up to 25MB each
</p>
<% end %>
<%= form.file_field :files,
multiple: true,
accept: "image/*, video/*",
class: "appearance-none w-full text-base font-normal text-ellipsis whitespace-pre overflow-hidden bg-gray-100 py-1 px-3 rounded",
aria: {
invalid: files_error_messages.any?,
describedby: files_error_messages.any? ? form.field_id(:files, :error) : nil
},
data: {
multiple_file_input_target: "input",
action: "multiple-file-input#previewInGallery"
},
"max-files-count": 10,
"max-file-size": "25MB" %>
</div>
<!-- Put the ERB <% if %> right next to the HTML tag to ensure `empty:` Tailwind classes work -->
<ul class="bg-red-200 text-red-800 rounded px-2 py-1 mb-2 empty:p-0"
data-multiple-file-input-target="notice"><% if files_error_messages.any? %>
<li class="" id="<%= form.field_id(:files, :error) %>">
<%= PurchaseReport.human_attribute_name(:files) %>
<%= files_error_messages.to_sentence %>
</li>
<% end %></ul>
<section class="hidden" data-multiple-file-input-target="gallerySection">
<div class="flex items-center justify-between flex-wrap sm:flex-nowrap mb-2">
<h3 class="text-md leading-6 font-medium text-gray-900">
Unsaved uploads
</h3>
<button type="button" class="cursor-pointer rounded bg-white py-1 px-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" data-action="multiple-file-input#removeAll">
Remove all
</button>
</div>
<ul role="list" class="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-4 sm:gap-x-6 lg:grid-cols-6 xl:gap-x-8" data-multiple-file-input-target="gallery">
<template data-multiple-file-input-target="template">
<li class="relative flex flex-col items-center text-center border rounded select-none overflow-hidden bg-white border-dashed" data-multiple-file-input-target="unpersisted">
<button type="button" class="group flex items-center gap-1 cursor-pointer hover:text-red-700 text-gray-700 absolute top-0 right-0 z-50 p-1 bg-white rounded-bl" data-action="multiple-file-input#removeFile">
<span class="text-sm opacity-0 w-0 group-hover:w-auto group-hover:opacity-100">Remove</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-4 h-4 bi bi-x-lg" viewBox="0 0 16 16">
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8 2.146 2.854Z"/>
</svg>
</button>
<div class="aspect-h-7 aspect-w-10 w-full">
<img alt="" class="border-4 border-white pointer-events-none object-contain">
</div>
<p class="w-full flex flex-col items-center bg-gray-100 text-gray-900 px-2 py-1">
<span class="w-full font-bold truncate" data-target="name">Loading</span>
<span class="text-xs" data-target="size">&hellip;</span>
</p>
</li>
</template>
</ul>
</section>
</div>
<hr class="mt-8 pb-8">
<%= form.submit %>
<% end %>
class Model < ApplicationRecord
has_many_attached :files, dependent: :destroy
end
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "template", "gallery", "input", "persisted", "unpersisted", "notice", "gallerySection" ]
static classes = [ "supportedInput" ]
connect() {
this.fileSizeBase = 1000
this.inputTarget.classList.add(...this.supportedInputClasses)
}
previewInGallery() {
if (this.inputTarget.files) {
let inputFiles = this.inputTarget.files
this.unpersistedTargets.forEach(n => n.remove())
this.notices = {}
this.noticeTarget.innerHTML = ''
if (!this._validateFiles(inputFiles)) {
this._showNoticeForFiles()
return false
}
Array.from(inputFiles).forEach(file => {
if (this._validateFile(file)) {
this.gallerySectionTarget.classList.remove('hidden')
this._addFile(this.galleryTarget, file)
} else {
this._showNoticeForFile(file)
}
})
}
}
removeFile(event){
const target = this.unpersistedTargets.find(li => li.contains(event.currentTarget))
const index = this.unpersistedTargets.indexOf(target)
const attachments = this.inputTarget.files
let fileBuffer = new DataTransfer()
// append the file list to an array iteratively
for (let i = 0; i < attachments.length; i++) {
if (index !== i)
fileBuffer.items.add(attachments[i])
}
// Assign buffer to file input
this.inputTarget.files = fileBuffer.files
target.remove()
}
removeAll() {
var fileFieldHTML = this.inputTarget.outerHTML
var parent = this.inputTarget.parentElement
this.inputTarget.remove()
parent.insertAdjacentHTML('afterbegin', fileFieldHTML)
this.unpersistedTargets.forEach(n => n.remove())
this.gallerySectionTarget.classList.add('hidden')
}
_validateFiles(files) {
const validFilesTotalCount = this._validateFilesTotalCount(files)
return validFilesTotalCount
}
_validateFile(file) {
const validFileSize = this._validateFileSize(file)
const validFileType = this._validateFileType(file)
return validFileSize && validFileType
}
_validateFileType(file) {
const acceptedTypes = this.inputTarget.accept.replace(/\s/g, '').split(',')
const validations = acceptedTypes.map(type => new RegExp(type.replace('*', '.*')))
const isValid = validations.some(validation => validation.test(file.type))
if (isValid) {
return true
} else {
this.notices[file.name] ||= []
this.notices[file.name].push(`"${file.type}" is not an allowed type`)
return false
}
}
_validateFileSize(file) {
const maxFileSize = this.inputTarget.getAttribute('max-file-size')
if (!maxFileSize) return true
const base = this.fileSizeBase
const toInt = value => parseInt(value, 10)
const maxFileSizeNatural = maxFileSize.trim()
let maxFileSizeString = maxFileSizeNatural
let maxFileSizeBytes = null
if (/MB$/i.test(maxFileSizeString)) {
maxFileSizeString = maxFileSizeString.replace(/MB$i/, '').trim()
maxFileSizeBytes = toInt(maxFileSizeString) * base * base
} else if (/KB$/i.test(maxFileSizeString)) {
maxFileSizeString = maxFileSizeString.replace(/KB$i/, '').trim()
maxFileSizeBytes = toInt(maxFileSizeString) * base
} else {
maxFileSizeBytes = toInt(maxFileSizeString)
}
const isValid = maxFileSizeBytes !== null && file.size <= maxFileSizeBytes
if (isValid) {
return true
} else {
this.notices[file.name] ||= []
this.notices[file.name].push(`${this._humanFileSize(file.size)} is larger than the ${maxFileSizeNatural} allowed`)
return false
}
}
_validateFilesTotalCount(files) {
const maxFilesCount = this.inputTarget.getAttribute('max-files-count')
if (!maxFilesCount) return true
const filesCount = files.length
const isValid = filesCount <= maxFilesCount
if (isValid) {
return true
} else {
this.notices["files"] ||= []
this.notices["files"].push(`${filesCount} is more than the ${maxFilesCount} allowed`)
return false
}
}
_addFile(target, file) {
const objectURL = URL.createObjectURL(file)
const clone = this.templateTarget.content.cloneNode(true)
const nameElement = clone.querySelector("[data-target='name']")
const sizeElement = clone.querySelector("[data-target='size']")
nameElement.textContent = file.name
nameElement.title = file.name
sizeElement.textContent = this._humanFileSize(file.size)
Object.assign(clone.querySelector("img"), {
src: objectURL,
alt: file.name
})
target.append(clone)
}
_showNoticeForFiles() {
const li = document.createElement('li')
li.innerHTML = `
<code>Please try again&hellip;</code>
<ul class="list-disc ml-8">
${this.notices["files"].map(notice => `<li>${notice}</li>`).join('')}
</ul>
`
this.noticeTarget.appendChild(li)
}
_showNoticeForFile(file) {
const li = document.createElement('li')
li.innerHTML = `
<code>${file.name}</code>
<ul class="list-disc ml-8">
${this.notices[file.name].map(notice => `<li>${notice}</li>`).join('')}
</ul>
`
this.noticeTarget.appendChild(li)
}
_humanFileSize(size) {
const base = this.fileSizeBase
const i = size == 0 ? 0 : Math.floor(Math.log(size) / Math.log(base))
const suffix = ['B', 'KB', 'MB', 'GB'][i]
const integer = Math.round(size / Math.pow(base, i))
return integer + ' ' + suffix
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment