Tutorial
Typeahead with LiveView and Tailwind
This post was updated 27 Mar
In this tutorial I want to show how easy it is to do an autocomplete or typeahead without any additional javascript. And I want to have something like typeahead.js
STEP 1
First I will create the new LiveView component called search_form_live.ex
defmodule MyAppWeb.SearchFormLive do
use Phoenix.LiveView
def mount(%{}, socket) do
assigns = [
conn: socket,
search_results: [],
search_phrase: ""
]
{:ok, assign(socket, assigns)}
end
def render(assigns) do
MyAppWeb.PageView.render("search_form.html", assigns)
end
end
This also requires me to create a file called search_form.html.heex and that file needs to be placed inside lib/my_app_web/templates/page/ folder.
STEP 2
For this tutorial, I will just make a very simple search function based on a hard coded list of American states. So in the search_form_live.ex component, I will add:
def search(""), do: []
def search(search_phrase) do
states()
|> Enum.filter(& matches?(&1, search_phrase))
end
def matches?(first, second) do
String.starts_with?(
String.downcase(first), String.downcase(second)
)
end
def states do
[
"Alabama", "Alaska", "Arizona", "Arkansas", "California",
"Colorado", "Connecticut", "Delaware", "Florida", "Georgia", "Hawaii",
"Idaho", "Illinois", "Indiana", "Iowa", "Kansas", "Kentucky", "Louisiana",
"Maine", "Maryland", "Massachusetts", "Michigan", "Minnesota",
"Mississippi", "Missouri", "Montana", "Nebraska", "Nevada", "New Hampshire",
"New Jersey", "New Mexico", "New York", "North Carolina", "North Dakota",
"Ohio", "Oklahoma", "Oregon", "Pennsylvania", "Rhode Island",
"South Carolina", "South Dakota", "Tennessee", "Texas", "Utah", "Vermont",
"Virginia", "Washington", "West Virginia", "Wisconsin", "Wyoming"
]
end
STEP 3
Now it’s time for adding the view and seeing how it looks in the browser. In the search_form.html.heex file, add:
<form phx-change="search" phx-submit="submit">
<input type="text" class="form-control" name="search_phrase" value={@search_phrase} phx-debounce="500" placeholder="Search..." />
<%= if @search_results != [] do %>
<div class="relative">
<div class="absolute z-50 left-0 right-0 rounded border border-gray-100 shadow py-2 bg-white">
<%= for search_result <- @search_results do %>
<div class="cursor-pointer p-2 hover:bg-gray-200 focus:bg-gray-200" phx-click="pick" phx-value-name={search_result}>
{search_result}
</div>
<% end %>
</div>
</div>
<% end %>
<p class="pt-1 text-xs text-gray-700">Search for states</p>
</form>
Then in page/index.html.heex add:
<div class="max-w-sm">
<%= live_render @conn, MyAppWeb.SearchFormLive %>
</div>
And now if I navigate to http://localhost:4000/ I should see something like:
However, if I type something now, my LiveView component will explode.
[error] GenServer #PID<0.881.0> terminating
** (UndefinedFunctionError) function MyAppWeb.SearchFormLive.handle_event/3 is undefined or private
The reason is that there is no handle_event function to handle my search. Let’s add that.
STEP 4
Open the LiveView component search_form_live.ex again and add:
def handle_event("search", %{"search_phrase" => search_phrase}, socket) do
assigns = [
search_results: search(search_phrase),
search_phrase: search_phrase
]
{:noreply, assign(socket, assigns)}
end
def handle_event("pick", %{"name" => search_phrase}, socket) do
assigns = [
search_results: [],
search_phrase: search_phrase
]
{:noreply, assign(socket, assigns)}
end
And now, with that in place I can refresh the page and see the search results.
However, there’s one thing that bothers me a little. I want to format the result nicer with the typeahead part.
In the file page_view.ex I can add a helper function:
def format_search_result(search_result, search_phrase) do
split_at = String.length(search_phrase)
{selected, rest} = String.split_at(search_result, split_at)
"<strong>#{selected}</strong>#{rest}"
end
And in the template search_form.html.heex, call that function. Change from:
{search_result}
To:
{raw format_search_result(search_result, @search_phrase)}
And when I test this again, I can see that the matching part is now in bold:
STEP 5 - Key events
Note that if I click on one of the results in the list, that one gets selected and the list is empty again. That is the behaviour I want. But I would also like to be able to navigate with keys.
I need to add a few more functions to get that to work.
Open up the LiveView component search_form_live.ex again. Inside the mount function, where we do the assigns, we need to add a current_focus and that value will start at -1. That means the current focus will start outside of the search results.
assigns = [
conn: socket,
search_results: [],
search_phrase: "",
current_focus: -1
]
Then, while we are here, we need to add a few more event handlers:
def handle_event("submit", _, socket), do: {:noreply, socket} # PREVENT FORM SUBMIT
def handle_event("set-focus", %{"keyCode" => 38}, socket) do # UP
current_focus =
Enum.max([(socket.assigns.current_focus - 1), 0])
{:noreply, assign(socket, current_focus: current_focus)}
end
def handle_event("set-focus", %{"keyCode" => 40}, socket) do # DOWN
current_focus =
Enum.min([(socket.assigns.current_focus + 1), (length(socket.assigns.search_results)-1)])
{:noreply, assign(socket, current_focus: current_focus)}
end
def handle_event("set-focus", %{"keyCode" => 13}, socket) do # ENTER
case Enum.at(socket.assigns.search_results, socket.assigns.current_focus) do
"" <> search_phrase -> handle_event("pick", %{"name" => search_phrase}, socket)
_ -> {:noreply, socket}
end
end
# FALLBACK FOR NON RELATED KEY STROKES
def handle_event("set-focus", _, socket), do: {:noreply, socket}
Last, we need to update the view. There are 3 things to look for here.
- Add a
phx-submit="submit"on the form so we can prevent form submits. - Add
phx-window-keydown="set-focus", meaning that we start capturing key press events. - We iterate the search results and give them an index so we can keep track of where we are with
Enum.with_index(@search_results).
<form phx-change="search" phx-submit="submit">
<input type="text" class="form-control" name="search_phrase" value={@search_phrase} phx-debounce="500" placeholder="Search..." />
<%= if @search_results != [] do %>
<div class="relative" phx-window-keydown="set-focus">
<div class="absolute z-50 left-0 right-0 rounded border border-gray-100 shadow py-2 bg-white">
<%= for {search_result, idx} <- Enum.with_index(@search_results) do %>
<div class={"cursor-pointer p-2 hover:bg-gray-200 focus:bg-gray-200 #{if idx == @current_focus, do: "bg-gray-200"}"} phx-click="pick" phx-value-name={search_result}>
{raw format_search_result(search_result, @search_phrase)}
</div>
<% end %>
</div>
</div>
<% end %>
<p class="pt-1 text-xs text-gray-700">Search for states</p>
</form>
STEP 6 - Final result
To me this is extremely simple and would for sure require a lot more code to do with a frontend library.