Corex.Checkbox (Corex v0.1.0)

View Source

Phoenix implementation of Zag.js Checkbox.

Anatomy

Minimal

<.checkbox class="checkbox">
  <:label>Option</:label>
</.checkbox>

Label and indicator

<.checkbox class="checkbox">
  <:label>Accept the terms</:label>
  <:indicator>
    <.heroicon name="hero-check" />
  </:indicator>
</.checkbox>

Invalid

<.checkbox
  class="checkbox checkbox--accent"
  invalid
  checked
  errors={["Required"]}
>
  <:label>Subscribe</:label>
  <:indicator>
    <.heroicon name="hero-check" />
  </:indicator>
  <:error :let={msg}>
    <.heroicon name="hero-exclamation-circle" class="icon" />
    {msg}
  </:error>
</.checkbox>

Indeterminate

<.checkbox class="checkbox" checked={:indeterminate}>
  <:label>Select some rows</:label>
  <:indicator>
    <.heroicon name="hero-check" />
  </:indicator>
  <:indeterminate>
    <.heroicon name="hero-minus" />
  </:indeterminate>
</.checkbox>

API

Requires a stable id on <.checkbox>. Imperative helpers set or toggle checked state (boolean only; clears indeterminate).

FunctionActionReturns
set_checked/2Set checked state (client)%Phoenix.LiveView.JS{}
set_checked/3Set checked state (server)socket
toggle_checked/1Toggle checked state (client)%Phoenix.LiveView.JS{}
toggle_checked/2Toggle checked state (server)socket

set_checked

<.action phx-click={Corex.Checkbox.set_checked("checkbox-api-bind", true)} class="button button--sm">
  Set checked
</.action>
<.action phx-click={Corex.Checkbox.set_checked("checkbox-api-bind", false)} class="button button--sm">
  Set unchecked
</.action>
<.action phx-click={Corex.Checkbox.toggle_checked("checkbox-api-bind")} class="button button--sm">
  Toggle
</.action>
<.checkbox id="checkbox-api-bind" class="checkbox">
  <:label>Terms</:label>
  <:indicator>
    <.heroicon name="hero-check" />
  </:indicator>
  <:indeterminate>
    <.heroicon name="hero-minus" />
  </:indeterminate>
</.checkbox>

set_checked (dispatch)

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 }));
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

EventWhenPayload
on_checked_change="checkbox_changed"User toggles checked state%{"id" => id, "checked" => boolean}

on_checked_change

<.checkbox
  class="checkbox"
  on_checked_change="checkbox_changed"
>
  <:label>Subscribe</:label>
  <:indicator>
    <.heroicon name="hero-check" />
  </:indicator>
</.checkbox>
def handle_event("checkbox_changed", %{"id" => id, "checked" => checked}, socket) do
  {:noreply, assign(socket, :checked, checked)}
end

Client events

EventWhenevent.detail
on_checked_change_client="checkbox-changed"User toggles checked stateid, checked

on_checked_change_client

<.checkbox
  id="checkbox-on-checked-change-client"
  class="checkbox"
  on_checked_change_client="checkbox-changed"
>
  <:label>Subscribe</:label>
  <:indicator>
    <.heroicon name="hero-check" />
  </:indicator>
</.checkbox>
document.getElementById("checkbox-on-checked-change-client")?.addEventListener(
  "checkbox-changed",
  (event) => console.log(event.detail)
);

Patterns

Async

Heex

<.async_result :let={checkbox} assign={@checkbox}>
  <:loading><.checkbox_skeleton class="checkbox" /></:loading>
  <.checkbox class="checkbox" checked={checkbox.checked}>
    <:label>Accept terms</:label>
    <:indicator><.heroicon name="hero-check" /></:indicator>
    <:indeterminate><.heroicon name="hero-minus" /></:indeterminate>
  </.checkbox>
</.async_result>

Elixir

socket =
  assign_async(socket, :checkbox, fn ->
    Process.sleep(1000)
    {:ok, %{checkbox: %{checked: true}}}
  end)

Controlled (LiveView)

Heex

<.checkbox
  class="checkbox"
  controlled
  checked={@checked}
  on_checked_change="patterns_controlled_changed"
>
  <:label>Accept terms</:label>
  <:indicator><.heroicon name="hero-check" /></:indicator>
  <:indeterminate><.heroicon name="hero-minus" /></:indeterminate>
</.checkbox>

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.

