Tutorial

Typeahead with LiveView and Tailwind

This post was updated 01 May - 2020

tailwind typeahead liveview forms phoenix

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 searchformlive.ex

defmodule TutorialWeb.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
    TutorialWeb.PageView.render("search_form.html", assigns)
  end
end

This also requires me to create a file called searchform.html.leex and that file needs to be placed inside lib/tutorialweb/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 searchformlive.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 its time for adding the view and see how it looks in the browser. In the search_form.html.leex file, add:

<%= form_tag "#", [phx_change: :search] do %>
  <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>
<% end %>

Then in page/index.html.eex add:

<div class="max-w-sm">
  <%= live_render @conn, TutorialWeb.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 TutorialWeb.SearchFormLive.handle_event/3 is undefined or private
    TutorialWeb.SearchFormLive.handle_event("search", %{"_csrf_token" => "BB8LEz4rZ3M2CTlLBgcuAwkDAwYTGQIF1go_HeU8AslslsIixyonwqkG", "_target" => ["search_phrase"], "_utf8" => "✓", "search_phrase" => "a"}, #Phoenix.LiveView.Socket<assigns: %{conn: #Phoenix.LiveView.Socket<assigns: %{}, changed: %{}, endpoint: TutorialWeb.Endpoint, id: "phx-jkK5IJUmDYM", ...>, search_phrase: "", search_results: []}, changed: %{}, endpoint: TutorialWeb.Endpoint, id: "phx-jkK5IJUmDYM", parent_pid: nil, view: TutorialWeb.SearchFormLive, ...>)

The reason is that there is no handle_event function to handle my search. Lets add that.

STEP 4

Open the LiveView component searchformlive.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 result.

However, its 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.leex, 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 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 for getting that to work.

Open up the LiveView component searchformlive.ex again. Inside the mount function, where we do the assigns, we need to add a current_focus and that value will start att -1. That means that the current focus will start outside of the search result.

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, what we need to do is update the view. And there are 3 things to look for here.

  1. Add a phx_submit: :submit in the form tag so we can prevent form submits.
  2. Add phx-window-keydown="set-focus", meaning that we start capturing the key press events.
  3. We iterate the search result and gives them an index so we can keep track in where we are Enum.withindex(@searchresults)
<%= form_tag "#", [phx_change: :search, phx_submit: :submit] do %>
  <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>
<% end %>

STEP 6 - Final result

To be this is extremely simple and would for sure require a lot more code to do with a frontend library.

Related Tutorials

Published 04 May - 2021
Updated 05 May - 2022

How to combine Phoenix LiveView with Alpine.js

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..

Published 13 Feb - 2020
Updated 01 May - 2020

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

In the previous tutorial, I set up the the backend for being able to add tags to products. I have also written a tutorial about adding a LiveView an..