Autocomplete input component w/o Javascript

Hi,

i would like to share the following component that you can use in your phoenix forms to provide a input field with a customizable source of suggestions. Could be used as a tagging component etc.

defmodule MyProjectWeb.Components.SelectComponent do
  use MyProjectWeb, :live_component

  def render(assigns) do
    ~H"""
    <div class="flex flex-row items-baseline gap-4 break-inside-avoid" phx-target={@myself}>
      <.label><%= @label %></.label>
      <div class="w-full">
        <div class="flex flex-wrap items-center gap-1">
          <.inputs_for :let={f} field={@form[@field_name]}>
            <.tag_badge color={@color_function.(f[:type].value)}>
              <%= f[:name].value %>
              <:actions>
                <button
                  type="button"
                  phx-click="autocomplete_remove"
                  phx-target={@myself}
                  phx-value-index={f.index}
                  class="hover:text-red-500 pl-1"
                >
                  ×
                </button>
              </:actions>
            </.tag_badge>

            <.input field={f[:id]} type="hidden" />
            <.input field={f[:name]} type="hidden" />
            <input type="hidden" name={"case[#{@field_name}_sort][]"} value={f.index} />
          </.inputs_for>
        </div>
        <.input
          field={@form[:"new_#{@field_name}"]}
          type="text"
          placeholder={@placeholder}
          phx-keydown="autocomplete_key"
          onkeydown="return (event.keyCode!=13);"
          phx-target={@myself}
        />
        <input type="hidden" name={"case[#{@field_name}_drop][]"} />

        <div>
          <%= for {suggestion, index} <- Enum.with_index(@suggestions) do %>
            <div
              class={"suggestion #{if index == @selected_index, do: "bg-indigo-200"} rounded-sm p-1"}
              phx-click="autocomplete_select"
              phx-target={@myself}
              phx-value-index={index}
            >
              <%= suggestion.name %>
            </div>
          <% end %>
        </div>
      </div>
    </div>
    """
  end

  def mount(socket) do
    {:ok, assign(socket, suggestions: [], selected_index: 0)}
  end

  def update(assigns, socket) do
    {:ok, assign(socket, assigns)}
  end

  def handle_event("autocomplete_key", %{"key" => key, "value" => value}, socket) do
    case key do
      "ArrowDown" ->
        {:noreply, update_selected_index(socket, 1)}

      "ArrowUp" ->
        {:noreply, update_selected_index(socket, -1)}

      "Enter" ->
        selected = get_from_suggestions(socket, socket.assigns.selected_index) || %{name: value}
        {:noreply, socket.assigns.on_select.(socket, selected)}

      _ ->
        suggestions = socket.assigns.search_function.(value)
        {:noreply, assign(socket, suggestions: suggestions, selected_index: 0)}
    end
  end

  ## In real life there seems to be always a value, but to make tests easier
  ## this def allows events without value
  def handle_event("autocomplete_key", %{"key" => key}, socket) do
    handle_event("autocomplete_key", %{"key" => key, "value" => ""}, socket)
  end

  def handle_event("autocomplete_remove", %{"index" => index}, socket) do
    {:noreply, socket.assigns.on_remove.(socket, index)}
  end

  def handle_event("autocomplete_select", %{"index" => index}, socket) do
    {int, _} = Integer.parse(index)
    selected = get_from_suggestions(socket, int)
    {:noreply, socket.assigns.on_select.(socket, selected)}
  end

  defp get_from_suggestions(socket, index) do
    Enum.at(socket.assigns.suggestions, index)
  end

  defp update_selected_index(socket, direction) do
    update(
      socket,
      :selected_index,
      &rem(
        &1 + direction + length(socket.assigns.suggestions),
        length(socket.assigns.suggestions)
      )
    )
  end
end

use it like this:

      <.live_component
        module={SelectComponent}
        id={@id}
        form={@form}
        field_name="tags"
        label="Tags"
        placeholder="Enter a tag"
        search_function={&search_suggestions/1}
        color_function={&get_color_for_actor/1}
        on_select={&handle_select/2}
        on_remove={&handle_remove/2}
      />

You need to define these callback functions for it to work…

3 Likes