Autocomplete field in Phoenix Live View, Surface, and TailwindCSS
I know it's a mouthful, but hear me out:
I've been looking for an autocomplete field that worked for my purposes (lookup in the database, not in memory, and support multiselect and the ability to limit the number of items a field could support.
Since it couldn't find it and being the dumb dev that i am, i obviously wrote my own. It's meant for Surface forms, but with a bit of tinkering you can make it work with plain LiveView.
defmodule MyProjectWeb.Live.Components.Autocomplete do
@moduledoc """
Autocomplete component to lookup a single item from the database
"""
use MyProjectWeb, :surface_component
alias Surface.Components.Form
@doc """
The form field name for the data that we are looking up
"""
prop field_name, :atom, required: true
@doc """
The label of the field
"""
slot default, required: true
@doc """
Currently selected values for the field
"""
prop current_values, :map, default: %{}
@doc """
Suggestions that the user can select
"""
prop suggestions, :list, required: true
@doc """
Add and remove events that will get triggered
as user clicks on the suggestions or asks to
remove one of the current values
"""
prop add_event, :event, required: true
prop remove_event, :event, required: true
@doc """
Maximum number of items this field allows for. Controls
whether to show the search field or not.
"""
prop max_items, :integer, default: 9_999_999
@impl true
def render(assigns) do
assigns =
assign(assigns, :suggestions, filter_existing(assigns.suggestions, assigns.current_values))
~F"""
<div id={@id}>
<div class="form-control">
<Form.Label><#slot /></Form.Label>
<div class="flex flex-row items-start">
<div class="flex flex-row">
{#for {id, title} <- @current_values}
<div class="badge badge-secondary p-3 mr-2 cursor-pointer flex-1">
{title}
<div :on-click={@remove_event} phx-value-id={id}>
<Heroicons.Solid.x_circle class="ml-2 h-6 w-6 cursor-pointer" />
</div>
</div>
{/for}
</div>
<div class="flex flex-col">
<Form.TextInput
id={"autocomplete_#{@field_name}"}
name={"autocomplete_#{@field_name}"}
class="inline-block max-h-8"
opts={phx_debounce: 500, autocomplete: "off", placeholder: "Search..."}
value=""
:if={Enum.count(@current_values) < @max_items}
/>
<div
:if={Enum.any?(@suggestions)}
class="mb-3 grid grid-cols-1 divide-y border-t border-b text-sm border-neutral"
>
{#for {id, title} <- @suggestions}
<div
class="p-1 cursor-pointer border-x pl-3 pr-3 hover:bg-secondary border-neutral"
:on-click={@add_event}
phx-value-id={id}
>
{title}
</div>
{/for}
</div>
</div>
</div>
<Form.ErrorTag />
</div>
</div>
"""
end
defp filter_existing(suggestions, current_values) do
Enum.reject(suggestions, fn s -> Enum.member?(current_values, s) end)
end
end
The gist of it is:
- The current_values property accepts what the database object already contains in that field (probably a list of id's.
- The suggestions property receives a map for which the key is the id of the suggestion and the value is the title/name to be displayed when suggesting.
- The two events control what gets called when the user adds (i.e. clicks a suggestion) or removes (i.e. clicks remove on an existing item. These events will be controlled on the outside.
- The max_items property defines at which point do we stop showing the search box for this field (i.e. the max number of selected items was reached).
The rest is pretty simple:
Whenever the user enters something in the search field, the form will get it's change event triggered and we only need to intercept that event based on whether we are asking for a completion or not. This is easy to achieve, for instance, like described next.
My form component contains this:
@impl true
def handle_event(
"change",
%{
"_target" => ["autocomplete_user"],
"autocomplete_user" => query
},
socket
) do
cond do
String.length(query) < 2 ->
{:noreply, assign(socket, :user_autocomplete_suggestions, [])}
true ->
suggestions =
query
|> Users.find()
|> Enum.map(fn %{id: id, title: title} ->
{id, title}
end)
{:noreply, assign(socket, :user_autocomplete_suggestions, suggestions)}
end
end
As you can see, the suggestions for that field get set as a list of tuples (as accepted on the autocomplete component) and those suggestions are passed in when we update state. Here's the render of the component:
<Autocomplete
id="users_lookup"
field_name={:users}
current_values={users_autocomplete_values(@blog)}
suggestions={@users_autocomplete_suggestions}
max_items={1}
add_event="add_users"
remove_event="remove_users"
>
User
</Autocomplete>
The users_autocomplete_values(@blog) is basically building the currently assigned user to the blog:
defp user_autocomplete_values(%{user_id: nil}), do: %{}
defp user_autocomplete_values(%{user: %{id: id, name: name}}),
do: %{id => name}
As you can see, the function returns a map where the key is the id, and the value is the displayed name, as per what the component expects. Also, this is the case where you want to autocomplete only one value. Here's an example function for more than one:
defp user_autocomplete_values(%{users: users}) do
users
|> Enum.map(fn %{id: id, name: name} ->
{id, name}
end)
|> Map.new()
end
Here the blog would have multiple users. Maybe not the best practical example, but you get the idea of how that current_values property can be built.
Finally, we only need to implement the add and remove functions on the form component (or live view). For a single item autocomplete something like this would work:
@impl true
def handle_event("add_user", %{"id" => id} = _event, socket) do
validate_and_save(
%{"blog" => %{"user_id" => id}},
assign(socket, :user_autocomplete_suggestions, [])
)
end
@impl true
def handle_event("remove_user", %{"id" => _id} = _event, socket) do
validate_and_save(%{"blog" => %{"user_id" => nil}}, socket)
end
And if you wanted to support multiple users, something like this should do the trick:
@impl true
def handle_event("add_user", %{"id" => id} = _event, socket) do
existing_user_ids = Enum.map(socket.assigns.customer.users, & &1.id)
validate_and_save(
%{"customer" => %{"customer_users" => [id | existing_user_ids]}},
assign(socket, :users_autocomplete_suggestions, [])
)
end
@impl true
def handle_event("remove_user", %{"id" => id} = _event, socket) do
existing_user_ids = Enum.map(socket.assigns.customer.users, & &1.id)
new_user_ids = List.delete(existing_user_ids, String.to_integer(id))
validate_and_save(
%{"customer" => %{"customer_users" => new_user_ids}},
assign(socket, :users_autocomplete_suggestions, [])
)
end
Happy coding!