defmodule Corex.Select do @moduledoc ~S''' Phoenix implementation of [Zag.js Select](https://zagjs.com/components/react/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 ```heex <.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" /> ``` ### Grouped ```heex <.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" /> ``` ### Custom This example requires the installation of [Flagpack](https://hex.pm/packages/flagpack) to display the use of custom item rendering. ```heex <.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 <:item :let={item}> {item.label} <:trigger> <.heroicon name="hero-chevron-down" /> <:item_indicator> <.heroicon name="hero-check" /> ``` ### Custom Grouped This example requires the installation of [Flagpack](https://hex.pm/packages/flagpack) to display the use of custom item rendering. ```heex <.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}> {item.label} <:trigger> <.heroicon name="hero-chevron-down" /> <:item_indicator> <.heroicon name="hero-check" /> ``` ## 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`. ```heex <.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" /> ``` ### 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. ```elixir 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" /> """ end end ``` ### Stream 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}`. ```heex <.select class="select" items={Corex.List.new(@items_list)}> <:label>Country <:trigger> <.heroicon name="hero-chevron-down" class="icon" /> ``` ## 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](forms.html) 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 `
{render_slot(@label)}
{render_slot(@error, msg)}
  • {group}
    • {render_slot(@item, item)} {item.label} {render_slot(@item_indicator)}
  • {render_slot(@item, item)} {item.label} {render_slot(@item_indicator)}
""" end api_doc(~S""" Set selected value(s) from a control (`phx-click`). Pass one value or a list (wrapped internally). ```heex <.action phx-click={Corex.Select.set_value("my-select", "bel")}>Belgium <.select id="my-select" class="select" items={Corex.List.new([ %{label: "Belgium", value: "bel"}, %{label: "Germany", value: "deu"} ])} > <:trigger><.heroicon name="hero-chevron-down" /> ``` ```javascript document.getElementById("my-select")?.dispatchEvent( new CustomEvent("corex:select:set-value", { bubbles: false, detail: { value: ["bel"] }, }) ); ``` """) def set_value(select_id, value) when is_binary(select_id) do JS.dispatch("corex:select:set-value", to: "##{select_id}", detail: %{value: validate_value!(List.wrap(value))}, bubbles: false ) end api_doc(~S""" Set selected value(s) from `handle_event`. Pushes `select_set_value`. ```heex <.action phx-click="pick_bel" phx-value-value="bel">Belgium <.select id="my-select" class="select" items={Corex.List.new([ %{label: "Belgium", value: "bel"}, %{label: "Germany", value: "deu"} ])} > <:trigger><.heroicon name="hero-chevron-down" /> ``` ```elixir def handle_event("pick_bel", %{"value" => v}, socket) do {:noreply, Corex.Select.set_value(socket, "my-select", v)} end ``` """) def set_value(socket, select_id, value) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(select_id) do RespondTo.push_set_value( socket, "select_set_value", select_id, validate_value!(List.wrap(value)) ) end api_doc(~S""" Open or close the listbox from a control (`phx-click`). ```heex <.action phx-click={Corex.Select.set_open("my-select", true)}>Open <.select id="my-select" class="select" items={Corex.List.new([%{label: "Belgium", value: "bel"}])} > <:trigger><.heroicon name="hero-chevron-down" /> ``` ```javascript document.getElementById("my-select")?.dispatchEvent( new CustomEvent("corex:select:set-open", { bubbles: false, detail: { open: true }, }) ); ``` """) def set_open(select_id, open) when is_binary(select_id) and is_boolean(open) do JS.dispatch("corex:select:set-open", to: "##{select_id}", detail: %{open: open}, bubbles: false ) end api_doc(~S""" Set open state from `handle_event`. Pushes `select_set_open`. ```heex <.action phx-click="open_select">Open <.select id="my-select" class="select" items={Corex.List.new([%{label: "Belgium", value: "bel"}])} > <:trigger><.heroicon name="hero-chevron-down" /> ``` ```elixir def handle_event("open_select", _, socket) do {:noreply, Corex.Select.set_open(socket, "my-select", true)} end ``` """) def set_open(socket, select_id, open) when is_struct(socket, Phoenix.LiveView.Socket) and is_binary(select_id) and is_boolean(open) do RespondTo.push_set_open(socket, "select_set_open", select_id, open) end defp get_disabled_values(collection) do collection |> Enum.filter(&Map.get(&1, :disabled, false)) |> Enum.map(& &1.value) end defp value_for_hidden_input(value_list, _multiple) when value_list == [], do: "" defp value_for_hidden_input(value_list, false), do: List.first(value_list) defp value_for_hidden_input(value_list, true), do: Enum.join(value_list, ",") defp transform_collection_to_options(items) do grouped = group_by_group(items) case grouped do [{nil, all_items}] -> Enum.map(all_items, &{&1.label, &1.value}) _ -> Enum.flat_map(grouped, &group_to_options/1) end end defp group_to_options({nil, items}), do: Enum.map(items, &{&1.label, &1.value}) defp group_to_options({group, items}), do: [{group, Enum.map(items, &{&1.label, &1.value})}] defp get_value(field_value) do case field_value do nil -> [] [] -> [] value when is_list(value) -> Enum.map(value, &to_string/1) value -> [to_string(value)] end end defp get_selected_label(collection, value) do case value do [] -> nil _ -> value |> Enum.map(fn val -> collection |> Enum.find(&(to_string(&1.value) == val)) end) |> Enum.filter(& &1) |> Enum.map(& &1.label) |> case do [] -> nil labels -> Enum.join(labels, ", ") end end end end