defmodule Corex.Select do @moduledoc ~S''' Phoenix implementation of [Zag.js Select](https://zagjs.com/components/react/select). ## Examples The placeholder can be set via the `placeholder_text` attribute or via the optional `:placeholder` slot. When both are provided, the slot content is shown (the slot overrides the attribute). ### Minimal This example assumes the import of `.icon` from `Core Components`, you are free to replace it ```heex <.select id="my-select" class="select" placeholder_text="Select a country" collection={[ %{label: "France", id: "fra", disabled: true}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}, %{label: "Netherlands", id: "nld"}, %{label: "Switzerland", id: "che"}, %{label: "Austria", id: "aut"} ]} > <:trigger> <.icon name="hero-chevron-down" /> ``` ### Grouped This example assumes the import of `.icon` from `Core Components`, you are free to replace it ```heex <.select class="select" placeholder_text="Select a country" collection={[ %{label: "France", id: "fra", group: "Europe"}, %{label: "Belgium", id: "bel", group: "Europe"}, %{label: "Germany", id: "deu", group: "Europe"}, %{label: "Netherlands", id: "nld", group: "Europe"}, %{label: "Switzerland", id: "che", group: "Europe"}, %{label: "Austria", id: "aut", group: "Europe"}, %{label: "Japan", id: "jpn", group: "Asia"}, %{label: "China", id: "chn", group: "Asia"}, %{label: "South Korea", id: "kor", group: "Asia"}, %{label: "Thailand", id: "tha", group: "Asia"}, %{label: "USA", id: "usa", group: "North America"}, %{label: "Canada", id: "can", group: "North America"}, %{label: "Mexico", id: "mex", group: "North America"} ]} > <:trigger> <.icon name="hero-chevron-down" /> ``` ### With custom placeholder slot Use the `:placeholder` slot to fully customize the placeholder (e.g. icon + text): ```heex <.select id="my-select" class="select" collection={[...]} > <:placeholder> <.icon name="hero-globe-alt" class="opacity-60" /> Choose a country <:trigger> <.icon 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. This example assumes the import of `.icon` from `Core Components`, you are free to replace it ```heex <.select class="select" placeholder_text="Select a country" collection={[ %{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}, %{label: "Netherlands", id: "nld"}, %{label: "Switzerland", id: "che"}, %{label: "Austria", id: "aut"} ]} > <:label> Country of residence <:item :let={item}> {item.label} <:trigger> <.icon name="hero-chevron-down" /> <:item_indicator> <.icon 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. This example assumes the import of `.icon` from `Core Components`, you are free to replace it ```heex <.select class="select" placeholder_text="Select a country" collection={[ %{label: "France", id: "fra", group: "Europe"}, %{label: "Belgium", id: "bel", group: "Europe"}, %{label: "Germany", id: "deu", group: "Europe"}, %{label: "Japan", id: "jpn", group: "Asia"}, %{label: "China", id: "chn", group: "Asia"}, %{label: "South Korea", id: "kor", group: "Asia"} ]} > <:item :let={item}> {item.label} <:trigger> <.icon name="hero-chevron-down" /> <:item_indicator> <.icon name="hero-check" /> ``` ## Use as Navigation Set `redirect` so the first selected value is used as the destination URL. Per item: `redirect: false` disables redirect; `new_tab: true` opens in a new tab. ### Controller When not connected to LiveView, the hook automatically performs a full page redirect via `window.location`. ```heex <.select id="nav-select" class="select" redirect placeholder_text="Go to" collection={[ %{label: "Account", id: ~p"/account"}, %{label: "Settings", id: ~p"/settings"} ]} > <:trigger> <.icon 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" placeholder_text="Go to" collection={[ %{label: "Account", id: ~p"/account"}, %{label: "Settings", id: ~p"/settings"} ]} > <:trigger> <.icon name="hero-chevron-down" /> """ end end ``` ## Phoenix Form Integration When using with Phoenix forms, you must add an id to the form using the `Corex.Form.get_form_id/1` function. ### Controller ```elixir defmodule MyAppWeb.PageController do use MyAppWeb, :controller def home(conn, params) do form = Phoenix.Component.to_form(Map.get(params, "user", %{}), as: :user) render(conn, :home, form: form) end end ``` ```heex <.form :let={f} as={:user} for={@form} id={get_form_id(@form)} method="get"> <.select field={f[:country]} class="select" placeholder_text="Select a country" collection={[ %{label: "France", id: "fra", disabled: true}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}, %{label: "Netherlands", id: "nld"}, %{label: "Switzerland", id: "che"}, %{label: "Austria", id: "aut"} ]} > <:label>Your country of residence <:trigger> <.icon name="hero-chevron-down" /> <:error :let={msg}> <.icon name="hero-exclamation-circle" class="icon" /> {msg} ``` ### Live View When using Phoenix form in a Live view you must also add controlled mode. This allows the Live view to be the source of truth and the component to be in sync accordingly. ```elixir defmodule MyAppWeb.SelectLive do use MyAppWeb, :live_view def mount(_params, _session, socket) do form = to_form(%{"country" => nil}, as: :user) {:ok, assign(socket, :form, form)} end def render(assigns) do ~H""" <.form as={:user} for={@form} id={get_form_id(@form)}> <.select field={@form[:country]} class="select" controlled placeholder_text="Select a country" collection={[ %{label: "France", id: "fra", disabled: true}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"}, %{label: "Netherlands", id: "nld"}, %{label: "Switzerland", id: "che"}, %{label: "Austria", id: "aut"} ]} > <:label>Your country of residence <:trigger> <.icon name="hero-chevron-down" /> <:error :let={msg}> <.icon name="hero-exclamation-circle" class="icon" /> {msg} """ end end ``` ### 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: ```elixir 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 ``` ```elixir 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} id={get_form_id(@form)} phx-change="validate"> <.select field={@form[:country]} class="select" controlled placeholder_text="Select a country" collection={[ %{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"} ]} > <:label>Your country of residence <:trigger> <.icon name="hero-chevron-down" /> <:error :let={msg}> <.icon name="hero-exclamation-circle" class="icon" /> {msg} """ end end ``` ## API Control ```heex # Client-side # Server-side def handle_event("set_value", _, socket) do {:noreply, Corex.Select.set_value(socket, "my-select", "fra")} end ``` ## Styling Use data attributes to target elements: ```css [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"] {} ``` If you wish to use the default Corex styling, you can use the class `select` 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/select.css"; ``` You can then use modifiers ```heex <.select class="select select--accent select--lg"> ``` Learn more about modifiers and [Corex Design](https://corex-ui.com/components/select#modifiers) ''' use Phoenix.Component alias Corex.Select.Connect alias Corex.Select.Anatomy.{Props, Root, Label, Control, Positioner, Content} attr(:id, :string, required: false) attr(:collection, :list, default: []) attr(:controlled, :boolean, default: false, doc: "Whether the select is controlled") attr(:placeholder_text, :string, default: nil, doc: "The placeholder text of the select when no value is selected" ) attr(:value, :list, default: [], doc: "The value of the select") attr(:disabled, :boolean, default: false, doc: "Whether the select is disabled") attr(:close_on_select, :boolean, default: true, doc: "Whether to close the select on select") attr(:dir, :string, default: nil, doc: "The direction of the select. When nil, derived from document (html lang + config :rtl_locales)" ) attr(:loop_focus, :boolean, default: false, doc: "Whether to loop focus the select") attr(:multiple, :boolean, default: false, doc: "Whether to allow multiple selection") attr(:invalid, :boolean, default: false, doc: "Whether the select is invalid") attr(:name, :string, doc: "The name of the select") attr(:form, :string, doc: "The id of the form of the select") attr(:read_only, :boolean, default: false, doc: "Whether the select is read only") attr(:required, :boolean, default: false, doc: "Whether the select is required") attr(:prompt, :string, default: nil, doc: "the prompt for select inputs") attr(:on_value_change, :string, default: nil, doc: "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." ) attr(:on_value_change_client, :any, default: nil, doc: """ 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). """ ) attr(:redirect, :boolean, default: false, doc: "When true, the first selected value is used as the destination URL. When not connected the hook sets window.location; when connected use on_value_change and redirect(socket, to: Enum.at(value, 0)) in your handler. Same approach as menu's redirect. Per item: set redirect: false on an item to disable redirect for that item; set new_tab: true to open that item's URL in a new tab." ) attr(:positioning, Corex.Positioning, default: %Corex.Positioning{}, doc: "Positioning options for the dropdown" ) attr(:rest, :global) slot :label, required: false, doc: "The label content" do attr(:class, :string, required: false) end slot(:trigger, required: true, doc: "The trigger button content") slot(:placeholder, required: false, doc: "Custom placeholder content. When provided, overrides the placeholder_text attribute" ) slot(:item_indicator, required: false, doc: "Optional indicator for selected items") slot :error, required: false do attr(:class, :string, required: false) end slot(:item, required: false, doc: "Custom content for each item. Receives the item as :let binding" ) attr(:field, Phoenix.HTML.FormField, doc: "A form field struct retrieved from the form, for example: @form[:country]. Automatically sets id, name, value, and errors from the form field" ) attr(:errors, :list, default: [], doc: "List of error messages to display" ) def select(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do errors = if Phoenix.Component.used_input?(field), do: field.errors, else: [] value = get_value(field.value) selected_label = get_selected_label(assigns.collection, value) assigns |> assign(field: nil) |> assign(:errors, Enum.map(errors, &Corex.Gettext.translate_error(&1))) |> assign_new(:id, fn -> field.id end) |> assign_new(:form, fn -> field.form.id end) |> assign_new(:name, fn -> field.name end) |> assign(:value, value) |> assign(:selected_label, selected_label) |> select() end def select(assigns) do assigns = assigns |> assign_new(:id, fn -> "select-#{System.unique_integer([:positive])}" end) |> assign_new(:name, fn -> "name-#{System.unique_integer([:positive])}" end) |> assign_new(:form, fn -> nil end) value = Map.get(assigns, :value, []) value_list = get_value(value) selected_label = get_selected_label(assigns.collection, value_list) assigns = assign(assigns, :selected_label, selected_label) options = transform_collection_to_options(assigns.collection) grouped_items = Enum.group_by(assigns.collection, &Map.get(&1, :group)) has_groups = grouped_items |> Map.keys() |> Enum.any?(& &1) selected_for_options = if assigns.multiple do value_list else if value_list == [], do: "", else: List.first(value_list) end options_with_prompt = [{"", ""} | options] assigns = assigns |> assign(:grouped_items, grouped_items) |> assign(:has_groups, has_groups) |> assign(:options, options) |> assign(:options_with_prompt, options_with_prompt) |> assign(:selected_for_options, selected_for_options) |> assign(:disabled_values, get_disabled_values(assigns.collection)) |> assign(:value_for_hidden_input, value_for_hidden_input(value_list, assigns.multiple)) ~H"""
{render_slot(@label)}
{render_slot(@error, msg)}
  • (a || "") <= (b || "") end)} data-scope="select" data-part="item-group" data-id={group || "default"}>
    {group}
    • {render_slot(@item, item)} {item.label} {render_slot(@item_indicator)}
  • {render_slot(@item, item)} {item.label} {render_slot(@item_indicator)}
""" end defp get_disabled_values(collection) do collection |> Enum.filter(&Map.get(&1, :disabled, false)) |> Enum.map(& &1.id) 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(collection) do grouped = Enum.group_by(collection, &Map.get(&1, :group)) case Map.keys(grouped) do [nil] -> Enum.map(collection, &{&1.label, &1.id}) _ -> grouped |> Enum.sort_by(fn {group, _} -> group || "" end) |> Enum.flat_map(&group_to_options/1) end end defp group_to_options({nil, items}), do: Enum.map(items, &{&1.label, &1.id}) defp group_to_options({group, items}), do: [{group, Enum.map(items, &{&1.label, &1.id})}] 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(&(&1.id == val)) end) |> Enum.filter(& &1) |> Enum.map(& &1.label) |> case do [] -> nil labels -> Enum.join(labels, ", ") end end end end