defmodule Corex.Checkbox do @moduledoc ~S''' Phoenix implementation of [Zag.js Checkbox](https://zagjs.com/components/react/checkbox). ## API and events Client DOM dispatches (handled by the `Checkbox` hook on the component root): - `corex:checkbox:set-checked` — `detail.checked` must be a boolean; clears indeterminate. - `corex:checkbox:toggle-checked` — toggles checked. Server pushes (from `set_checked/3`, `toggle_checked/2`): - `checkbox_set_checked` — `%{"id" => id, "checked" => boolean}` - `checkbox_toggle_checked` — `%{"id" => id}` Declarative `checked` may be `true`, `false`, or `:indeterminate` (Zag `CheckedState`). Imperative `set_checked/2` remains boolean-only. ## Examples ### Minimal ```heex <.checkbox class="checkbox"> <:label>Accept terms ``` ### Custom Control ```heex <.checkbox class="checkbox"> <:label> Accept the terms <:indicator> <.heroicon name="hero-check" /> <:indeterminate> <.heroicon name="hero-minus" /> ``` ### Custom Error ```heex <.checkbox class="checkbox"> <:label> Accept the terms <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ## Phoenix Form Integration When using with Phoenix forms, set the form `id` in `to_form/2` and use `id={@form.id}` on `<.form>`. ### Controller Build the form from an Ecto changeset and pass it to the template. Pass `id` into `to_form/2` so the template can use `id={@form.id}`: ```elixir def checkbox_form_page(conn, _params) do form = %MyApp.Form.Terms{} |> MyApp.Form.Terms.changeset(%{}) |> Phoenix.Component.to_form(as: :terms, id: "checkbox-form") render(conn, :checkbox_form_page, form: form) end ``` ```heex <.form :let={f} for={@form} id={@form.id} action={@action} method="post"> <.checkbox field={f[:terms]} class="checkbox"> <:label>Accept terms <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ### Live View When using Phoenix form in a Live view you must also add controlled mode. Prefer building the form from an Ecto changeset (see "With Ecto changeset" below). ### With Ecto changeset (LiveView) When using an Ecto changeset for validation in a LiveView, enable the `controlled` attribute on the checkbox so the LiveView remains the source of truth. Schema and changeset: ```elixir defmodule MyApp.Form.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 end ``` LiveView with validate and submit: ```elixir defmodule MyAppWeb.CheckboxFormLive do use MyAppWeb, :live_view alias MyApp.Form.Terms def mount(_params, _session, socket) do form = %Terms{} |> Terms.changeset(%{}) |> to_form(as: :terms, id: "checkbox-form-terms") {:ok, assign(socket, :form, form)} end def handle_event("validate", %{"terms" => params}, socket) do changeset = Terms.changeset(%Terms{}, params) {:noreply, assign(socket, :form, to_form(changeset, action: :validate, as: :terms, id: "checkbox-form-terms"))} end def handle_event("save", %{"terms" => params}, socket) do case Terms.changeset(%Terms{}, params) do %Ecto.Changeset{valid?: true} = _ -> {:noreply, assign(socket, :form, to_form(Terms.changeset(%Terms{}, %{}), as: :terms, id: "checkbox-form-terms"))} changeset -> {:noreply, assign(socket, :form, to_form(changeset, action: :insert, as: :terms, id: "checkbox-form-terms"))} end end def render(assigns) do ~H""" <.form for={@form} id={@form.id} phx-change="validate" phx-submit="save"> <.checkbox field={@form[:terms]} class="checkbox" controlled> <:label>Accept terms <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} """ end end ``` ## API Control ```heex # Client-side # Server-side def handle_event("check", _, socket) do {:noreply, Corex.Checkbox.set_checked(socket, "my-checkbox", true)} end def handle_event("toggle", _, socket) do {:noreply, Corex.Checkbox.toggle_checked(socket, "my-checkbox")} end ``` ## Styling Use data attributes to target elements: ```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"] {} ``` If you wish to use the default Corex styling, you can use the class `checkbox` 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/checkbox.css"; ``` You can then use modifiers ```heex <.checkbox class="checkbox checkbox--accent checkbox--lg"> ``` components/checkbox#modifiers) ''' @doc type: :component use Phoenix.Component alias Corex.Checkbox.Anatomy.{ Control, HiddenInput, Indeterminate, Indicator, Label, Props, Root } alias Corex.Checkbox.Connect alias Corex.Helpers alias Phoenix.HTML.Form alias Phoenix.LiveView alias Phoenix.LiveView.JS @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: "ltr", values: ["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: "The server event name when the checked state changes" ) attr(:on_checked_change_client, :string, default: nil, doc: "The client event name when the checked state changes" ) 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 errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] assigns = assigns |> assign(field: nil) |> assign(:errors, Enum.map(errors, &Corex.Gettext.translate_error(&1))) |> assign_new(:id, fn -> field.id end) |> assign_new(:name, fn -> field.name end) |> assign(:checked, Form.normalize_value("checkbox", field.value)) |> assign_new(:form, fn -> field.form.id end) 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(:checked, Helpers.normalize_checkbox_checked(assigns.checked)) ~H"""