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 ```heex <.form :let={f} for={@changeset} id={get_form_id(@changeset)}> <.select class="select" field={f[:country]} 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 ```heex <.form for={@form} id={get_form_id(@form)} phx-change="validate" phx-submit="save"> <.select class="select" field={@form[:country]} 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} ``` The `field` attribute automatically handles: - Setting the `id` from the form field - Setting the `name` for form submission - Mapping the form value to the `select` value - Displaying validation errors - Integration with Phoenix changesets ## 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 ''' 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] -> # No groups, flat list Enum.map(collection, fn item -> {item.label, item.id} end) _ -> # Has groups grouped |> Enum.sort_by(fn {group, _} -> group || "" end) |> Enum.flat_map(fn {nil, items} -> Enum.map(items, fn item -> {item.label, item.id} end) {group, items} -> [{group, Enum.map(items, fn item -> {item.label, item.id} end)}] end) end end 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