Corex.Select (Corex v0.2.0)

View Source

Select for Phoenix LiveView forms and navigation. Behavior follows Zag.js Select.

Anatomy

The placeholder text comes from the translation attribute (default English "Select" is passed through the host Phoenix gettext backend at render time when unchanged). Pass translation={%Select.Translation{placeholder: …}} to customize.

Minimal

<.select
  class="select"
  items={Corex.List.new([
    %{label: "France", value: "fra", disabled: true},
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"},
    %{label: "Netherlands", value: "nld"},
    %{label: "Switzerland", value: "che"},
    %{label: "Austria", value: "aut"}
  ])}
>
  <:trigger>
    <.heroicon name="hero-chevron-down" />
  </:trigger>
</.select>

Grouped

<.select
  class="select"
  items={Corex.List.new([
    %{label: "France", value: "fra", group: "Europe"},
    %{label: "Belgium", value: "bel", group: "Europe"},
    %{label: "Germany", value: "deu", group: "Europe"},
    %{label: "Netherlands", value: "nld", group: "Europe"},
    %{label: "Switzerland", value: "che", group: "Europe"},
    %{label: "Austria", value: "aut", group: "Europe"},
    %{label: "Japan", value: "jpn", group: "Asia"},
    %{label: "China", value: "chn", group: "Asia"},
    %{label: "South Korea", value: "kor", group: "Asia"},
    %{label: "Thailand", value: "tha", group: "Asia"},
    %{label: "USA", value: "usa", group: "North America"},
    %{label: "Canada", value: "can", group: "North America"},
    %{label: "Mexico", value: "mex", group: "North America"}
  ])}
>
  <:trigger>
    <.heroicon name="hero-chevron-down" />
  </:trigger>
</.select>

Custom

This example requires the installation of Flagpack to display the use of custom item rendering.

<.select
  class="select"
  items={Corex.List.new([
    %{label: "France", value: "fra"},
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"},
    %{label: "Netherlands", value: "nld"},
    %{label: "Switzerland", value: "che"},
    %{label: "Austria", value: "aut"}
  ])}
>
  <:label>
    Country of residence
  </:label>
  <:item :let={item}>
    <% Code.ensure_loaded!(Flagpack) %>
    <Flagpack.flag name={String.to_existing_atom(to_string(item.value))} />
    {item.label}
  </:item>
  <:trigger>
    <.heroicon name="hero-chevron-down" />
  </:trigger>
  <:item_indicator>
    <.heroicon name="hero-check" />
  </:item_indicator>
</.select>

Custom Grouped

This example requires the installation of Flagpack to display the use of custom item rendering.

<.select
  class="select"
  items={Corex.List.new([
    %{label: "France", value: "fra", group: "Europe"},
    %{label: "Belgium", value: "bel", group: "Europe"},
    %{label: "Germany", value: "deu", group: "Europe"},
    %{label: "Japan", value: "jpn", group: "Asia"},
    %{label: "China", value: "chn", group: "Asia"},
    %{label: "South Korea", value: "kor", group: "Asia"}
  ])}
>
  <:item :let={item}>
    <% Code.ensure_loaded!(Flagpack) %>
    <Flagpack.flag name={String.to_existing_atom(to_string(item.value))} />
    {item.label}
  </:item>
  <:trigger>
    <.heroicon name="hero-chevron-down" />
  </:trigger>
  <:item_indicator>
    <.heroicon name="hero-check" />
  </:item_indicator>
</.select>

Patterns

Navigation

Set redirect on the component so the first selected value is used as the destination URL. Per item, choose the navigation kind explicitly via the item's :redirect field:

  • :href (default) - full page redirect via window.location (safe everywhere)
  • :patch - LiveView js().patch(url) (caller asserts: same LV mount + matching live route)
  • :navigate - LiveView js().navigate(url) (caller asserts: another LV in the same live_session)
  • false - disable redirect for this item (e.g. let your on_value_change server handler decide)

