defmodule Corex.SignaturePad do @moduledoc ~S''' Phoenix implementation of [Zag.js Signature Pad](https://zagjs.com/components/react/signature-pad). ## Anatomy ### Basic Usage ```heex <.signature_pad class="signature-pad"> <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> ``` ### With Callback ```heex <.signature_pad on_draw_end="signature_drawn" class="signature-pad"> <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> ``` ```elixir def handle_event("signature_drawn", %{"paths" => paths}, socket) do {:noreply, put_flash(socket, :info, "Signature drawn with #{length(paths)} paths")} end ``` ### Custom Drawing Options ```heex <.signature_pad drawing_fill="blue" drawing_size={3} drawing_simulate_pressure class="signature-pad"> <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> ``` ## Form When using with Phoenix forms, set the form `id` in `to_form/2` (for example `to_form(changeset, as: :name, id: "my-form")`) and use `<.form for={@form}>`. For `phx-change` and `used_input?/1`, set `phx-change` on `<.form>` so the whole form is sent (not on a single input only). For cross-cutting invalid styling and error presentation, see the [Forms](forms.html) guide. Pass `invalid={Corex.FormField.invalid?(@form[:signature])}` when you want alert borders after validation. With `field={@form[:signature]}`, paths submit as `name="…[]"` hidden array inputs. On draw or clear, the hook updates those inputs and dispatches `input` so LiveView tracks the field. Errors render only when `Phoenix.Component.used_input?/1` is true for the field. ### Controller Build the form from an Ecto changeset: ```elixir def form_page(conn, _params) do form = %MyApp.Form.SignatureForm{} |> MyApp.Form.SignatureForm.changeset(%{}) |> Phoenix.Component.to_form(as: :signature_form, id: "signature-form") render(conn, :form_page, form: form) end ``` ```heex <.form :let={f} for={@form} action={@action} method="post"> <.signature_pad field={f[:signature]} class="signature-pad"> <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ### Live View Prefer building the form from an Ecto changeset (see "With Ecto changeset" below). ### With Ecto changeset First create your schema and changeset: ```elixir defmodule MyApp.Accounts.User do use Ecto.Schema import Ecto.Changeset schema "users" do field :name, :string field :signature, :text timestamps(type: :utc_datetime) end def changeset(user, attrs) do user |> cast(attrs, [:name, :signature]) |> validate_required([:name, :signature]) end end ``` ```elixir defmodule MyAppWeb.UserLive do use MyAppWeb, :live_view alias MyApp.Accounts.User def mount(_params, _session, socket) do {:ok, assign(socket, :form, to_form(User.changeset(%User{}, %{})))} end def handle_event("validate", %{"user" => user_params}, socket) do changeset = User.changeset(%User{}, user_params) {:noreply, assign(socket, form: to_form(changeset, action: :validate))} end def render(assigns) do ~H""" <.form for={@form} phx-change="validate"> <.signature_pad field={@form[:signature]} id="my-signature-pad" class="signature-pad" > <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} """ end end ``` ## API Requires a stable `id` on `<.signature_pad>`. | Function | Action | Returns | | -------- | ------ | ------- | | [`clear/1`](#clear/1) | Clear canvas (client) | `%Phoenix.LiveView.JS{}` | | [`clear/2`](#clear/2) | Clear canvas (server) | `socket` | ## Events Pick an event name and pass it to `on_*` on `<.signature_pad>`. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_draw_end="signature_drawn"` | Stroke ends | `%{"id" => id, "paths" => paths}` | ### on_draw_end ```heex <.signature_pad class="signature-pad" on_draw_end="signature_drawn" > <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> ``` ```elixir def handle_event("signature_drawn", %{"id" => _id, "paths" => paths}, socket) do {:noreply, assign(socket, :path_count, length(paths))} end ``` ## Patterns ### LiveView sync Pass `paths` from a LiveView assign and handle `on_draw_end` so the server owns stroke data. ```heex <.signature_pad class="signature-pad" paths={@signature_paths} on_draw_end="signature_drawn" > <:label>Sign here <:clear_trigger> <.heroicon name="hero-x-mark" /> ``` ```elixir def handle_event("signature_drawn", %{"paths" => paths}, socket) do {:noreply, assign(socket, :signature_paths, paths)} end ``` ## Style Use data attributes to target elements: ```css [data-scope="signature-pad"][data-part="root"] {} [data-scope="signature-pad"][data-part="label"] {} [data-scope="signature-pad"][data-part="control"] {} [data-scope="signature-pad"][data-part="segment"] {} [data-scope="signature-pad"][data-part="guide"] {} [data-scope="signature-pad"][data-part="clear-trigger"] {} [data-scope="signature-pad"][data-part="hidden-input"] {} ``` If you wish to use the default Corex styling, you can use the class `signature-pad` on the component. This requires to install `Mix.Tasks.Corex.Design` first and import the component css file. ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/signature-pad.css"; ``` Drawing stroke color is set with the `drawing_fill` attribute (a CSS color value such as `var(--color-ink)` or `var(--color-accent)`). It is not controlled by root modifier classes. Trigger color, size, and corner radius use modifier classes on the root: ```heex <.signature_pad class="signature-pad signature-pad--accent signature-pad--lg signature-pad--rounded-xl" drawing_fill="var(--color-ink)" > ``` | Modifier | Applies to | | -------- | ---------- | | `signature-pad--{accent,brand,alert,success,info}` | Clear trigger only | | `signature-pad--{sm,md,lg,xl}` | Label, control height, clear trigger | | `signature-pad--rounded-{none,sm,md,lg,xl,full}` | Control, pad surface, clear trigger | The guide line (`data-part="guide"`) is not themed by color modifiers. ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.SignaturePad.Anatomy.{ ClearTrigger, Control, Error, Guide, HiddenInput, Label, Path, Props, Root, Segment } alias Corex.SignaturePad.Connect alias Phoenix.LiveView alias Phoenix.LiveView.JS @doc """ Renders a signature pad component. """ attr(:id, :string, required: false, doc: "The id of the signature pad, useful for API to identify the signature pad" ) attr(:drawing_fill, :string, default: "black", doc: "CSS color for drawing strokes (e.g. `var(--color-ink)` or `var(--color-accent)`)" ) attr(:drawing_size, :integer, default: 2, doc: "The size/thickness of drawing strokes" ) attr(:drawing_simulate_pressure, :boolean, default: false, doc: "Whether to simulate pressure for drawing strokes" ) attr(:drawing_smoothing, :float, default: 0.9, doc: "Smoothing factor for drawing strokes (0–1, perfect-freehand option)" ) attr(:drawing_easing, :string, default: nil, doc: "Easing function for drawing strokes (perfect-freehand option)" ) attr(:drawing_thinning, :float, default: nil, doc: "Thinning factor for drawing strokes (perfect-freehand option)" ) attr(:drawing_streamline, :float, default: 0.1, doc: "Streamline factor for drawing strokes (perfect-freehand option)" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the signature pad. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:on_draw_end, :string, default: nil, doc: "The server event name when drawing ends" ) attr(:on_draw_end_client, :string, default: nil, doc: "The client event name when drawing ends" ) attr(:paths, :any, default: nil, doc: "Initial stroke paths: a list of SVG d strings, or one string with lines separated by newline, sent as `data-default-paths` to the hook." ) attr(:name, :string, doc: "The name of the signature pad input for form submission") attr(:errors, :list, default: [], doc: "List of error messages to display" ) attr(:field, Phoenix.HTML.FormField, doc: "A form field from the form, e.g. @form[:signature]. Sets id, name, paths, and errors. Errors are filtered with `used_input?/1` (see the Phoenix Form Integration section)." ) attr(:rest, :global) slot :label, required: false do attr(:class, :string, required: false) end slot :clear_trigger, required: false do attr(:class, :string, required: false) attr(:aria_label, :string, doc: "Accessibility label for the clear button. Defaults to 'Clear signature' if not provided." ) end slot :error, required: false do attr(:class, :string, required: false) end def signature_pad(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do paths_value = case field.value do nil -> [] "" -> [] value when is_list(value) -> Enum.filter(value, &is_binary/1) value when is_binary(value) -> path_d_strings(value) _ -> [] end assigns |> Corex.FormField.assign_form_field(field) |> assign(:paths, paths_value) |> signature_pad_render(true, Phoenix.Component.used_input?(field)) end def signature_pad(assigns) do signature_pad_render(assigns, false, false) end defp signature_pad_render(assigns, form_field, field_used) do assigns = assigns |> assign_new(:id, fn -> "signature-pad-#{System.unique_integer([:positive])}" end) |> then(fn a -> assign(a, :paths, paths_from_paths_attr(a[:paths])) end) |> assign_new(:form, fn -> nil end) |> assign_new(:name, fn -> "name-#{System.unique_integer([:positive])}" end) |> Corex.FormField.assign_list_submit() connect_props = Connect.props(%Props{ id: assigns.id, form_field: form_field, field_used: field_used, paths: assigns.paths, drawing_fill: assigns.drawing_fill, drawing_size: assigns.drawing_size, drawing_simulate_pressure: assigns.drawing_simulate_pressure, drawing_smoothing: assigns.drawing_smoothing, drawing_easing: assigns.drawing_easing, drawing_thinning: assigns.drawing_thinning, drawing_streamline: assigns.drawing_streamline, dir: assigns.dir, on_draw_end: assigns.on_draw_end, on_draw_end_client: assigns.on_draw_end_client, name: assigns.name, submit_name: assigns.submit_name }) assigns = assign(assigns, connect_props: connect_props, empty_array_name: if(field_used, do: assigns.submit_name) ) ~H"""
{render_slot(@error, msg)}
""" end api_doc(~S""" Clear all strokes from a control (`phx-click`). ```heex <.action phx-click={Corex.SignaturePad.clear("my-signature-pad")}>Clear <.signature_pad id="my-signature-pad" class="signature-pad"> <:label>Sign <:clear_trigger><.heroicon name="hero-x-mark" /> ``` ```javascript document.getElementById("my-signature-pad")?.dispatchEvent( new CustomEvent("corex:signature-pad:clear", { bubbles: false, detail: { id: "my-signature-pad" }, }) ); ``` """) def clear(signature_pad_id) when is_binary(signature_pad_id) do JS.dispatch("corex:signature-pad:clear", to: "##{signature_pad_id}", detail: %{id: signature_pad_id}, bubbles: false ) end api_doc(~S""" Clear strokes from `handle_event`. ```heex <.action phx-click="clear_sig">Clear <.signature_pad id="my-signature-pad" class="signature-pad"> <:label>Sign <:clear_trigger><.heroicon name="hero-x-mark" /> ``` ```elixir def handle_event("clear_sig", _, socket) do {:noreply, Corex.SignaturePad.clear(socket, "my-signature-pad")} end ``` """) def clear(socket, signature_pad_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(signature_pad_id) do LiveView.push_event(socket, "signature_pad_clear", %{ signature_pad_id: signature_pad_id }) end defp paths_from_paths_attr(nil), do: [] defp paths_from_paths_attr(paths) when is_list(paths), do: Enum.filter(paths, &is_binary/1) defp paths_from_paths_attr(paths) when is_binary(paths), do: path_d_strings(paths) defp paths_from_paths_attr(_), do: [] defp has_paths?([]), do: false defp has_paths?(paths) when is_list(paths), do: true defp has_paths?(_), do: false defp hidden_input_value([]), do: "" defp hidden_input_value(paths) when is_list(paths), do: Enum.join(paths, "\n") defp path_d_strings(s) when is_binary(s) do s |> String.split("\n") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) end end