File Upload
Drag-and-drop file upload with a dropzone, browse button, file list with remove, and image previews. Submits via a real multipart file input.
Installation
bin/rails g wabi:add file_upload
bin/importmap pin @zag-js/file-upload @zag-js/vanillaPin @zag-js/file-upload and @zag-js/vanilla at 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only fetches the main entry and leaves submodules unresolved.
Example
Drag a file here, or
render Components::UI::FileUpload.new(name: "post[avatar]") do
render Components::UI::FileUploadDropzone.new do
plain "Drag a file here, or "
render Components::UI::FileUploadTrigger.new { "browse" }
end
render Components::UI::FileUploadList.new
end
Multiple / images
Pass max_files: to allow multiple selections (the field name gains [] automatically) and accept: to restrict by MIME type or extension.
Drag images here, or
render Components::UI::FileUpload.new(name: "post[images]", max_files: 5, accept: "image/*") do
render Components::UI::FileUploadDropzone.new do
plain "Drag images here, or "
render Components::UI::FileUploadTrigger.new { "browse" }
end
render Components::UI::FileUploadList.new
end
Source
app/components/ui/file_upload.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class FileUpload < Wabi::Base
def initialize(name: nil, accept: nil, max_files: 1, max_size: nil, disabled: false, **attrs)
@name = name
@accept = accept
@max_files = max_files
@max_size = max_size
@disabled = disabled
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
user_data = @attrs.delete(:data) || {}
multiple = @max_files > 1
field_name = @name && multiple && !@name.end_with?("[]") ? "#{@name}[]" : @name
root_data = {
controller: "wabi--file-upload",
"wabi--file-upload-name-value": field_name,
"wabi--file-upload-accept-value": @accept.to_s,
"wabi--file-upload-max-files-value": @max_files.to_s,
"wabi--file-upload-max-size-value": @max_size.to_s,
"wabi--file-upload-disabled-value": @disabled.to_s,
}
div(**@attrs, data: user_data.merge(root_data),
class: merge_class("flex flex-col gap-3", user_class)) do
input(type: "file", name: field_name, multiple: (multiple || nil),
data: { "wabi--file-upload-target": "hiddenInput" }, class: "sr-only")
yield if block
end
end
end
end
end
app/components/ui/file_upload_dropzone.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class FileUploadDropzone < Wabi::Base
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
div(**@attrs, data: { "wabi--file-upload-target": "dropzone" },
class: merge_class(
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed " \
"border-input bg-background px-6 py-10 text-center text-sm text-muted-foreground " \
"transition-colors motion-reduce:transition-none cursor-pointer data-[dragging]:border-ring data-[dragging]:bg-accent",
user_class)) do
yield if block
end
end
end
end
end
app/components/ui/file_upload_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class FileUploadTrigger < Wabi::Base
def initialize(**attrs) = @attrs = attrs
def view_template(&block)
user_class = @attrs.delete(:class)
button(type: "button", **@attrs, data: { "wabi--file-upload-target": "trigger" },
class: merge_class(
"inline-flex h-9 items-center justify-center rounded-md border border-input bg-background " \
"px-4 text-sm font-medium shadow-sm transition-colors motion-reduce:transition-none hover:bg-accent hover:text-accent-foreground " \
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
user_class)) do
yield if block
end
end
end
end
end
app/components/ui/file_upload_list.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class FileUploadList < Wabi::Base
def initialize(**attrs) = @attrs = attrs
def view_template
user_class = @attrs.delete(:class)
ul(**@attrs, data: { "wabi--file-upload-target": "list" },
aria: { live: "polite", atomic: "false" },
class: merge_class("flex flex-col gap-2 empty:hidden", user_class))
end
end
end
end
app/javascript/controllers/wabi/file_upload_controller.js
import { Controller } from "@hotwired/stimulus"
import * as fileUpload from "@zag-js/file-upload"
import { VanillaMachine, normalizeProps, spreadProps } from "@zag-js/vanilla"
function humanSize(bytes) {
if (bytes == null) return ""
const u = ["B", "KB", "MB", "GB"]; let i = 0; let n = bytes
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ }
return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${u[i]}`
}
export default class extends Controller {
static targets = ["dropzone", "trigger", "hiddenInput", "list"]
static values = {
name: String,
accept: String,
maxFiles: { type: Number, default: 1 },
maxSize: String,
disabled: { type: Boolean, default: false },
}
connect() {
this.machine = new VanillaMachine(fileUpload.machine, {
id: this.element.id || crypto.randomUUID(),
name: this.nameValue || undefined,
accept: this.acceptValue || undefined,
maxFiles: this.maxFilesValue,
maxFileSize: this.maxSizeValue ? Number(this.maxSizeValue) : undefined,
disabled: this.disabledValue,
onFileChange: () => { this.renderList(); this.dispatch("change") },
})
this.unsubscribe = this.machine.subscribe(() => this.render())
this.machine.start()
this.render()
this.renderList()
}
disconnect() {
;(this._objectUrls || []).forEach((u) => URL.revokeObjectURL(u))
this.unsubscribe?.()
this.machine?.stop()
}
get api() {
return fileUpload.connect(this.machine.service, normalizeProps)
}
render() {
const api = this.api
spreadProps(this.element, api.getRootProps())
if (this.hasDropzoneTarget) spreadProps(this.dropzoneTarget, api.getDropzoneProps())
if (this.hasTriggerTarget) spreadProps(this.triggerTarget, api.getTriggerProps())
if (this.hasHiddenInputTarget) spreadProps(this.hiddenInputTarget, api.getHiddenInputProps())
}
renderList() {
if (!this.hasListTarget) return
;(this._objectUrls ||= []).forEach((u) => URL.revokeObjectURL(u))
this._objectUrls = []
const api = this.api
this.listTarget.innerHTML = ""
api.acceptedFiles.forEach((file) => {
const li = document.createElement("li")
li.className = "flex items-center gap-3 rounded-md border border-input p-2 text-sm"
// Image preview: only for image files — Zag's getItemPreviewImageProps
// throws for non-image types, so we guard with a type check.
if (file.type?.startsWith("image/")) {
const img = document.createElement("img")
// getItemPreviewImageProps requires a `url` arg (the object URL).
// We build it manually to avoid async createFileUrl and to keep
// the preview synchronous for jsdom compatibility.
const url = URL.createObjectURL(file)
this._objectUrls.push(url)
spreadProps(img, api.getItemPreviewImageProps({ file, url }))
img.className = "h-10 w-10 rounded object-cover"
li.appendChild(img)
}
const meta = document.createElement("div")
meta.className = "flex-1 min-w-0"
const nm = document.createElement("p")
nm.className = "truncate font-medium"
nm.textContent = file.name
const sz = document.createElement("p")
sz.className = "text-xs text-muted-foreground"
sz.textContent = humanSize(file.size)
meta.append(nm, sz)
li.appendChild(meta)
const del = document.createElement("button")
del.type = "button"
spreadProps(del, api.getItemDeleteTriggerProps({ file }))
del.className = "shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
del.setAttribute("aria-label", `Remove ${file.name}`)
del.textContent = "✕"
li.appendChild(del)
this.listTarget.appendChild(li)
})
}
}
Accessibility
- The dropzone has role="button" with tabindex=0 and opens the file picker on Enter or Space.
- Drag state is reflected via data-[dragging] on the dropzone for visual feedback.
- The underlying <input type="file"> is tabindex=-1 and aria-hidden (out of the tab order and the accessibility tree); the dropzone and the Browse button are the accessible surfaces.
- File list renders accepted files; each entry can expose a remove button wired to the controller.
- max_files > 1 automatically appends [] to the field name and enables the multiple attribute.
- The dropzone is keyboard-focusable and contains the browse button (Zag's file-upload structure); automated tools may flag this as nested-interactive, but both the dropzone (drag/drop + Enter/Space) and the button are independently operable.