Packages

phoenix_kit

1.7.200
1.7.209 1.7.208 1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit_web live components searchable_select.ex
Raw

lib/phoenix_kit_web/live/components/searchable_select.ex

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 `<optgroup>`).
## 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"""
<fieldset class="fieldset relative" id={@id}>
<%= if @label do %>
<legend class="fieldset-legend font-semibold">{@label}</legend>
<% end %>
<input type="hidden" name={@name} value={@value || ""} />
<%!-- Display input --%>
<div
class="input input-bordered w-full flex items-center justify-between cursor-pointer"
phx-click="toggle"
phx-target={@myself}
>
<span class={if @value, do: "text-base-content", else: "text-base-content/50"}>
{@selected_label || @placeholder}
</span>
<.icon
name={if @open, do: "hero-chevron-up-mini", else: "hero-chevron-down-mini"}
class="w-4 h-4 text-base-content/50"
/>
</div>
<%!-- Dropdown --%>
<%= if @open do %>
<div class="absolute top-full left-0 right-0 mt-1 bg-base-100 border border-base-300 rounded-lg shadow-xl max-h-64 overflow-hidden flex flex-col z-50">
<%!-- Search input --%>
<form
phx-change="search"
phx-target={@myself}
phx-submit="search"
class="p-2 border-b border-base-200"
>
<input
type="text"
value={@search}
placeholder={@placeholder}
class="input input-sm input-bordered w-full"
name="search"
autocomplete="off"
autofocus
phx-debounce="100"
id={"#{@id}-search"}
/>
</form>
<%!-- Options list --%>
<div class="overflow-y-auto max-h-52">
<%= if @filtered == [] do %>
<div class="p-3 text-sm text-base-content/50 text-center">
No results found
</div>
<% else %>
<%= for item <- @filtered do %>
<%= case item do %>
<% {:group, group_label, options} -> %>
<div class="px-3 pt-2 pb-1 text-xs font-semibold text-base-content/50 uppercase tracking-wider">
{group_label}
</div>
<%= for {opt_label, opt_val} <- options do %>
<button
type="button"
phx-click="select"
phx-value-selected={opt_val}
phx-target={@myself}
class={[
"w-full text-left px-3 py-2 text-sm hover:bg-base-200 cursor-pointer flex items-center justify-between",
@value == opt_val && "bg-primary/10 text-primary font-medium"
]}
>
<span>{opt_label}</span>
<%= if @value == opt_val do %>
<.icon name="hero-check-mini" class="w-4 h-4 text-primary" />
<% end %>
</button>
<% end %>
<% {:option, opt_label, opt_val} -> %>
<button
type="button"
phx-click="select"
phx-value-selected={opt_val}
phx-target={@myself}
class={[
"w-full text-left px-3 py-2 text-sm hover:bg-base-200 cursor-pointer flex items-center justify-between",
@value == opt_val && "bg-primary/10 text-primary font-medium"
]}
>
<span>{opt_label}</span>
<%= if @value == opt_val do %>
<.icon name="hero-check-mini" class="w-4 h-4 text-primary" />
<% end %>
</button>
<% end %>
<% end %>
<% end %>
</div>
</div>
<% end %>
</fieldset>
"""
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