Psst. It would be super cool if you could try the new Phoenix Boilerplate!

Try now

Tutorial

View on Github

Typeahead with LiveView and Tailwind

This post was updated 01 May

formsliveviewphoenixtailwindtypeahead

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 the new LiveView component called search_form_live.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 search_form.html.leex and that file needs to be placed inside lib/tutorial_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 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 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 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 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 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.with_index(@search_results)
<%= 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 extremly simple and would for sure require a lot more code to do with a frontend library.


What are you working on?

If you want, you can send me a link to your Phoenix or Phoenix LiveView project so. Lets connect on Twitter or Linkedin.

- Andreas Eriksson, web developer since 2005

Related Tutorials

Published 13 Feb

Tagging interface with Phoenix LiveView and Tailwind - Tagging part 2

formstypeaheadliveviewphoenixtaggingtailwind

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

Published 11 Jul

Create a reusable modal with LiveView Component

alpinejsliveviewmodalphoenixtailwind

To reduce duplicity and complexity in your apps, Phoenix LiveView comes with the possibility to use reusable components. Each component can have its..