defmodule Corex.Select do @moduledoc ~S''' Phoenix implementation of [Zag.js Select](https://zagjs.com/components/react/select). ## Examples ### 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="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="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" /> ``` ### 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="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="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" /> ``` ## 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="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="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="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: - `[data-scope="select"][data-part="root"]` - Label wrapper - `[data-scope="select"][data-part="control"]` - Select control - `[data-scope="select"][data-part="label"]` - Label text - `[data-scope="select"][data-part="input"]` - Hidden input - `[data-scope="select"][data-part="error"]` - Error message State-specific styling: - `[data-state="open"]` - When select is open - `[data-state="closed"]` - When select is closed - `[data-disabled]` - When select is disabled - `[data-readonly]` - When select is read-only - `[data-invalid]` - When select has validation errors If you wish to use the default Corex styling, you can use the class `select` on the component. This requires to install mix 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, :string, default: nil, doc: "The placeholder of the select") 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: "ltr", doc: "The direction of the select") 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: "The server event name to trigger on value change" ) attr(:on_value_change_client, :string, default: nil, doc: "The client event name to trigger on value change" ) attr(:bubble, :boolean, default: false, doc: "Whether the client events are bubbled") attr(:positioning, Corex.Positioning, default: %Corex.Positioning{}, doc: "Positioning options for the dropdown" ) attr(:rest, :global) slot(:label, required: false, doc: "The label content") slot(:trigger, required: true, doc: "The trigger button content") 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)}
{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