Last active
June 6, 2024 16:41
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<%= 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">…</span> | |
</p> | |
</li> | |
</template> | |
</ul> | |
</section> | |
</div> | |
<hr class="mt-8 pb-8"> | |
<%= form.submit %> | |
<% end %> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
class Model < ApplicationRecord | |
has_many_attached :files, dependent: :destroy | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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…</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