Set new_tab: true on an item to open its destination in a new tab via window.open. An item may also set :to to override the destination (defaults to the item id).

Build items with Corex.List.new/1. When redirect is true, the client runs single-select in Zag even if multiple is set on the component.

Controller

When not connected to LiveView, the hook always performs a full page redirect via window.location.

<.select
  class="select"
  redirect
  translation={%Corex.Select.Translation{placeholder: "Go to"}}
  items={Corex.List.new([
    %{label: "Account", id: ~p"/account"},
    %{label: "Settings", id: ~p"/settings"}
  ])}
>
  <:trigger>
    <.heroicon name="hero-chevron-down" />
  </:trigger>
</.select>

LiveView

When connected to LiveView, use on_value_change and redirect in the callback. The payload includes value (list); use Enum.at(value, 0) for the destination.

defmodule MyAppWeb.NavLive do
  use MyAppWeb, :live_view

  def handle_event("nav_change", %{"value" => value}, socket) do
    path = Enum.at(value, 0) || ~p"/"
    {:noreply, push_navigate(socket, to: path)}
  end

  def render(assigns) do
    ~H"""
    <.select
      id="nav-select"
      class="select"
      redirect
      on_value_change="nav_change"
      translation={%Corex.Select.Translation{placeholder: "Go to"}}
      items={Corex.List.new([
        %{label: "Account", id: ~p"/account"},
        %{label: "Settings", id: ~p"/settings"}
      ])}
    >
      <:trigger>
        <.heroicon name="hero-chevron-down" />
      </:trigger>
    </.select>
    """
  end
end

Dynamic items

Grow or shrink options at runtime by updating a list assign and passing Corex.List.new(@items) as items. Select options are rebuilt from props after morph (not a LiveView stream consumer); use Phoenix.LiveView.stream/3 only with components that render @streams.* (for example data_table).

<.select class="select" items={Corex.List.new(@items)}>
  <:label>Country</:label>
  <:trigger>
    <.heroicon name="hero-chevron-down" class="icon" />
  </:trigger>
</.select>

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 guide. With field={@form[:…]}, pass auto_invalid for alert borders from visible errors, or invalid={true} to force the alert state.

Multiple selection and {:array, :string} fields

With multiple and field={f[:tags]}, the hidden native <select> submits list params (post[tags][]), matching Phoenix's multi-select convention:

%{"post" => %{"tags" => ["option1", "option2"]}}

Pair with field :tags, {:array, :string} in your schema. Single-select forms still submit one scalar through the hidden value-input.

<.select
  field={@form[:tags]}
  class="select"
  multiple
  controlled
  items={Corex.List.new([
    %{label: "Option 1", value: "option1"},
    %{label: "Option 2", value: "option2"}
  ])}
  translation={%Corex.Select.Translation{placeholder: "Choose tags"}}
>
  <:label>Tags</:label>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
  <:error :let={msg}>
    <.heroicon name="hero-exclamation-circle" class="icon" />
    {msg}
  </:error>
</.select>

For free-form tags (not limited to items), use Corex.TagsInput with the same {:array, :string} field type.

Controller

Build the form from an Ecto changeset:

def form_page(conn, _params) do
  form =
    %MyApp.Form.SelectForm{}
    |> MyApp.Form.SelectForm.changeset(%{})
    |> Phoenix.Component.to_form(as: :select_form, id: "select-form")
  render(conn, :form_page, form: form)