[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"] {}
@import "../corex/main.css";
@import "../corex/tokens/themes/neo/light.css";
@import "../corex/components/checkbox.css";

Color

ModifierClasses
Defaultcheckbox
Accentcheckbox checkbox--accent
Brandcheckbox checkbox--brand
Alertcheckbox checkbox--alert
Infocheckbox checkbox--info
Successcheckbox checkbox--success
<.checkbox class="checkbox" checked>
      <:label>Default</:label>
      <:indicator>
        <.heroicon name="hero-check" />
      </:indicator>
      <:indeterminate>
        <.heroicon name="hero-minus" />
      </:indeterminate>
    </.checkbox>
    <.checkbox class="checkbox checkbox--accent" checked>
      <:label>Accent</:label>
      <:indicator>
        <.heroicon name="hero-check" />
      </:indicator>
      <:indeterminate>
        <.heroicon name="hero-minus" />
      </:indeterminate>
    </.checkbox>
    <.checkbox class="checkbox checkbox--brand" checked>
      <:label>Brand</:label>
      <:indicator>
        <.heroicon name="hero-check" />
      </:indicator>
      <:indeterminate>
        <.heroicon name="hero-minus" />
      </:indeterminate>
    </.checkbox>
    <.checkbox class="checkbox checkbox--alert" checked>
      <:label>Alert</:label>
      <:indicator>
        <.heroicon name="hero-check" />
      </:indicator>
      <:indeterminate>
        <.heroicon name="hero-minus" />
      </:indeterminate>
    </.checkbox>
    <.checkbox class="checkbox checkbox--info" checked>
      <:label>Info</:label>
      <:indicator>
        <.heroicon name="hero-check" />
      </:indicator>
      <:indeterminate>
        <.heroicon name="hero-minus" />
      </:indeterminate>
    </.checkbox>
    <.checkbox class="checkbox checkbox--success" checked>
      <:label>Success</:label>
      <:indicator>
        <.heroicon name="hero-check" />
      </:indicator>
      <:indeterminate>
        <.heroicon name="hero-minus" />
      </:indeterminate>
    </.checkbox>

Size

ModifierClasses
SMcheckbox checkbox--sm
Defaultcheckbox
LGcheckbox checkbox--lg
XLcheckbox checkbox--xl
<.checkbox class="checkbox checkbox--sm">
      <:label>Small</:label>
    </.checkbox>
    <.checkbox class="checkbox">
      <:label>Default</:label>
    </.checkbox>
    <.checkbox class="checkbox checkbox--lg">
      <:label>Large</:label>
    </.checkbox>
    <.checkbox class="checkbox checkbox--xl">
      <:label>XLarge</:label>
    </.checkbox>

Invalid

Invalid styles the label and control border. Checked indicators keep their semantic fill color.

<.checkbox class="checkbox checkbox--accent" invalid checked errors={["Required"]}>
  <:label>Subscribe</:label>
  <:indicator>
    <.heroicon name="hero-check" />
  </:indicator>
  <:error :let={msg}>
    <.heroicon name="hero-exclamation-circle" class="icon" />
    {msg}
  </:error>
</.checkbox>

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 guide. Pass invalid={Corex.FormField.invalid?(@form[:terms])} when you want alert borders after validation.

Phoenix Form (changeset)

Heex

    <.form
      :let={f}
      for={@form}
      action="/account/terms"
      method="post"
    >
      <.checkbox field={f[:terms]} class="checkbox">
        <:label>Accept terms</:label>
        <:error :let={msg}>
          <.heroicon name="hero-exclamation-circle" class="icon" />
          {msg}
        </:error>
      </.checkbox>

      <.action type="submit" class="button button--accent">
        Submit
      </.action>
    </.form>

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

    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

    <.form
      :let={f}
      for={@form}
      action="/account/terms"
      method="post"
    >
      <.checkbox field={f[:terms]} class="checkbox">
        <:label>Accept terms (strict messages)</:label>
        <:error :let={msg}>
          <.heroicon name="hero-exclamation-circle" class="icon" />
          {msg}
        </:error>
      </.checkbox>

      <.action type="submit" class="button button--accent">
        Submit
      </.action>
    </.form>

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

    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)

    <form
      action="/register"
      method="post"
    >
      <input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
      <.checkbox
        name="user[accept_terms]"
        class="checkbox"
      >
        <:label>Accept terms</:label>
      </.checkbox>
      <.action type="submit" class="button button--accent">Submit</.action>
    </form>

LiveView · Phoenix Form (changeset)

Heex

    <.form
      for={@form}
     
      phx-change="validate"
      phx-submit="save"
    >
      <.checkbox field={@form[:terms]} class="checkbox">
        <:label>Accept terms</:label>
        <:error :let={msg}>
          <.heroicon name="hero-exclamation-circle" class="icon" />
          {msg}
        </:error>
      </.checkbox>

      <.action type="submit" class="button button--accent">
        Submit
      </.action>
    </.form>

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

    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

    <.form
      for={@form}
     
      phx-change="validate_strict"
      phx-submit="save_strict"
    >
      <.checkbox field={@form[:terms]} class="checkbox">
        <:label>Accept terms</:label>
        <:error :let={msg}>
          <.heroicon name="hero-exclamation-circle" class="icon" />
          {msg}
        </:error>
      </.checkbox>

      <.action type="submit" class="button button--accent">
        Submit
      </.action>
    </.form>

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

    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

