defmodule Corex.Select do import Corex.Gettext, only: [gettext: 1] @moduledoc ~S''' Phoenix implementation of [Zag.js Select](https://zagjs.com/components/react/select). ## Examples The placeholder text comes from the Translation struct. Use `translation={%Select.Translation{ placeholder: gettext("Select an option") }}` to customize. ### Minimal ```heex <.select id="my-select" class="select" items={[ %{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> <.heroicon name="hero-chevron-down" /> ``` ### Grouped ```heex <.select class="select" items={[ %{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> <.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={[ %{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> <.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={[ %{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> <.heroicon name="hero-chevron-down" /> <:item_indicator> <.heroicon 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 translation={%Corex.Select.Translation{placeholder: "Go to"}} items={[ %{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={[ %{label: "Account", id: ~p"/account"}, %{label: "Settings", id: ~p"/settings"} ]} > <:trigger> <.heroicon 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 Build the form from an Ecto changeset: ```elixir 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 ``` ```heex <.form :let={f} for={@form} id={Corex.Form.get_form_id(@form)} action={@action} method="post"> <.select field={f[:country]} class="select" translation={%Corex.Select.Translation{placeholder: "Select a country"}} items={[ %{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> <.heroicon name="hero-chevron-down" /> <:error :let={msg}> <.heroicon name="hero-exclamation-circle" class="icon" /> {msg} ``` ### 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: ```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 translation={%Corex.Select.Translation{placeholder: "Select a country"}} items={[ %{label: "France", id: "fra"}, %{label: "Belgium", id: "bel"}, %{label: "Germany", id: "deu"} ]} > <:label>Your country of residence <:trigger> <.heroicon name="hero-chevron-down" /> <:error :let={msg}> <.heroicon 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) ''' defmodule Translation do @moduledoc """ Translation struct for Select component strings. Without gettext: `translation={%Select.Translation{ placeholder: "Choose an option" }}` With gettext: `translation={%Select.Translation{ placeholder: gettext("Select an option") }}` """ defstruct [:placeholder] end use Phoenix.Component alias Corex.Select.Anatomy.{Content, Control, Label, Positioner, Props, Root} alias Corex.Select.Connect import Corex.Helpers, only: [normalize_items: 1, has_groups?: 1, group_by_group: 1] attr(:id, :string, required: false, doc: "The id of the select component") attr(:items, :list, default: [], doc: "List of items (maps with :id and :label, or Corex.List.Item)" ) attr(:controlled, :boolean, default: false, doc: "Whether the select is controlled") 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(:orientation, :string, default: "vertical", values: ["vertical", "horizontal"], doc: "Layout orientation for CSS (vertical or horizontal)" ) 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(:translation, Corex.Select.Translation, default: nil, doc: "Override translatable strings") 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" do attr(:class, :string, required: false) end slot :item_indicator, required: false, doc: "Optional indicator for selected items" do attr(:class, :string, required: false) end 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" do attr(:class, :string, required: false) end 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.items, 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 items = normalize_items(assigns.items) default_translation = %Translation{placeholder: gettext("Select an option")} translation = assigns[:translation] || default_translation placeholder = translation.placeholder assigns = assigns |> assign(:items, items) |> 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) |> assign(:translation, translation) |> assign(:placeholder, placeholder) value = Map.get(assigns, :value, []) value_list = get_value(value) selected_label = get_selected_label(items, value_list) assigns = assign(assigns, :selected_label, selected_label) options = transform_collection_to_options(items) grouped_items = group_by_group(items) has_groups = has_groups?(items) 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(items)) |> assign(:value_for_hidden_input, value_for_hidden_input(value_list, assigns.multiple)) ~H"""
{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 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(items) do grouped = group_by_group(items) case grouped do [{nil, all_items}] -> Enum.map(all_items, &{&1.label, &1.id}) _ -> Enum.flat_map(grouped, &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