end
<.form :let={f} for={@form} action={@action} method="post">
  <.select
    field={f[:country]}
    class="select"
    translation={%Corex.Select.Translation{placeholder: "Select a country"}}
    items={Corex.List.new([
      %{label: "France", value: "fra", disabled: true},
      %{label: "Belgium", value: "bel"},
      %{label: "Germany", value: "deu"},
      %{label: "Netherlands", value: "nld"},
      %{label: "Switzerland", value: "che"},
      %{label: "Austria", value: "aut"}
    ])}
  >
    <:label>Your country of residence</:label>
    <:trigger>
      <.heroicon name="hero-chevron-down" />
    </:trigger>
    <:error :let={msg}>
      <.heroicon name="hero-exclamation-circle" class="icon" />
      {msg}
    </:error>
  </.select>
  <button type="submit">Submit</button>
</.form>

Live View

When using in a Live view you must add controlled mode. Prefer building the form from an Ecto changeset (see "With Ecto changeset" below).

With Ecto changeset

When using Ecto changeset for validation and inside a Live view you must enable the controlled mode.

This allows the Live View to be the source of truth and the component to be in sync accordingly.

First create your schema and changeset:

defmodule MyApp.Accounts.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    field :country, :string
    timestamps(type: :utc_datetime)
  end

  def changeset(user, attrs) do
    user
    |> cast(attrs, [:name, :country])
    |> validate_required([:name, :country])
  end
