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.
Files for the feaure
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