Corex. Select
(Corex v0.1.1)
View Source
Phoenix implementation of 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}>
<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}>
<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 viawindow.location(safe everywhere):patch- LiveViewjs().patch(url)(caller asserts: same LV mount + matching live route):navigate- LiveViewjs().navigate(url)(caller asserts: another LV in the samelive_session)false- disable redirect for this item (e.g. let youron_value_changeserver 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
endStream
Use Phoenix.LiveView.stream/3 to add or remove options at runtime. Keep @items_list in sync and pass Corex.List.new(@items_list) as items. Configure dom_id as select:stream-select:item:#{value}.
<.select class="select" items={Corex.List.new(@items_list)}>
<: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. Pass invalid={Corex.FormField.invalid?(@form[:field])} when you want alert borders after validation.
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
enddefmodule 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
endAPI
Requires a stable id on <.select>.
| Function | Action | Returns |
|---|---|---|
set_value/2 | Set selection (client) | %Phoenix.LiveView.JS{} |
set_value/3 | Set selection (server) | socket |
set_open/2 | Open or close menu (client) | %Phoenix.LiveView.JS{} |
set_open/3 | Open or close menu (server) | socket |
set_value
<.action phx-click={Corex.Select.set_value("select-api-bind", ["fra"])} class="button button--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"])}
endEvents
Server events
| Event | When | Payload |
|---|---|---|
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)}
endClient events
| Event | When | event.detail |
|---|---|---|
on_value_change_client="select-value-changed" | Selection changes | id, 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/main.css";
@import "../corex/tokens/themes/neo/light.css";
@import "../corex/components/select.css";Stack modifiers on <.select class="select ...">.
Color
Color modifiers apply theme ink to the trigger label and chevron, and to selected menu items.
| Modifier | Classes |
|---|---|
| Default | select |
| Accent | select select--accent |
| Brand | select select--brand |
| Alert | select select--alert |
| Success | select select--success |
| Info | select select--info |
Size
| Modifier | Classes |
|---|---|
| SM | select select--sm |
| MD | select select--md |
| LG | select select--lg |
| XL | select select--xl |
Text
| Modifier | Classes |
|---|---|
| SM | select select--text-sm |
| XL | select select--text-xl |
| 2XL | select select--text-2xl |
| 4XL | select select--text-4xl |
Rounded
| Modifier | Classes |
|---|---|
| None | select select--rounded-none |
| SM | select select--rounded-sm |
| MD | select select--rounded-md |
| LG | select select--rounded-lg |
| XL | select select--rounded-xl |
| Full | select select--rounded-full |
Max width
| Modifier | Classes |
|---|---|
| SM | select max-w-sm |
| MD | select max-w-md |
| LG | select max-w-lg |
| XL | select 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
Attributes
id(:string) - The id of the select component.items(:list) - List of items fromCorex.List.new/1(or maps with :label and optional :value). Defaults to[].controlled(:boolean) - Whether the select is controlled. Defaults tofalse.value(:list) - The value of the select. Defaults to[].disabled(:boolean) - Whether the select is disabled. Defaults tofalse.close_on_select(:boolean) - Whether to close the select on select. Defaults totrue.dir(:string) - The direction of the select (ltr or rtl). Defaults tonil. Must be one ofnil,"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 tofalse.multiple(:boolean) - Allow multiple selection. With field and form, submits name[] list params for Ecto {:array, :string}. Defaults tofalse.invalid(:boolean) - Whether the select is invalid. Defaults tofalse.name(:string) - The name of the select.form(:string) - The id of the form of the select.read_only(:boolean) - Whether the select is read only. Defaults tofalse.required(:boolean) - Whether the select is required. Defaults tofalse.deselectable(:boolean) - Whether the selected items can be deselected. Defaults tofalse.update_trigger(:boolean) - When false, the hook does not overwrite trigger item-text from the selected label. Defaults totrue.on_value_change(:string) - Server event name to push on value change. Payload includesvalue(list),path(current path without locale),id,items. UseEnum.at(value, 0)for the first selected value. Defaults tonil.on_value_change_client(:any) - Client-side only: either a string (CustomEvent name to dispatch) or aPhoenix.LiveView.JScommand. For JS commands, placeholders are replaced at run time:__VALUE__(selected value(s) as JSON array),__VALUE_0__(first value). For redirect-on-select useredirectinstead (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 ifmultipleis 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 tonil.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.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
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 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 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 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