Autocomplete field in Phoenix Live View, Surface, and TailwindCSS

3 months ago

I know it's a mouthful, but hear me out:

I've been looking for an autocomplete field that worked for my purposes (lookup in the database, not in memory, and support multiselect and the ability to limit the number of items a field could support.

Since it couldn't find it and being the dumb dev that i am, i obviously wrote my own. It's meant for Surface forms, but with a bit of tinkering you can make it work with plain LiveView.

defmodule MyProjectWeb.Live.Components.Autocomplete do
  @moduledoc """
  Autocomplete component to lookup a single item from the database
  """
  use MyProjectWeb, :surface_component

  alias Surface.Components.Form

  @doc """
  The form field name for the data that we are looking up
  """
  prop field_name, :atom, required: true

  @doc """
  The label of the field
  """
  slot default, required: true

  @doc """
  Currently selected values for the field
  """
  prop current_values, :map, default: %{}

  @doc """
  Suggestions that the user can select
  """
  prop suggestions, :list, required: true

  @doc """
  Add and remove events that will get triggered
  as user clicks on the suggestions or asks to
  remove one of the current values
  """
  prop add_event, :event, required: true
  prop remove_event, :event, required: true

  @doc """
  Maximum number of items this field allows for. Controls
  whether to show the search field or not.
  """
  prop max_items, :integer, default: 9_999_999

  @impl true
  def render(assigns) do
    assigns =
      assign(assigns, :suggestions, filter_existing(assigns.suggestions, assigns.current_values))

    ~F"""
    <div id={@id}>
      <div class="form-control">
        <Form.Label><#slot /></Form.Label>
        <div class="flex flex-row items-start">
          <div class="flex flex-row">
            {#for {id, title} <- @current_values}
              <div class="badge badge-secondary p-3 mr-2 cursor-pointer flex-1">
                {title}
                <div :on-click={@remove_event} phx-value-id={id}>
                  <Heroicons.Solid.x_circle class="ml-2 h-6 w-6 cursor-pointer" />
                </div>
              </div>
            {/for}
          </div>

          <div class="flex flex-col">
            <Form.TextInput
              id={"autocomplete_#{@field_name}"}
              name={"autocomplete_#{@field_name}"}
              class="inline-block max-h-8"
              opts={phx_debounce: 500, autocomplete: "off", placeholder: "Search..."}
              value=""
              :if={Enum.count(@current_values) < @max_items}
            />
            <div
              :if={Enum.any?(@suggestions)}
              class="mb-3 grid grid-cols-1 divide-y border-t border-b text-sm border-neutral"
            >
              {#for {id, title} <- @suggestions}
                <div
                  class="p-1 cursor-pointer border-x pl-3 pr-3 hover:bg-secondary border-neutral"
                  :on-click={@add_event}
                  phx-value-id={id}
                >
                  {title}
                </div>
              {/for}
            </div>
          </div>
        </div>
        <Form.ErrorTag />
      </div>
    </div>
    """
  end

  defp filter_existing(suggestions, current_values) do
    Enum.reject(suggestions, fn s -> Enum.member?(current_values, s) end)
  end
end

The gist of it is:

  • The current_values property accepts what the database object already contains in that field (probably a list of id's.
  • The suggestions property receives a map for which the key is the id of the suggestion and the value is the title/name to be displayed when suggesting.
  • The two events control what gets called when the user adds (i.e. clicks a suggestion) or removes (i.e. clicks remove on an existing item. These events will be controlled on the outside.
  • The max_items property defines at which point do we stop showing the search box for this field (i.e. the max number of selected items was reached).
  • The rest is pretty simple:

    Whenever the user enters something in the search field, the form will get it's change event triggered and we only need to intercept that event based on whether we are asking for a completion or not. This is easy to achieve, for instance, like described next.

    My form component contains this:

    @impl true
      def handle_event(
            "change",
            %{
              "_target" => ["autocomplete_user"],
              "autocomplete_user" => query
            },
            socket
          ) do
        cond do
          String.length(query) < 2 ->
            {:noreply, assign(socket, :user_autocomplete_suggestions, [])}
    
          true ->
            suggestions =
              query
              |> Users.find()
              |> Enum.map(fn %{id: id, title: title} ->
                {id, title}
              end)
    
            {:noreply, assign(socket, :user_autocomplete_suggestions, suggestions)}
        end
      end

    As you can see, the suggestions for that field get set as a list of tuples (as accepted on the autocomplete component) and those suggestions are passed in when we update state. Here's the render of the component:

    <Autocomplete
      id="users_lookup"
      field_name={:users}
      current_values={users_autocomplete_values(@blog)}
      suggestions={@users_autocomplete_suggestions}
      max_items={1}
      add_event="add_users"
      remove_event="remove_users"
    >
      User
    </Autocomplete>

    The users_autocomplete_values(@blog) is basically building the currently assigned user to the blog:

    defp user_autocomplete_values(%{user_id: nil}), do: %{}
    defp user_autocomplete_values(%{user: %{id: id, name: name}}),
        do: %{id => name}

    As you can see, the function returns a map where the key is the id, and the value is the displayed name, as per what the component expects. Also, this is the case where you want to autocomplete only one value. Here's an example function for more than one:

    defp user_autocomplete_values(%{users: users}) do
      users
      |> Enum.map(fn %{id: id, name: name} ->
        {id, name}
      end)
      |> Map.new()
    end

    Here the blog would have multiple users. Maybe not the best practical example, but you get the idea of how that current_values property can be built.

    Finally, we only need to implement the add and remove functions on the form component (or live view). For a single item autocomplete something like this would work:

    @impl true
    def handle_event("add_user", %{"id" => id} = _event, socket) do
      validate_and_save(
        %{"blog" => %{"user_id" => id}},
        assign(socket, :user_autocomplete_suggestions, [])
      )
    end
    
    @impl true
    def handle_event("remove_user", %{"id" => _id} = _event, socket) do
      validate_and_save(%{"blog" => %{"user_id" => nil}}, socket)
    end

    And if you wanted to support multiple users, something like this should do the trick:

    @impl true
    def handle_event("add_user", %{"id" => id} = _event, socket) do
      existing_user_ids = Enum.map(socket.assigns.customer.users, & &1.id)
    
      validate_and_save(
        %{"customer" => %{"customer_users" => [id | existing_user_ids]}},
        assign(socket, :users_autocomplete_suggestions, [])
      )
    end
    
    @impl true
    def handle_event("remove_user", %{"id" => id} = _event, socket) do
      existing_user_ids = Enum.map(socket.assigns.customer.users, & &1.id)
      new_user_ids = List.delete(existing_user_ids, String.to_integer(id))
    
      validate_and_save(
        %{"customer" => %{"customer_users" => new_user_ids}},
        assign(socket, :users_autocomplete_suggestions, [])
      )
    end

    Happy coding!