defmodule Corex.PasswordInput do @moduledoc ~S''' Phoenix implementation of [Zag.js Password Input](https://zagjs.com/components/react/password-input). ## Anatomy ### Minimal ```heex <.password_input class="password-input"> <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ### Custom Error ```heex <.password_input field={@form[:password]} class="password-input"> <:label>Password <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ## 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 cross-cutting invalid styling and error presentation, see the [Forms](forms.html) guide. Pass `invalid={Corex.FormField.invalid?(@form[:password])}` when you want alert borders after validation. ### Controller Build the form from an Ecto changeset: ```elixir def form_page(conn, _params) do form = %MyApp.Form.PasswordForm{} |> MyApp.Form.PasswordForm.changeset(%{}) |> Phoenix.Component.to_form(as: :password_form, id: "password-form") render(conn, :form_page, form: form) end ``` ```heex <.form :let={f} for={@form} action={@action} method="post"> <.password_input field={f[:password]} class="password-input"> <:label>Password <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ### 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 :email, :string field :password, :string timestamps(type: :utc_datetime) end def changeset(user, attrs) do user |> cast(attrs, [:email, :password]) |> validate_required([:email, :password]) |> validate_length(:password, min: 8) 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"> <.password_input field={@form[:password]} class="password-input"> <:label>Password <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> """ end end ``` ## API Requires a stable `id` on `<.password_input>`. | Function | Action | Returns | | -------- | ------ | ------- | | [`set_visible/2`](#set_visible/2) | Set visibility (client) | `%Phoenix.LiveView.JS{}` | | [`set_visible/3`](#set_visible/3) | Set visibility (server) | `socket` | | [`toggle_visible/1`](#toggle_visible/1) | Toggle visibility (client) | `%Phoenix.LiveView.JS{}` | | [`toggle_visible/2`](#toggle_visible/2) | Toggle visibility (server) | `socket` | | [`focus/1`](#focus/1) | Focus input (client) | `%Phoenix.LiveView.JS{}` | | [`focus/2`](#focus/2) | Focus input (server) | `socket` | ## Events Pick an event name and pass it to `on_*` on `<.password_input>`. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_visibility_change="password_visibility_changed"` | Visibility toggles | `%{"id" => id, "visible" => boolean}` | ### on_visibility_change ```heex <.password_input class="password-input" on_visibility_change="password_visibility_changed" > <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ```elixir def handle_event("password_visibility_changed", %{"id" => _id, "visible" => visible}, socket) do {:noreply, assign(socket, :password_visible, visible)} end ``` ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_visibility_change_client="password-visibility-changed"` | Visibility toggles | `id`, `visible` | ## Style Use data attributes to target elements: ```css [data-scope="password-input"][data-part="root"] {} [data-scope="password-input"][data-part="label"] {} [data-scope="password-input"][data-part="control"] {} [data-scope="password-input"][data-part="input"] {} [data-scope="password-input"][data-part="visibility-trigger"] {} [data-scope="password-input"][data-part="indicator"] {} ``` ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/password-input.css"; ``` Stack modifiers on the host (`class` on `<.password_input>`). ### Color | Modifier | Classes | | -------- | ------- | | Default | `password-input` | | Accent | `password-input password-input--accent` | | Brand | `password-input password-input--brand` | | Alert | `password-input password-input--alert` | | Info | `password-input password-input--info` | | Success | `password-input password-input--success` | ### Size | Modifier | Classes | | -------- | ------- | | SM | `password-input password-input--sm` | | MD | `password-input password-input--md` | | LG | `password-input password-input--lg` | | XL | `password-input password-input--xl` | ''' @doc type: :component use Phoenix.Component import Corex.Api.Doc alias Corex.PasswordInput.Anatomy.{ Control, Indicator, Input, Label, Props, Root, VisibilityTrigger } alias Corex.PasswordInput.Connect alias Corex.PasswordInput.Translation alias Phoenix.HTML.Form alias Phoenix.LiveView alias Phoenix.LiveView.JS attr(:id, :string, required: false) attr(:value, :string, default: nil) attr(:visible, :boolean, default: false) attr(:disabled, :boolean, default: false) attr(:invalid, :boolean, default: false) attr(:read_only, :boolean, default: false) attr(:required, :boolean, default: false) attr(:ignore_password_managers, :boolean, default: true) attr(:name, :string) attr(:form, :string) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"]) attr(:orientation, :string, default: "vertical", values: ["horizontal", "vertical"]) attr(:auto_complete, :string, default: "current-password", values: ["current-password", "new-password"] ) attr(:on_visibility_change, :string, default: nil) attr(:on_visibility_change_client, :string, default: nil) attr(:errors, :list, default: [], doc: "List of error messages to display") attr(:translation, Corex.PasswordInput.Translation, default: nil, doc: "Override translatable strings" ) attr(:field, Phoenix.HTML.FormField, doc: "A form field struct retrieved from the form, for example: @form[:password]. Automatically sets id, name, form, and errors from the form field. Pass `invalid` explicitly for alert styling." ) attr(:rest, :global) slot :label, required: false do attr(:class, :string, required: false) end slot(:error, required: false) do attr(:class, :string, required: false) end slot :visible_indicator, required: false, doc: "Icon shown when password is visible" do attr(:class, :string, required: false) end slot :hidden_indicator, required: false, doc: "Icon shown when password is hidden" do attr(:class, :string, required: false) end def password_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns |> Corex.FormField.assign_form_field(field) |> assign(:value, Form.normalize_value("password", field.value)) |> password_input() end def password_input(assigns) do translation = Translation.resolve(assigns.translation) assigns = assigns |> assign_new(:id, fn -> "password-input-#{System.unique_integer([:positive])}" end) |> assign_new(:name, fn -> nil end) |> assign_new(:form, fn -> nil end) |> assign_new(:form_field, fn -> false end) |> assign_new(:dir, fn -> "ltr" end) |> assign_new(:orientation, fn -> "horizontal" end) |> assign(:translation, translation) ~H"""
{render_slot(@error, msg)}
""" end api_doc(~S""" Set whether the password is visible from a control (`phx-click`). ```heex <.action phx-click={Corex.PasswordInput.set_visible("my-password-input", true)}>Reveal <.password_input id="my-password-input" class="password-input" name="pw"> <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ```javascript document.getElementById("my-password-input")?.dispatchEvent( new CustomEvent("corex:password-input:set-visible", { bubbles: false, detail: { visible: true }, }) ); ``` """) def set_visible(password_input_id, visible) when is_binary(password_input_id) and is_boolean(visible) do JS.dispatch("corex:password-input:set-visible", to: "##{password_input_id}", detail: %{visible: visible}, bubbles: false ) end api_doc(~S""" Set visibility from `handle_event`. ```heex <.action phx-click="reveal_pw">Reveal <.password_input id="my-password-input" class="password-input" name="pw"> <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ```elixir def handle_event("reveal_pw", _, socket) do {:noreply, Corex.PasswordInput.set_visible(socket, "my-password-input", true)} end ``` """) def set_visible(socket, password_input_id, visible) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(password_input_id) and is_boolean(visible) do LiveView.push_event(socket, "password_input_set_visible", %{ "id" => password_input_id, "visible" => visible }) end api_doc(~S""" Toggle visibility from a control (`phx-click`). ```heex <.action phx-click={Corex.PasswordInput.toggle_visible("my-password-input")}>Toggle <.password_input id="my-password-input" class="password-input" name="pw"> <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ```javascript document.getElementById("my-password-input")?.dispatchEvent( new CustomEvent("corex:password-input:toggle-visible", { bubbles: false }) ); ``` """) def toggle_visible(password_input_id) when is_binary(password_input_id) do JS.dispatch("corex:password-input:toggle-visible", to: "##{password_input_id}", bubbles: false ) end api_doc(~S""" Toggle visibility from `handle_event`. ```heex <.action phx-click="toggle_pw">Toggle <.password_input id="my-password-input" class="password-input" name="pw"> <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ```elixir def handle_event("toggle_pw", _, socket) do {:noreply, Corex.PasswordInput.toggle_visible(socket, "my-password-input")} end ``` """) def toggle_visible(socket, password_input_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(password_input_id) do LiveView.push_event(socket, "password_input_toggle_visible", %{"id" => password_input_id}) end api_doc(~S""" Focus the input from a control (`phx-click`). ```heex <.action phx-click={Corex.PasswordInput.focus("my-password-input")}>Focus <.password_input id="my-password-input" class="password-input" name="pw"> <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ```javascript document.getElementById("my-password-input")?.dispatchEvent( new CustomEvent("corex:password-input:focus", { bubbles: false }) ); ``` """) def focus(password_input_id) when is_binary(password_input_id) do JS.dispatch("corex:password-input:focus", to: "##{password_input_id}", bubbles: false) end api_doc(~S""" Focus the input from `handle_event`. ```heex <.action phx-click="focus_pw">Focus <.password_input id="my-password-input" class="password-input" name="pw"> <:label>Password <:visible_indicator><.heroicon name="hero-eye" /> <:hidden_indicator><.heroicon name="hero-eye-slash" /> ``` ```elixir def handle_event("focus_pw", _, socket) do {:noreply, Corex.PasswordInput.focus(socket, "my-password-input")} end ``` """) def focus(socket, password_input_id) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(password_input_id) do LiveView.push_event(socket, "password_input_focus", %{"id" => password_input_id}) end end