end
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">
      <.select
        field={@form[:country]}
        class="select"
        controlled
        translation={%Corex.Select.Translation{placeholder: "Select a country"}}
        items={Corex.List.new([
          %{label: "France", value: "fra"},
          %{label: "Belgium", value: "bel"},
          %{label: "Germany", value: "deu"}
        ])}
      >
        <:label>Your country of residence</:label>
        <:trigger>
          <.heroicon name="hero-chevron-down" />
        </:trigger>
        <:error :let={msg}>
          <.heroicon name="hero-exclamation-circle" class="icon" />
          {msg}
        </:error>
      </.select>
    </.form>
    """
  end
end

API

Requires a stable id on <.select>.

FunctionActionReturns
set_value/2Set selection (client)%Phoenix.LiveView.JS{}
set_value/3Set selection (server)socket
set_open/2Open or close menu (client)%Phoenix.LiveView.JS{}
set_open/3Open or close menu (server)socket

set_value

<.action phx-click={Corex.Select.set_value("select-api-bind", ["fra"])} class="button ui-size-sm">France</.action>
<.select id="select-api-bind" class="select" items={
  Corex.List.new([
    %{label: "France", value: "fra"},
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"}
  ])
}>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
</.select>
def handle_event("select_api_set_value", _, socket) do
  {:noreply, Corex.Select.set_value(socket, "select-api-srv", ["fra"])}
end

Events

Server events

EventWhenPayload
on_value_change="select_value_changed"Selection changes%{"id" => id, "value" => values, "path" => path, "items" => items}

on_value_change

<.select
  class="select"
  items={Corex.List.new([
    %{label: "France", value: "fra"},
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"}
  ])}
  on_value_change="select_value_changed"
>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
</.select>
def handle_event("select_value_changed", %{"value" => value}, socket) do
  {:noreply, assign(socket, :selected, value)}
end

Client events

EventWhenevent.detail
on_value_change_client="select-value-changed"Selection changesid, value, items

on_value_change_client

<.select
  id="select-events-client"
  class="select"
  items={Corex.List.new([
    %{label: "France", value: "fra"},
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"}
  ])}
  on_value_change_client="select-value-changed"
>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
</.select>
document.getElementById("select-events-client")?.addEventListener("select-value-changed", (e) => {
  console.log(e.detail);
});

Style

Target parts with data-scope and data-part:

[data-scope="select"][data-part="root"] {}
[data-scope="select"][data-part="control"] {}
[data-scope="select"][data-part="label"] {}
[data-scope="select"][data-part="input"] {}
[data-scope="select"][data-part="error"] {}
[data-scope="select"][data-part="trigger"] {}
[data-scope="select"][data-part="item-group"] {}
[data-scope="select"][data-part="item-group-label"] {}
[data-scope="select"][data-part="item"] {}
[data-scope="select"][data-part="item-text"] {}
[data-scope="select"][data-part="item-indicator"] {}
@import "../corex/corex.css";

Stack modifiers on <.select class="select ...">. Combine axes, for example select ui-accent ui-size-lg or select ui-info ui-solid.

Axes: Semantic (ui-accent, ui-brand, ui-alert, ui-info, ui-success), Variant (ui-solid), Size (ui-size-smui-size-xl), Radius (ui-rounded-*), Max height (ui-max-height-* on the host; clamps content). See the modifier guide.

Semantic modifiers set palette variables on the trigger. Variant modifiers control trigger surface treatment. Default is subtle; add ui-solid for a filled trigger. Selected menu items still use the semantic palette.

Semantic

Palette variables for select ink and fill. Does not change surface treatment by itself.

ModifierClasses
Defaultselect
Accentselect ui-accent
Brandselect ui-brand
Alertselect ui-alert
Successselect ui-success
Infoselect ui-info

Variant

Visual treatment of the trigger surface. Combine with a semantic modifier for palette-driven ink and fill.

ModifierClasses
Subtle (default)select or select ui-accent
Solidselect ui-accent ui-solid

Size

ModifierClasses
SMselect ui-size-sm
MDselect ui-size-md
LGselect ui-size-lg
XLselect ui-size-xl

Max height

Opt-in clamp on the dropdown content. Example: select ui-max-height-xs.

Rounded

ModifierClasses
Noneselect ui-rounded-none
SMselect ui-rounded-sm
MDselect ui-rounded-md
LGselect ui-rounded-lg
XLselect ui-rounded-xl
Fullselect ui-rounded-full

Max width

ModifierClasses
SMselect max-w-sm
MDselect max-w-md
LGselect max-w-lg
XLselect max-w-xl

Summary

API

Open or close the listbox from a control (phx-click).

Set open state from handle_event. Pushes select_set_open.

Set selected value(s) from a control (phx-click). Pass one value or a list (wrapped internally).

Set selected value(s) from handle_event. Pushes select_set_value.

Components

select(assigns)

Attributes

  • id (:string) - The id of the select component. Defaults to nil.

  • field (Phoenix.HTML.FormField) - A form field struct retrieved from the form, for example: @form[:country]. Automatically sets id, name, value, and errors from the form field. Defaults to nil.

  • name (:string) - The name of the select. Defaults to nil.

  • form (:string) - The id of the form of the select. Defaults to nil.

  • invalid (:boolean) - Whether the control has validation errors. Defaults to nil.

  • auto_invalid (:boolean) - When true with field, set invalid from visible changeset errors (default false). Defaults to false.

  • controlled (:boolean) - Whether the control is controlled. Defaults to false.

  • disabled (:boolean) - Whether the control is disabled. Defaults to false.

  • read_only (:boolean) - Whether the select is read only. Defaults to false.

  • required (:boolean) - Whether the control is required. Defaults to false.

  • items (:list) - Items from Corex.List.new/1, or plain maps with :label (see Corex.List for the full contract). Defaults to [].

  • value (:list) - The value of the select. Defaults to [].

  • close_on_select (:boolean) - Whether to close the select on select. Defaults to true.

  • dir (:string) - The direction of the select (ltr or rtl). Defaults to nil. Must be one of nil, "ltr", or "rtl".

  • orientation (:string) - Layout orientation for CSS (vertical or horizontal). Defaults to "vertical". Must be one of "vertical", or "horizontal".

  • loop_focus (:boolean) - Whether to loop focus the select. Defaults to false.

  • multiple (:boolean) - Allow multiple selection. With field and form, submits name[] list params for Ecto {:array, :string}. Defaults to false.

  • deselectable (:boolean) - Whether the selected items can be deselected. Defaults to false.

  • update_trigger (:boolean) - When false, the hook does not overwrite trigger item-text from the selected label. Defaults to true.

  • on_value_change (:string) - Server event name to push on value change. Payload includes value (list), path (current path without locale), id, items. Use Enum.at(value, 0) for the first selected value. Defaults to nil.

  • on_value_change_client (:any) - Client-side only: either a string (CustomEvent name to dispatch) or a Phoenix.LiveView.JS command. For JS commands, placeholders are replaced at run time: __VALUE__ (selected value(s) as JSON array), __VALUE_0__ (first value). For redirect-on-select use redirect instead (no placeholders).

    Defaults to nil.

  • redirect (:boolean) - When true, selecting a value triggers redirect-on-select. Each item picks the navigation kind via :redirect (:href (default) | :patch | :navigate | false). Items may also set :to (overrides the destination) and :new_tab (opens in a new tab). When true, the client runs single-select in Zag even if multiple is set on this component.

    Defaults to false.

  • positioning (Corex.Positioning) - Positioning options for the dropdown. Defaults to %Corex.Positioning{hide_when_detached: true, strategy: "fixed", placement: "bottom", gutter: 8, shift: 0, overflow_padding: 0, arrow_padding: 4, flip: true, slide: true, overlap: false, same_width: true, fit_viewport: true, offset: nil}.

  • translation (Corex.Select.Translation) - Translatable strings for the select. Defaults to nil.

  • errors (:list) - List of error messages to display. Defaults to [].

  • Global attributes are accepted.

Slots

  • label - The label content. Accepts attributes:
    • class (:string)
  • trigger (required) - The trigger button content. Accepts attributes:
    • class (:string)
  • item_indicator - Optional indicator for selected items. Accepts attributes:
    • class (:string)
  • error - Accepts attributes:
    • class (:string)
  • item - Custom content for each item. Receives the item as :let binding. Accepts attributes:
    • class (:string)

API

set_open(select_id, open)

@spec set_open(String.t(), boolean()) :: Phoenix.LiveView.JS.t()

Open or close the listbox from a control (phx-click).

<.action phx-click={Corex.Select.set_open("my-select", true)}>Open</.action>
<.select
  id="my-select"
  class="select"
  items={Corex.List.new([%{label: "Belgium", value: "bel"}])}
>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
</.select>
document.getElementById("my-select")?.dispatchEvent(
  new CustomEvent("corex:select:set-open", {
    bubbles: false,
    detail: { open: true },
  })
);

set_open(socket, select_id, open)

Set open state from handle_event. Pushes select_set_open.

<.action phx-click="open_select">Open</.action>
<.select
  id="my-select"
  class="select"
  items={Corex.List.new([%{label: "Belgium", value: "bel"}])}
>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
</.select>
def handle_event("open_select", _, socket) do
  {:noreply, Corex.Select.set_open(socket, "my-select", true)}
end

set_value(select_id, value)

Set selected value(s) from a control (phx-click). Pass one value or a list (wrapped internally).

<.action phx-click={Corex.Select.set_value("my-select", "bel")}>Belgium</.action>
<.select
  id="my-select"
  class="select"
  items={Corex.List.new([
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"}
  ])}
>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
</.select>
document.getElementById("my-select")?.dispatchEvent(
  new CustomEvent("corex:select:set-value", {
    bubbles: false,
    detail: { value: ["bel"] },
  })
);

set_value(socket, select_id, value)

Set selected value(s) from handle_event. Pushes select_set_value.

<.action phx-click="pick_bel" phx-value-value="bel">Belgium</.action>
<.select
  id="my-select"
  class="select"
  items={Corex.List.new([
    %{label: "Belgium", value: "bel"},
    %{label: "Germany", value: "deu"}
  ])}
>
  <:trigger><.heroicon name="hero-chevron-down" /></:trigger>
</.select>
def handle_event("pick_bel", %{"value" => v}, socket) do
  {:noreply, Corex.Select.set_value(socket, "my-select", v)}
end