defmodule PhoenixKitWeb.Live.Components.SearchableSelect do @moduledoc """ Searchable select dropdown LiveComponent. A reusable dropdown with a search input that filters options in real-time. Supports flat options and grouped options (like ``). ## Usage — Flat options <.live_component module={PhoenixKitWeb.Live.Components.SearchableSelect} id="country-select" name="user[country]" value={@selected_country} placeholder="Search countries..." options={[{"United States", "us"}, {"United Kingdom", "uk"}, {"Germany", "de"}]} /> ## Usage — Grouped options <.live_component module={PhoenixKitWeb.Live.Components.SearchableSelect} id="region-select" name="bucket[region]" value={@selected_region} placeholder="Search regions..." grouped_options={[ {"North America", [{"US East (N. Virginia) — us-east-1", "us-east-1"}, ...]}, {"Europe", [{"Europe (Frankfurt) — eu-central-1", "eu-central-1"}, ...]} ]} /> ## Assigns - `id` (required) — unique component ID - `name` (required) — form field name for the hidden input - `value` (string) — currently selected value - `options` — flat list of `{label, value}` tuples - `grouped_options` — list of `{group_label, [{label, value}]}` tuples - `placeholder` — search input placeholder (default: "Search...") - `label` — optional label text above the input - `required` — whether the field is required (default: false) """ use PhoenixKitWeb, :live_component @impl true def mount(socket) do {:ok, socket |> assign(:open, false) |> assign(:search, "") |> assign(:internally_selected, false)} end @impl true def update(assigns, socket) do # Only accept parent's value if we haven't selected internally value = if socket.assigns[:internally_selected] do socket.assigns[:value] else assigns[:value] end socket = socket |> assign(Map.drop(assigns, [:value])) |> assign(:value, value) |> assign_new(:options, fn -> [] end) |> assign_new(:grouped_options, fn -> [] end) |> assign_new(:placeholder, fn -> "Search..." end) |> assign_new(:label, fn -> nil end) |> assign_new(:required, fn -> false end) {:ok, socket} end @impl true def handle_event("toggle", _params, socket) do {:noreply, assign(socket, :open, !socket.assigns.open)} end def handle_event("search", %{"search" => query}, socket) do {:noreply, assign(socket, :search, query)} end def handle_event("select", params, socket) do value = params["selected"] {:noreply, socket |> assign(:value, value) |> assign(:internally_selected, true) |> assign(:open, false) |> assign(:search, "")} end def handle_event("close", _params, socket) do {:noreply, socket |> assign(:open, false) |> assign(:search, "")} end @impl true def render(assigns) do selected_label = find_label(assigns) filtered = filter_options(assigns) assigns = assigns |> assign(:selected_label, selected_label) |> assign(:filtered, filtered) ~H"""
<%= if @label do %> {@label} <% end %> <%!-- Display input --%>
{@selected_label || @placeholder} <.icon name={if @open, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"} class="w-4 h-4 text-base-content/50" />
<%!-- Dropdown --%> <%= if @open do %>
<%!-- Search input --%>
<%!-- Options list --%>
<%= if @filtered == [] do %>
No results found
<% else %> <%= for item <- @filtered do %> <%= case item do %> <% {:group, group_label, options} -> %>
{group_label}
<%= for {opt_label, opt_val} <- options do %> <% end %> <% {:option, opt_label, opt_val} -> %> <% end %> <% end %> <% end %>
<% end %>
""" end defp find_label(assigns) do value = assigns.value if value && value != "" do find_in_flat(assigns.options, value) || find_in_grouped(assigns.grouped_options, value) end end defp find_in_flat(options, value) do case Enum.find(options, fn {_label, v} -> v == value end) do {label, _} -> label nil -> nil end end defp find_in_grouped(groups, value) do Enum.find_value(groups, fn {_group, options} -> find_in_flat(options, value) end) end defp filter_options(assigns) do search = String.downcase(assigns.search || "") if assigns.grouped_options != [] do filter_grouped(assigns.grouped_options, search) else filter_flat(assigns.options, search) end end defp filter_flat(options, "") do Enum.map(options, fn {label, value} -> {:option, label, value} end) end defp filter_flat(options, search) do options |> Enum.filter(fn {label, value} -> String.contains?(String.downcase(label), search) || String.contains?(String.downcase(value), search) end) |> Enum.map(fn {label, value} -> {:option, label, value} end) end defp filter_grouped(groups, "") do Enum.flat_map(groups, fn {group_label, options} -> [{:group, group_label, options}] end) end defp filter_grouped(groups, search) do groups |> Enum.map(fn {group_label, options} -> filtered = Enum.filter(options, fn {label, value} -> String.contains?(String.downcase(label), search) || String.contains?(String.downcase(value), search) || String.contains?(String.downcase(group_label), search) end) {group_label, filtered} end) |> Enum.reject(fn {_group, options} -> options == [] end) |> Enum.flat_map(fn {group_label, options} -> [{:group, group_label, options}] end) end end