defmodule Corex.Checkbox do @moduledoc ~S''' Phoenix implementation of [Zag.js Checkbox](https://zagjs.com/components/react/checkbox). ## Anatomy ### Minimal ```heex <.checkbox class="checkbox"> <:label>Option ``` ### Label and indicator ```heex <.checkbox class="checkbox"> <:label>Accept the terms <:indicator> <.heroicon name="hero-check" /> ``` ### Invalid ```heex <.checkbox class="checkbox checkbox--accent" invalid checked errors={["Required"]} > <:label>Subscribe <:indicator> <.heroicon name="hero-check" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ### Indeterminate ```heex <.checkbox class="checkbox" checked={:indeterminate}> <:label>Select some rows <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> ``` ## API Requires a stable `id` on `<.checkbox>`. Imperative helpers set or toggle checked state (boolean only; clears indeterminate). | Function | Action | Returns | | -------- | ------ | ------- | | [`set_checked/2`](#set_checked/2) | Set checked state (client) | `%Phoenix.LiveView.JS{}` | | [`set_checked/3`](#set_checked/3) | Set checked state (server) | `socket` | | [`toggle_checked/1`](#toggle_checked/1) | Toggle checked state (client) | `%Phoenix.LiveView.JS{}` | | [`toggle_checked/2`](#toggle_checked/2) | Toggle checked state (server) | `socket` | ### set_checked ```heex <.action phx-click={Corex.Checkbox.set_checked("checkbox-api-bind", true)} class="button button--sm"> Set checked <.action phx-click={Corex.Checkbox.set_checked("checkbox-api-bind", false)} class="button button--sm"> Set unchecked <.action phx-click={Corex.Checkbox.toggle_checked("checkbox-api-bind")} class="button button--sm"> Toggle <.checkbox id="checkbox-api-bind" class="checkbox"> <:label>Terms <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> ``` ### set_checked (dispatch) ```javascript const el = document.getElementById("checkbox-api-dispatch"); el?.dispatchEvent( new CustomEvent("corex:checkbox:set-checked", { bubbles: false, detail: { checked: true } }) ); el?.dispatchEvent( new CustomEvent("corex:checkbox:set-checked", { bubbles: false, detail: { checked: false } }) ); el?.dispatchEvent(new CustomEvent("corex:checkbox:toggle-checked", { bubbles: false })); ``` ```elixir def handle_event("check", %{"id" => id}, socket) do {:noreply, Corex.Checkbox.set_checked(socket, id, true)} end def handle_event("uncheck", %{"id" => id}, socket) do {:noreply, Corex.Checkbox.set_checked(socket, id, false)} end def handle_event("toggle", %{"id" => id}, socket) do {:noreply, Corex.Checkbox.toggle_checked(socket, id)} end ``` ## Events User-driven only. Declarative `checked` may be `true`, `false`, or `:indeterminate`; imperative `set_checked` is always boolean. ### Server events | Event | When | Payload | | ----- | ---- | ------- | | `on_checked_change="checkbox_changed"` | User toggles checked state | `%{"id" => id, "checked" => boolean}` | ### on_checked_change ```heex <.checkbox class="checkbox" on_checked_change="checkbox_changed" > <:label>Subscribe <:indicator> <.heroicon name="hero-check" /> ``` ```elixir def handle_event("checkbox_changed", %{"id" => id, "checked" => checked}, socket) do {:noreply, assign(socket, :checked, checked)} end ``` ### Client events | Event | When | `event.detail` | | ----- | ---- | -------------- | | `on_checked_change_client="checkbox-changed"` | User toggles checked state | `id`, `checked` | ### on_checked_change_client ```heex <.checkbox id="checkbox-on-checked-change-client" class="checkbox" on_checked_change_client="checkbox-changed" > <:label>Subscribe <:indicator> <.heroicon name="hero-check" /> ``` ```javascript document.getElementById("checkbox-on-checked-change-client")?.addEventListener( "checkbox-changed", (event) => console.log(event.detail) ); ``` ## Patterns ### Async #### Heex ```heex <.async_result :let={checkbox} assign={@checkbox}> <:loading><.checkbox_skeleton class="checkbox" /> <.checkbox class="checkbox" checked={checkbox.checked}> <:label>Accept terms <:indicator><.heroicon name="hero-check" /> <:indeterminate><.heroicon name="hero-minus" /> ``` #### Elixir ```elixir socket = assign_async(socket, :checkbox, fn -> Process.sleep(1000) {:ok, %{checkbox: %{checked: true}}} end) ``` ### Controlled (LiveView) #### Heex ```heex <.checkbox class="checkbox" controlled checked={@checked} on_checked_change="patterns_controlled_changed" > <:label>Accept terms <:indicator><.heroicon name="hero-check" /> <:indeterminate><.heroicon name="hero-minus" /> ``` #### Elixir ```elixir def mount(_params, _session, socket) do {:ok, assign(socket, :checked, true)} end def handle_event("patterns_controlled_changed", %{"checked" => checked}, socket) do {:noreply, assign(socket, :checked, checked)} end ``` ## Style Target parts with `data-scope` and `data-part`, or import `checkbox.css` and stack modifiers on the host. ```css [data-scope="checkbox"][data-part="root"] {} [data-scope="checkbox"][data-part="control"] {} [data-scope="checkbox"][data-part="label"] {} [data-scope="checkbox"][data-part="hidden-input"] {} [data-scope="checkbox"][data-part="error"] {} ``` ```css @import "../corex/main.css"; @import "../corex/tokens/themes/neo/light.css"; @import "../corex/components/checkbox.css"; ``` ### Color | Modifier | Classes | | -------- | ------- | | Default | `checkbox` | | Accent | `checkbox checkbox--accent` | | Brand | `checkbox checkbox--brand` | | Alert | `checkbox checkbox--alert` | | Info | `checkbox checkbox--info` | | Success | `checkbox checkbox--success` | ```heex <.checkbox class="checkbox" checked> <:label>Default <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> <.checkbox class="checkbox checkbox--accent" checked> <:label>Accent <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> <.checkbox class="checkbox checkbox--brand" checked> <:label>Brand <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> <.checkbox class="checkbox checkbox--alert" checked> <:label>Alert <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> <.checkbox class="checkbox checkbox--info" checked> <:label>Info <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> <.checkbox class="checkbox checkbox--success" checked> <:label>Success <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> ``` ### Size | Modifier | Classes | | -------- | ------- | | SM | `checkbox checkbox--sm` | | Default | `checkbox` | | LG | `checkbox checkbox--lg` | | XL | `checkbox checkbox--xl` | ```heex <.checkbox class="checkbox checkbox--sm"> <:label>Small <.checkbox class="checkbox"> <:label>Default <.checkbox class="checkbox checkbox--lg"> <:label>Large <.checkbox class="checkbox checkbox--xl"> <:label>XLarge ``` ### Invalid Invalid styles the label and control border. Checked indicators keep their semantic fill color. ```heex <.checkbox class="checkbox checkbox--accent" invalid checked errors={["Required"]}> <:label>Subscribe <:indicator> <.heroicon name="hero-check" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ## Form Set the form `id` in `to_form/2` and use `<.form for={@form}>`. Use `field={@form[:terms]}` so the checkbox name matches the form. For Ecto validation in LiveView, add `phx-change` on the form so params stay in sync. For cross-cutting invalid styling and error presentation, see the [Forms](forms.html) guide. Pass `invalid={Corex.FormField.invalid?(@form[:terms])}` when you want alert borders after validation. ### Phoenix Form (changeset) #### Heex ```heex <.form :let={f} for={@form} action="/account/terms" method="post" > <.checkbox field={f[:terms]} class="checkbox"> <:label>Accept terms <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <.action type="submit" class="button button--accent"> Submit ``` #### Elixir ```elixir def account_terms_page(conn, _params) do changeset = MyApp.Forms.Terms.changeset(%MyApp.Forms.Terms{}, %{}) form = Phoenix.Component.to_form(changeset, as: :terms_changeset, id: "account-terms-changeset-form" ) render(conn, :account_terms, form: form) end def account_terms_create(conn, %{"terms_changeset" => params}) do case MyApp.Forms.Terms.changeset(%MyApp.Forms.Terms{}, params) do %Ecto.Changeset{valid?: true} = changeset -> data = Ecto.Changeset.apply_changes(changeset) conn |> put_flash(:info, "Saved: terms=#{data.terms}") |> redirect(to: "/account") changeset -> changeset = Map.put(changeset, :action, :insert) form = Phoenix.Component.to_form(changeset, as: :terms_changeset, id: "account-terms-changeset-form" ) render(conn, :account_terms, form: form) end end ``` #### Ecto ```elixir defmodule MyApp.Forms.Terms do use Ecto.Schema import Ecto.Changeset embedded_schema do field :terms, :boolean, default: false end def changeset(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms]) |> validate_acceptance(:terms) end def changeset_validate(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms], message: "can't be blank") |> validate_acceptance(:terms, message: "must be accepted to continue") end end ``` ### Ecto changeset (validation) #### Heex ```heex <.form :let={f} for={@form} action="/account/terms" method="post" > <.checkbox field={f[:terms]} class="checkbox"> <:label>Accept terms (strict messages) <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <.action type="submit" class="button button--accent"> Submit ``` #### Elixir ```elixir def account_terms_strict_page(conn, _params) do changeset = MyApp.Forms.Terms.changeset_validate(%MyApp.Forms.Terms{}, %{}) form = Phoenix.Component.to_form(changeset, as: :terms_validate, id: "account-terms-validate-form" ) render(conn, :account_terms_strict, form: form) end def account_terms_strict_create(conn, %{"terms_validate" => params}) do case MyApp.Forms.Terms.changeset_validate(%MyApp.Forms.Terms{}, params) do %Ecto.Changeset{valid?: true} = changeset -> data = Ecto.Changeset.apply_changes(changeset) conn |> put_flash(:info, "Saved: terms=#{data.terms}") |> redirect(to: "/account") changeset -> changeset = Map.put(changeset, :action, :insert) form = Phoenix.Component.to_form(changeset, as: :terms_validate, id: "account-terms-validate-form" ) render(conn, :account_terms_strict, form: form) end end ``` #### Ecto ```elixir defmodule MyApp.Forms.Terms do use Ecto.Schema import Ecto.Changeset embedded_schema do field :terms, :boolean, default: false end def changeset(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms]) |> validate_acceptance(:terms) end def changeset_validate(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms], message: "can't be blank") |> validate_acceptance(:terms, message: "must be accepted to continue") end end ``` ### Native form (plain HTML) ```heex
``` ### LiveView · Phoenix Form (changeset) #### Heex ```heex <.form for={@form} phx-change="validate" phx-submit="save" > <.checkbox field={@form[:terms]} class="checkbox"> <:label>Accept terms <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <.action type="submit" class="button button--accent"> Submit ``` #### Elixir ```elixir def mount(_params, _session, socket) do form = %MyApp.Forms.Terms{} |> MyApp.Forms.Terms.changeset(%{}) |> Phoenix.Component.to_form(as: :terms) {:ok, assign(socket, :form, form)} end def handle_event("validate", %{"terms" => params}, socket) do changeset = %MyApp.Forms.Terms{} |> MyApp.Forms.Terms.changeset(params) |> Map.put(:action, :validate) {:noreply, assign(socket, :form, Phoenix.Component.to_form(changeset, action: :validate, as: :terms))} end def handle_event("save", %{"terms" => params}, socket) do case MyApp.Forms.Terms.changeset(%MyApp.Forms.Terms{}, params) do %Ecto.Changeset{valid?: true} = _changeset -> {:noreply, assign(socket, :form, Phoenix.Component.to_form(MyApp.Forms.Terms.changeset(%MyApp.Forms.Terms{}, %{}), as: :terms))} changeset -> {:noreply, assign(socket, :form, Phoenix.Component.to_form(changeset, action: :insert, as: :terms))} end end ``` #### Ecto ```elixir defmodule MyApp.Forms.Terms do use Ecto.Schema import Ecto.Changeset embedded_schema do field :terms, :boolean, default: false end def changeset(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms]) |> validate_acceptance(:terms) end def changeset_validate(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms], message: "can't be blank") |> validate_acceptance(:terms, message: "must be accepted to continue") end end ``` ### LiveView · Ecto Changeset (validation) #### Heex ```heex <.form for={@form} phx-change="validate_strict" phx-submit="save_strict" > <.checkbox field={@form[:terms]} class="checkbox"> <:label>Accept terms <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} <.action type="submit" class="button button--accent"> Submit ``` #### Elixir ```elixir def mount(_params, _session, socket) do form = %MyApp.Forms.Terms{} |> MyApp.Forms.Terms.changeset_validate(%{}) |> Phoenix.Component.to_form(as: :terms_strict) {:ok, assign(socket, :strict_form, form)} end def handle_event("validate_strict", %{"terms_strict" => params}, socket) do changeset = %MyApp.Forms.Terms{} |> MyApp.Forms.Terms.changeset_validate(params) |> Map.put(:action, :validate) {:noreply, assign(socket, :strict_form, Phoenix.Component.to_form(changeset, action: :validate, as: :terms_strict))} end def handle_event("save_strict", %{"terms_strict" => params}, socket) do case MyApp.Forms.Terms.changeset_validate(%MyApp.Forms.Terms{}, params) do %Ecto.Changeset{valid?: true} = _changeset -> {:noreply, assign( socket, :strict_form, Phoenix.Component.to_form(MyApp.Forms.Terms.changeset_validate(%MyApp.Forms.Terms{}, %{}), as: :terms_strict) )} changeset -> {:noreply, assign(socket, :strict_form, Phoenix.Component.to_form(changeset, action: :insert, as: :terms_strict))} end end ``` #### Ecto ```elixir defmodule MyApp.Forms.Terms do use Ecto.Schema import Ecto.Changeset embedded_schema do field :terms, :boolean, default: false end def changeset(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms]) |> validate_acceptance(:terms) end def changeset_validate(terms, attrs \\ %{}) do terms |> cast(attrs, [:terms]) |> validate_required([:terms], message: "can't be blank") |> validate_acceptance(:terms, message: "must be accepted to continue") end end ``` ''' @doc type: :component use Phoenix.Component use Corex.Api.Imports, to: Corex.Checkbox.Api alias Corex.Checkable.Helpers, as: CheckableHelpers alias Corex.Checkbox.Anatomy.{ Control, HiddenInput, Indeterminate, Indicator, Label, Props, Root } alias Corex.Checkbox.Connect alias Phoenix.HTML.Form @doc """ Renders a checkbox component. """ attr(:id, :string, required: false, doc: "The id of the checkbox, useful for API to identify the checkbox" ) attr(:checked, :any, default: false, doc: "Checked state: true, false, or :indeterminate (Zag CheckedState). Form fields still use boolean." ) attr(:controlled, :boolean, default: false, doc: "Whether the checkbox is controlled" ) attr(:name, :string, doc: "The name of the checkbox input for form submission") attr(:form, :string, doc: "The form id to associate the checkbox with") attr(:aria_label, :string, default: "Label", doc: "The accessible label for the checkbox" ) attr(:disabled, :boolean, default: false, doc: "Whether the checkbox is disabled" ) attr(:value, :string, default: "true", doc: "The value of the checkbox when checked" ) attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "The direction of the checkbox. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:orientation, :string, default: "horizontal", values: ["vertical", "horizontal"], doc: "Layout orientation for CSS (vertical or horizontal)" ) attr(:read_only, :boolean, default: false, doc: "Whether the checkbox is read-only" ) attr(:invalid, :boolean, default: false, doc: "Whether the checkbox has validation errors" ) attr(:required, :boolean, default: false, doc: "Whether the checkbox is required" ) attr(:on_checked_change, :string, default: nil, doc: "LiveView event when checked changes. `handle_event` receives `%{\"id\" => id, \"checked\" => boolean}`." ) attr(:on_checked_change_client, :string, default: nil, doc: "Browser event type on the checkbox element when checked changes. `event.detail`: `{ id, checked }`." ) attr(:errors, :list, default: [], doc: "List of error messages to display" ) attr(:field, Phoenix.HTML.FormField, doc: "A form field struct retrieved from the form, for example: @form[:email]. Automatically sets id, name, checked state, and errors from the form field" ) attr(:rest, :global) slot :label, required: false do attr(:class, :string, required: false) end slot :indicator, required: false do attr(:class, :string, required: false) end slot :indeterminate, required: false do attr(:class, :string, required: false) end slot :error, required: false do attr(:class, :string, required: false) end def checkbox(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do assigns = assigns |> Corex.FormField.assign_form_field(field) |> assign(:checked, Form.normalize_value("checkbox", field.value)) checkbox(assigns) end def checkbox(assigns) do assigns = assigns |> assign_new(:id, fn -> "checkbox-#{System.unique_integer([:positive])}" end) |> assign_new(:name, fn -> "name-#{System.unique_integer([:positive])}" end) |> assign_new(:form, fn -> nil end) |> assign_new(:form_field, fn -> false end) |> assign(:checked, CheckableHelpers.normalize_checked(assigns.checked)) ~H"""