PetalComponents.FileUpload (petal_components v4.15.1)

Copy Markdown View Source

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
end

The 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 under max_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
end

Saved 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

file_upload(assigns)

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 from allow_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 to nil.
  • label (:string) - heading text inside the drop zone. Defaults to nil.
  • 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 to nil.
  • 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 calls cancel_upload/3 with the entry ref, sent as phx-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 to nil.
  • remove_label (:string) - prefix for the accessible name of each :existing item's remove button, joined with that item's name. Defaults to "Remove".
  • class (:any) - CSS class for the outer wrapper. Defaults to nil.
  • 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 as phx-value-id.
    • remove_target (:any) - phx-target for the remove event.