Feature

Simple Typeahead with LiveView and no JS

Preview

A simple typeahead that is very easy to implement. Just start with typing a character and LiveView will return a list of matching products. No javascript required.

lib/phoenix_features_web/live/components/typeahead_simple.ex
defmodule PhoenixFeaturesWeb.Components.TypeaheadSimple do
  use PhoenixFeaturesWeb, :live_component

  alias PhoenixFeatures.Products

  @impl true
  def mount(socket) do
    {:ok,
      socket
      |> assign(:search_results, [])
      |> assign(:search_phrase, "")
      |> assign(:current_focus, -1)
    }
  end

  def search(""), do: []
  def search(search_phrase) do
    Products.list_products()
    |> Enum.map(& "#{&1.name} - $#{&1.price} ")
    |> Enum.filter(& matches?(&1, search_phrase))
    |> Enum.slice(0..4)
  end

  def matches?(first, second) do
    String.starts_with?(
      String.downcase(first), String.downcase(second)
    )
  end

  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

  @impl true
  def handle_event("search", %{"search_phrase" => search_phrase}, socket) do
    {:noreply,
      socket
      |> assign(:search_results, search(search_phrase))
      |> assign(:search_phrase, search_phrase)
    }
  end

  @impl true
  def handle_event("pick", %{"name" => search_phrase}, socket) do
    {:noreply,
      socket
      |> assign(:search_results, [])
      |> assign(:search_phrase, search_phrase)
    }
  end

  @impl true
  def handle_event("submit", _, socket), do: {:noreply, socket} # PREVENT FORM SUBMIT

  @impl true
  def handle_event("set-focus", %{"key" => "ArrowUp"}, socket) do
    current_focus =
      Enum.max([(socket.assigns.current_focus - 1), 0])

    {:noreply, assign(socket, current_focus: current_focus)}
  end

  @impl true
  def handle_event("set-focus", %{"key" => "ArrowDown"}, socket) do
    current_focus =
      Enum.min([(socket.assigns.current_focus + 1), (length(socket.assigns.search_results)-1)])

    {:noreply, assign(socket, current_focus: current_focus)}
  end

  @impl true
  def handle_event("set-focus", %{"key" => "Enter"}, socket) do
    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 SUPPORTED KEY STROKES
  @impl true
  def handle_event("set-focus", _, socket), do: {:noreply, socket}
end
<div id="<%= @id %>" class="mx-auto max-w-sm my-12">
  <%= form_tag "#", [phx_change: "search", phx_submit: "submit", phx_target: "##{@id}"] %>

    <input
      type="text"
      class="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
      name="search_phrase"
      value="<%= @search_phrase %>"
      phx-debounce="500"
      placeholder="Search..." />

    <%= if @search_results != [] do %>
      <div
        phx-window-keydown="set-focus"
        phx-target="#<%= @id %>"
        class="relative">

        <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
              phx-target="#<%= @id %>"
              phx-click="pick"
              phx-value-name="<%= search_result %>"
              class="cursor-pointer p-2 hover:bg-gray-200 focus:bg-gray-200 <%= if idx == @current_focus, do: "bg-gray-200" %>">
              <%= raw format_search_result(search_result, @search_phrase) %>
            </div>
          <% end %>
        </div>
      </div>
    <% end %>

    <p class="pt-1 text-xs text-gray-700">Search for products</p>
  </form>
</div>
defmodule PhoenixFeaturesWeb.Components.CalendarSimpleTest do
  use PhoenixFeaturesWeb.ConnCase
  import Phoenix.LiveViewTest

  alias PhoenixFeatures.Products

  describe "TypeaheadSimple" do
    test "shows the component", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "typeahead_simple"))

      assert view |> element("#typeahead_simple") |> has_element?()
    end

    test "you can type in the input field and get a list of matching results", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "typeahead_simple"))

      [product | _rest] = Products.list_products()
      string_to_test = product.name |> String.slice(0..2)

      html = view
             |> element("form")
             |> render_change(%{"search_phrase" => string_to_test})

      assert html =~ "<strong>#{string_to_test}</strong>"
    end

    test "if you type giberish there will be no results", %{conn: conn} do
      {:ok, view, _html} = live(conn, Routes.demos_show_path(conn, :show, "typeahead_simple"))

      string_to_test = "should not exist"

      html = view
             |> element("form")
             |> render_change(%{"search_phrase" => string_to_test})

      refute html =~ "<strong>#{string_to_test}</strong>"
    end
  end