defmodule Corex.FileUpload do @moduledoc ~S''' Phoenix implementation of [Zag.js File Upload](https://zagjs.com/components/react/file-upload). Use `multipart` on the parent form and read `%Plug.Upload{}` from params in a **controller** action, as in [Phoenix file uploads](https://hexdocs.pm/phoenix/file_uploads.html). LiveView `phx-submit` cannot transport raw multipart file bytes over the WebSocket; use a controller route for classic `Plug.Upload`, or [`allow_upload/3`](https://hexdocs.pm/phoenix_live_view/uploads.html) for LiveView-native uploads with [`Corex.FileUploadLive`](Corex.FileUploadLive.html) (`<.file_upload_live>`). Do not combine this Zag component with [`live_file_input`](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#live_file_input/1) on the same file control. ## Anatomy ### Minimal ```heex <.file_upload name="document" class="file-upload"> <:close> <.heroicon name="hero-x-mark" /> ``` ### With label ```heex <.file_upload name="document" class="file-upload"> <:label>Files <:close> <.heroicon name="hero-x-mark" /> ``` ### Custom slots ```heex <.file_upload name="document" class="file-upload"> <:dropzone> Custom dropzone <:open> Custom trigger <:close> <.heroicon name="hero-x-mark" /> ``` ### Multipart form (controller) ```heex <.form for={@form} action={~p"/file-upload/form"} method="post" multipart> <.file_upload field={@form[:attachment]} class="file-upload"> <:label>Attachment <:close> <.heroicon name="hero-x-mark" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <.action type="submit" class="button button--accent w-full">Submit ``` Use `multipart` on the parent form so `%Plug.Upload{}` is available on the server for classic uploads. Optional hidden `_sent` supports `used_input?` when validating empty submits. ## API Requires a stable `id` on `<.file_upload>`. | Function | Action | Returns | | -------- | ------ | ------- | | [`clear_files/1`](#clear_files/1) | Clear accepted files (client) | `%Phoenix.LiveView.JS{}` | | [`clear_files/2`](#clear_files/2) | Clear accepted files (server) | `socket` | | [`clear_rejected_files/1`](#clear_rejected_files/1) | Clear rejected list (client) | `%Phoenix.LiveView.JS{}` | | [`clear_rejected_files/2`](#clear_rejected_files/2) | Clear rejected list (server) | `socket` | | [`open_file_picker/1`](#open_file_picker/1) | Open native picker (client) | `%Phoenix.LiveView.JS{}` | | [`open_file_picker/2`](#open_file_picker/2) | Open native picker (server) | `socket` | ## Events Pick an event name and pass it to `on_*` on `<.file_upload>`. Rejected files are not listed in the DOM; use `on_file_reject` to react to validation failures. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_file_change="files_changed"` | Accepted file list changes | `%{"id" => id, ...}` | | `on_file_accept="file_accepted"` | File passes validation | `%{"id" => id, ...}` | | `on_file_reject="file_rejected"` | File fails validation | `%{"id" => id, ...}` | ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_file_change_client="files-changed"` | Accepted list changes | `id`, file metadata | | `on_file_accept_client="file-accepted"` | File accepted | `id`, file metadata | | `on_file_reject_client="file-rejected"` | File rejected | `id`, reason | ## Form See **Multipart form** under Anatomy. Use `field={f[:attachment]}` inside `<.form multipart>`. For cross-cutting invalid styling and error presentation, see the [Forms](forms.html) guide. ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.FileUpload.Anatomy.{ Dropzone, HiddenInput, InputSentinel, ItemGroup, Label, Props, Root, Trigger } alias Corex.FileUpload.Connect alias Corex.FileUpload.Translation alias Phoenix.LiveView alias Phoenix.LiveView.JS attr(:id, :string, required: false, doc: "Stable id for the file upload root; set automatically when using field" ) attr(:disabled, :boolean, default: false, doc: "Whether the file upload is disabled") attr(:invalid, :boolean, default: false, doc: "Whether the file upload is invalid") attr(:read_only, :boolean, default: false, doc: "Whether the file upload is read-only") attr(:required, :boolean, default: false, doc: "Whether at least one file is required") attr(:name, :string, doc: "The name attribute of the hidden file input") attr(:form, :string, doc: "The id of the form this control belongs to") attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "Text direction (ltr or rtl)" ) attr(:max_files, :integer, default: 1, doc: "Maximum number of files the user may select" ) attr(:max_file_size, :integer, default: nil, doc: "Maximum file size in bytes; omit for no limit" ) attr(:min_file_size, :integer, default: nil, doc: "Minimum file size in bytes; omit for no limit" ) attr(:allow_drop, :boolean, default: true, doc: "Whether drag-and-drop onto the dropzone is enabled" ) attr(:prevent_document_drop, :boolean, default: true, doc: "When true, prevents dropping files on the document outside the dropzone" ) attr(:accept, :string, default: nil, doc: "Comma-separated MIME types or extensions (e.g. image/*,.pdf)" ) attr(:directory, :boolean, default: false, doc: "When true, allow selecting a directory instead of individual files" ) attr(:on_file_change, :string, default: nil, doc: "Server event when the accepted file list changes" ) attr(:on_file_change_client, :string, default: nil, doc: "Client event name when the accepted file list changes" ) attr(:on_file_accept, :string, default: nil, doc: "Server event when a file passes validation" ) attr(:on_file_accept_client, :string, default: nil, doc: "Client event name when a file passes validation" ) attr(:on_file_reject, :string, default: nil, doc: "Server event when a file fails validation" ) attr(:on_file_reject_client, :string, default: nil, doc: "Client event name when a file fails validation" ) attr(:translation, Corex.FileUpload.Translation, default: nil, doc: "Override translatable strings" ) attr(:errors, :list, default: [], doc: "List of error messages when not using field=" ) attr(:field, Phoenix.HTML.FormField, doc: "Form field for id, name, form, invalid, and required wiring" ) attr(:rest, :global) slot(:label, required: false, doc: "Label above the dropzone") do attr(:class, :string, required: false) end slot(:dropzone, required: false, doc: "Custom dropzone content; defaults to translation dropzone text" ) slot(:open, required: false, doc: "Custom open-picker trigger; defaults to translation open text" ) slot(:close, required: true, doc: "Remove control for each accepted file entry") do attr(:class, :string, required: false) end slot(:error, required: false, doc: "Error message content; receives the message as slot argument" ) do attr(:class, :string, required: false) end def file_upload(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns |> Corex.FormField.assign_form_field(field) |> file_upload() end def file_upload(assigns) do translation = Translation.resolve(Map.get(assigns, :translation)) assigns = assigns |> assign_new(:id, fn -> "file-upload-#{System.unique_integer([:positive])}" end) |> assign_new(:form_field, fn -> false end) |> assign_new(:name, fn -> nil end) |> assign_new(:form, fn -> nil end) |> assign_new(:dir, fn -> "ltr" end) |> assign_new(:max_files, fn -> 1 end) |> assign_new(:max_file_size, fn -> nil end) |> assign_new(:min_file_size, fn -> nil end) |> assign_new(:allow_drop, fn -> true end) |> assign_new(:prevent_document_drop, fn -> true end) |> assign_new(:accept, fn -> nil end) |> assign_new(:directory, fn -> false end) |> assign_new(:errors, fn -> [] end) |> assign(:translation, translation) ~H"""