Summary

Components

Renders a checkbox component.

Renders a loading skeleton for the checkbox component.

API

Set checked state from a control (phx-click). Clears indeterminate when applied.

Set checked state from handle_event. Pushes checkbox_set_checked (no reply event).

Toggle checked state from a control (phx-click).

Toggle checked from handle_event. Pushes checkbox_toggle_checked (no reply event).

Components

checkbox(assigns)

Renders a checkbox component.

Attributes

  • id (:string) - The id of the checkbox, useful for API to identify the checkbox.
  • checked (:any) - Checked state: true, false, or :indeterminate (Zag CheckedState). Form fields still use boolean. Defaults to false.
  • controlled (:boolean) - Whether the checkbox is controlled. Defaults to false.
  • name (:string) - The name of the checkbox input for form submission.
  • form (:string) - The form id to associate the checkbox with.
  • aria_label (:string) - The accessible label for the checkbox. Defaults to "Label".
  • disabled (:boolean) - Whether the checkbox is disabled. Defaults to false.
  • value (:string) - The value of the checkbox when checked. Defaults to "true".
  • dir (:string) - The direction of the checkbox. When nil, derived from document (html lang + config :rtl_locales). Defaults to nil. Must be one of nil, "ltr", or "rtl".
  • orientation (:string) - Layout orientation for CSS (vertical or horizontal). Defaults to "horizontal". Must be one of "vertical", or "horizontal".
  • read_only (:boolean) - Whether the checkbox is read-only. Defaults to false.
  • invalid (:boolean) - Whether the checkbox has validation errors. Defaults to false.
  • required (:boolean) - Whether the checkbox is required. Defaults to false.
  • on_checked_change (:string) - LiveView event when checked changes. handle_event receives %{"id" => id, "checked" => boolean}. Defaults to nil.
  • on_checked_change_client (:string) - Browser event type on the checkbox element when checked changes. event.detail: { id, checked }. Defaults to nil.
  • errors (:list) - List of error messages to display. Defaults to [].
  • field (Phoenix.HTML.FormField) - A form field struct retrieved from the form, for example: @form[:email]. Automatically sets id, name, checked state, and errors from the form field.
  • Global attributes are accepted.

Slots

  • label - Accepts attributes:
    • class (:string)
  • indicator - Accepts attributes:
    • class (:string)
  • indeterminate - Accepts attributes:
    • class (:string)
  • error - Accepts attributes:
    • class (:string)

checkbox_skeleton(assigns)

Renders a loading skeleton for the checkbox component.

Attributes

  • skeleton_label (:boolean) - When true, renders a compact label-line placeholder (same line height band as the real checkbox label). Defaults to true.
  • dir (:string) - Same as checkbox: logical direction for layout. Defaults to nil. Must be one of nil, "ltr", or "rtl".
  • orientation (:string) - Same as checkbox: layout orientation for the skeleton root. Defaults to "horizontal". Must be one of "vertical", or "horizontal".
  • Global attributes are accepted.

API

set_checked(checkbox_id, checked)

Set checked state from a control (phx-click). Clears indeterminate when applied.

<.action phx-click={Corex.Checkbox.set_checked("my-checkbox", true)}>Check</.action>
<.checkbox id="my-checkbox" class="checkbox">
  <:label>Option</:label>
</.checkbox>
document.getElementById("my-checkbox")?.dispatchEvent(
  new CustomEvent("corex:checkbox:set-checked", {
    bubbles: false,
    detail: { checked: true },
  })
);

set_checked(socket, checkbox_id, checked)

Set checked state from handle_event. Pushes checkbox_set_checked (no reply event).

<.action phx-click="check_box">Check</.action>
<.checkbox id="my-checkbox" class="checkbox">
  <:label>Option</:label>
</.checkbox>
def handle_event("check_box", _, socket) do
  {:noreply, Corex.Checkbox.set_checked(socket, "my-checkbox", true)}
end

toggle_checked(checkbox_id)

Toggle checked state from a control (phx-click).

<.action phx-click={Corex.Checkbox.toggle_checked("my-checkbox")}>Toggle</.action>
<.checkbox id="my-checkbox" class="checkbox">
  <:label>Option</:label>
</.checkbox>
document.getElementById("my-checkbox")?.dispatchEvent(
  new CustomEvent("corex:checkbox:toggle-checked", { bubbles: false })
);

toggle_checked(socket, checkbox_id)

Toggle checked from handle_event. Pushes checkbox_toggle_checked (no reply event).

<.action phx-click="toggle_box">Toggle</.action>
<.checkbox id="my-checkbox" class="checkbox">
  <:label>Option</:label>
</.checkbox>
def handle_event("toggle_box", _, socket) do
  {:noreply, Corex.Checkbox.toggle_checked(socket, "my-checkbox")}
end