Hi,
i would like to share the following component that you can use in your phoenix forms to provide a input field with a customizable source of suggestions. Could be used as a tagging component etc.
defmodule MyProjectWeb.Components.SelectComponent do
use MyProjectWeb, :live_component
def render(assigns) do
~H"""
<div class="flex flex-row items-baseline gap-4 break-inside-avoid" phx-target={@myself}>
<.label><%= @label %></.label>
<div class="w-full">
<div class="flex flex-wrap items-center gap-1">
<.inputs_for :let={f} field={@form[@field_name]}>
<.tag_badge color={@color_function.(f[:type].value)}>
<%= f[:name].value %>
<:actions>
<button
type="button"
phx-click="autocomplete_remove"
phx-target={@myself}
phx-value-index={f.index}
class="hover:text-red-500 pl-1"
>
×
</button>
</:actions>
</.tag_badge>
<.input field={f[:id]} type="hidden" />
<.input field={f[:name]} type="hidden" />
<input type="hidden" name={"case[#{@field_name}_sort][]"} value={f.index} />
</.inputs_for>
</div>
<.input
field={@form[:"new_#{@field_name}"]}
type="text"
placeholder={@placeholder}
phx-keydown="autocomplete_key"
onkeydown="return (event.keyCode!=13);"
phx-target={@myself}
/>
<input type="hidden" name={"case[#{@field_name}_drop][]"} />
<div>
<%= for {suggestion, index} <- Enum.with_index(@suggestions) do %>
<div
class={"suggestion #{if index == @selected_index, do: "bg-indigo-200"} rounded-sm p-1"}
phx-click="autocomplete_select"
phx-target={@myself}
phx-value-index={index}
>
<%= suggestion.name %>
</div>
<% end %>
</div>
</div>
</div>
"""
end
def mount(socket) do
{:ok, assign(socket, suggestions: [], selected_index: 0)}
end
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
def handle_event("autocomplete_key", %{"key" => key, "value" => value}, socket) do
case key do
"ArrowDown" ->
{:noreply, update_selected_index(socket, 1)}
"ArrowUp" ->
{:noreply, update_selected_index(socket, -1)}
"Enter" ->
selected = get_from_suggestions(socket, socket.assigns.selected_index) || %{name: value}
{:noreply, socket.assigns.on_select.(socket, selected)}
_ ->
suggestions = socket.assigns.search_function.(value)
{:noreply, assign(socket, suggestions: suggestions, selected_index: 0)}
end
end
## In real life there seems to be always a value, but to make tests easier
## this def allows events without value
def handle_event("autocomplete_key", %{"key" => key}, socket) do
handle_event("autocomplete_key", %{"key" => key, "value" => ""}, socket)
end
def handle_event("autocomplete_remove", %{"index" => index}, socket) do
{:noreply, socket.assigns.on_remove.(socket, index)}
end
def handle_event("autocomplete_select", %{"index" => index}, socket) do
{int, _} = Integer.parse(index)
selected = get_from_suggestions(socket, int)
{:noreply, socket.assigns.on_select.(socket, selected)}
end
defp get_from_suggestions(socket, index) do
Enum.at(socket.assigns.suggestions, index)
end
defp update_selected_index(socket, direction) do
update(
socket,
:selected_index,
&rem(
&1 + direction + length(socket.assigns.suggestions),
length(socket.assigns.suggestions)
)
)
end
end
use it like this:
<.live_component
module={SelectComponent}
id={@id}
form={@form}
field_name="tags"
label="Tags"
placeholder="Enter a tag"
search_function={&search_suggestions/1}
color_function={&get_color_for_actor/1}
on_select={&handle_select/2}
on_remove={&handle_remove/2}
/>
You need to define these callback functions for it to work…