A dropzone family that renders a Phoenix.LiveView upload end to end: the
drop surface, the file list with previews and progress, cancel buttons, and
human-readable errors.
This component requires a LiveView. It renders the state held in a
Phoenix.LiveView.UploadConfig - it does not upload anything itself. All the
machinery (validation, chunking, progress, previews) comes from
Phoenix.LiveView.allow_upload/3. If you only need a plain styled file input
in a static form, use PetalComponents.Field with <.field type="file">
instead; this component does not replace it.
Wiring it up
Three pieces: allow_upload/3 in mount, the component inside a form, and
consume_uploaded_entries/3 when the form is submitted.
defmodule MyAppWeb.ProfileLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:uploaded_files, [])
|> allow_upload(:avatar,
accept: ~w(.png .jpg .jpeg),
max_entries: 4,
max_file_size: 8_000_000
)}
end
@impl true
def render(assigns) do
~H\"\"\"
<form id="upload-form" phx-change="validate" phx-submit="save">
<.file_upload upload={@uploads.avatar} label="Drop your photos here" />
<.button type="submit">Save</.button>
</form>
\"\"\"
end
# phx-change is required for the entries to reach the server at all.
@impl true
def handle_event("validate", _params, socket), do: {:noreply, socket}
# The cancel button in each entry row pushes this event with the ref.
@impl true
def handle_event("cancel-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :avatar, ref)}
end
@impl true
def handle_event("save", _params, socket) do
uploaded =
consume_uploaded_entries(socket, :avatar, fn %{path: path}, entry ->
dest = Path.join("priv/static/uploads", entry.client_name)
File.cp!(path, dest)
{:ok, ~p"/uploads/#{entry.client_name}"}
end)
{:noreply, update(socket, :uploaded_files, &(&1 ++ uploaded))}
end
endThe phx-change form binding is not optional. Without it LiveView never
receives the selected entries, so the list stays empty and nothing uploads.
Variants
<.file_upload upload={@uploads.docs} />
<.file_upload upload={@uploads.docs} variant="compact" />
<.file_upload upload={@uploads.avatar} variant="avatar" label="Profile photo" />
<.file_upload upload={@uploads.photos} variant="gallery" label="Listing photos" />dropzone- a full dashed zone with a label, a hint line and the entry list underneath. The default.compact- a browse button plus the entry list, no zone.avatar- a single circular preview with a replace overlay; a dashed circle when empty.gallery- a responsive grid of preview tiles with an "add more" tile while the config is undermax_entries.
Files already on the server
On an edit form the photos are usually already uploaded: they live in your
database with a URL, not in the browser. The :existing slot renders those
as plain <img> tags in the same grid as the in-flight entries, so "edit
your listing" shows the photos already saved and the ones being dragged in
right now as one set.
<.file_upload upload={@uploads.photos} variant="gallery" label="Listing photos">
<:existing
:for={photo <- @listing.photos}
src={photo.url}
name={photo.filename}
remove_event="delete-photo"
remove_value={photo.id}
/>
</.file_upload>
def handle_event("delete-photo", %{"id" => id}, socket) do
# your own delete - a saved photo is a row in your database, not an
# upload entry, so cancel_upload/3 has nothing to do with it
endSaved items render first, then the entries. They carry no progress bar,
nothing is in flight, and their remove button is yours rather than
cancel_upload/3. max_entries counts entries only - it is LiveView's cap
on what the browser may still add - so if six saved plus six new is too
many, lower the cap yourself.
The avatar variant takes the same slot and shows the first item in the
circle while nothing has been picked, which is the whole "here is your
current photo, click to replace it" case:
<.file_upload upload={@uploads.avatar} variant="avatar" label="Profile photo">
<:existing :if={@user.avatar_url} src={@user.avatar_url} name="Current photo" />
</.file_upload>Drag and drop
Drag and drop is LiveView's, not ours: the drop surface carries
phx-drop-target={@upload.ref} and LiveView adds a
phx-drop-target-active class while files hover it. The highlight is plain
CSS keyed off that class, so there is no hook and no JS in this component.
The "Drop files to upload" hint that fades in with the highlight is
aria-hidden, not a live region. Its text never changes - only its opacity
does - so a live region there would be a permanently-populated one that can
never announce. Screen reader users reach this surface through the file
input, whose accessible name already says what the zone takes.
The description line
With no description, one is derived from the config - accepted types from
:accept, the size cap from :max_file_size, the count from :max_entries:
allow_upload(:photos, accept: ~w(.png .jpg), max_entries: 4, max_file_size: 8_000_000)
#=> "PNG or JPG, up to 8 MB, max 4 files"Sizes are rendered in SI units (1 KB = 1000 bytes), matching how
:max_file_size is conventionally written. Pass description to override,
or description="" to drop the line entirely.
Errors
Config-level errors (:too_many_files) render above the entry list and are
described from the wrapper; entry-level errors (:too_large,
:not_accepted) render inside the entry row and are described from that
row's cancel button, the one focusable element AT will read them against.
Messages are plain English and live in this module - there is no i18n hook.
To translate them, render your own rows through the :entry slot.
Custom entry rows
<.file_upload upload={@uploads.docs}>
<:entry :let={entry}>
<span>{entry.client_name} - {entry.progress}%</span>
</:entry>
</.file_upload>The slot replaces the whole default row, including the progress bar and the
cancel button, so re-add whatever you still need. upload_errors/2 is yours
to render too.
Summary
Functions
Renders an upload surface for a Phoenix.LiveView.UploadConfig.
Functions
Renders an upload surface for a Phoenix.LiveView.UploadConfig.
See the module documentation for the full allow_upload/3 wiring.
Attributes
upload(Phoenix.LiveView.UploadConfig) (required) - the upload config fromallow_upload/3, e.g.@uploads.avatar.id(:string) - id for the wrapper; the ARIA relationships are derived from it. Defaults to the upload ref. Defaults tonil.label(:string) - heading text inside the drop zone. Defaults tonil.description(:string) - hint line under the label. When nil it is derived from the config - accepted extensions from:accept, the cap from:max_file_size, the count from:max_entries(e.g. "PNG or JPG, up to 8 MB, max 4 files"). Pass "" for no line. Defaults tonil.variant(:string) - dropzone = full dashed zone; compact = browse button + list, no zone; avatar = single circular image with a replace overlay; gallery = grid of preview tiles. Defaults to"dropzone". Must be one of"dropzone","compact","avatar", or"gallery".cancel_event(:string) - phx-click event name emitted by each entry's cancel button. The parent LiveView handles it and callscancel_upload/3with the entry ref, sent asphx-value-ref. Defaults to"cancel-upload".cancel_label(:string) - prefix for each cancel button's accessible name, joined with the file name. Defaults to"Cancel upload of".cancel_target(:any) - phx-target for the cancel event when used inside a LiveComponent. Defaults tonil.remove_label(:string) - prefix for the accessible name of each:existingitem's remove button, joined with that item's name. Defaults to"Remove".class(:any) - CSS class for the outer wrapper. Defaults tonil.- Global attributes are accepted.
Slots
entry- optional custom rendering for each entry; receives the%Phoenix.LiveView.UploadEntry{}and replaces the default entry row.existing- files already uploaded and stored, shown by URL before the in-flight entries. Nothing here touches the upload config - see the module docs. Accepts attributes:src(:string) (required) - URL of the stored image.name(:string) (required) - file name or caption; the visible label and the image's alt text.remove_event(:any) - phx-click for this item's remove button. Without it no button renders.remove_value(:any) - sent with the remove event asphx-value-id.remove_target(:any) - phx-target for the remove event.