lib/pyro/components/autocomplete.ex

defmodule Pyro.Components.Autocomplete do
  use Pyro.LiveComponent

  import Pyro.Components.Core, only: [error: 1, label: 1]
  # import Pyro.Gettext

  @doc """
  A simple autocomplete component.

  ## Examples

      <.simple_form for={@form} phx-change="validate" phx-submit="save">
        <.live_component
          module={Pyro.Components.Autocomplete}
          id="fiend_id_autocomplete"
          field={@form[:friend_id]}
          label="Friend"
          search_fn={search_friends/1}
          lookup_fn={lookup_friend/1} />
        <:actions>
          <.button>Save</.button>
        </:actions>
      </.simple_form>
  """

  attr :overrides, :list, default: nil, doc: @overrides_attr_doc
  attr :throttle_time, :integer, overridable: true, required: true
  attr :option_label_key, :atom, overridable: true, required: true
  attr :option_value_key, :atom, overridable: true, required: true
  attr :prompt, :string, overridable: true, required: true, doc: "The prompt for search input"
  attr :description, :string, default: nil
  attr :errors, :list, default: []
  attr :label, :string, default: nil
  attr :input_id, :any, default: nil
  attr :multiple, :boolean, default: false, doc: "The multiple flag for select inputs"
  attr :required, :boolean, default: false
  attr :name, :any
  attr :value, :any

  attr :search_fn, :any,
    required: true,
    doc: "The arity-1 function to get options from search term"

  attr :lookup_fn, :any,
    required: true,
    doc: "The arity-1 function to get lookup/convert value to option"

  attr :no_results_message, :string,
    default: "[no results]",
    doc: "The message to display if there are no results for the search phrase"

  attr :field, Phoenix.HTML.FormField,
    doc: "A form field struct retrieved from the form, for example: @form[:email]"

  attr :autofocus, :boolean,
    default: false,
    doc: "Enable autofocus hook to reliably focus input on mount"

  attr :class, :tails_classes, overridable: true, required: true

  attr :input_class, :tails_classes,
    overridable: true,
    required: true,
    doc: "Class of the input element"

  attr :listbox_class, :tails_classes,
    overridable: true,
    required: true,
    doc: "Class of the listbox element"

  attr :listbox_option_class, :tails_classes,
    overridable: true,
    required: true,
    doc: "Class of the listbox option element"

  attr :description_class, :tails_classes,
    overridable: true,
    required: true,
    doc: "Class of the field description"

  slot :option_template

  def render(assigns) do
    assigns = assign_overridables(assigns)

    ~H"""
    <div
      id={@id}
      class={@class}
      phx-click-away={expanded?(@results, @search, @saved_label) && "cancel"}
      phx-target={@myself}
    >
      <input type="hidden" id={@input_id || @name} name={@name} value={@value} />
      <.label for={@id} overrides={@overrides}><%= @label %></.label>
      <input
        id={@id <> "-search"}
        type="text"
        name={@id <> "-search"}
        value={@search}
        class={@input_class}
        role="combobox"
        aria-expanded={expanded?(@results, @search, @saved_label)}
        aria-controls={@id <> "-listbox"}
        placeholder={@prompt}
        phx-hook="PyroAutocompleteComponent"
        phx-target={@myself}
        data-myself={@myself}
        data-input-id={@input_id || @name}
        data-selected-index={@selected_index}
        data-results-count={length(@results)}
        data-saved-label={@saved_label}
        data-autofocus={@autofocus}
        data-throttle-time={@throttle_time}
        data-select-on-focus
        autocomplete="off"
      />
      <div :if={expanded?(@results, @search, @saved_label)} class="relative">
        <ul id={@id <> "-listbox"} role="listbox" class={@listbox_class}>
          <li
            :for={{option, i} <- Enum.with_index(@results)}
            id={"#{@id}-search-result-#{Map.get(option, @option_value_key)}"}
            role="option"
            aria-selected={i == @selected_index}
            data-value={Map.get(option, @option_value_key)}
            data-label={Map.get(option, @option_label_key)}
            data-index={i}
            class={@listbox_option_class}
            phx-click={JS.dispatch("pick", to: "##{@id}-search")}
          >
            <%= render_slot(@option_template, option) || Map.get(option, @option_label_key) %>
          </li>
          <li
            :if={@results == []}
            id={"#{@id}-search-result-none"}
            role="option"
            tabindex="-1"
            class={@listbox_option_class}
          >
            <%= @no_results_message %>
          </li>
        </ul>
      </div>
      <p :if={@description} class={@description_class}>
        <%= @description %>
      </p>
      <.error :for={msg <- @errors} overrides={@overrides}><%= msg %></.error>
    </div>
    """
  end

  @impl true
  def mount(socket) do
    {:ok,
     socket
     |> assign(:search, nil)
     |> assign(:field, :unset)
     |> assign(:saved_label, nil)
     |> assign(:saved_value, nil)
     |> assign(:selected_index, -1)
     |> assign(:results, [])}
  end

  @impl true
  def update(
        %{field: %Phoenix.HTML.FormField{} = field} = assigns,
        %{assigns: %{field: :unset}} = socket
      ) do
    {:ok,
     socket
     |> assign(assigns)
     |> assign(field: nil, input_id: assigns[:input_id] || field.id)
     |> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
     |> assign_new(:name, fn ->
       if assigns[:multiple], do: field.name <> "[]", else: field.name
     end)
     |> assign_new(:value, fn -> field.value end)
     |> assign_label()}
  end

  @impl true
  def update(%{field: %{value: value}} = assigns, %{assigns: %{value: old_value}} = socket)
      when value !== old_value do
    {:ok,
     socket
     |> assign(assigns)
     |> assign(:selected_index, -1)
     |> assign(:value, value)
     |> assign_label()}
  end

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

  @impl true
  def handle_event("search", search, %{assigns: %{search_fn: search_fn}} = socket) do
    {:noreply,
     socket
     |> assign(:search, search)
     |> assign(:results, search_fn.(search))
     |> case do
       %{assigns: %{results: []}} = socket ->
         assign(socket, :selected_index, -1)

       %{assigns: %{search: search}} = socket when search == "" or is_nil(search) ->
         assign(socket, :selected_index, -1)

       socket ->
         assign(socket, :selected_index, 0)
     end}
  end

  @impl true
  def handle_event("cancel", _, %{assigns: %{results: results}} = socket) when results != [] do
    {:noreply,
     socket
     |> assign(:selected_index, -1)
     |> assign(:search, socket.assigns.saved_label)
     |> assign(:results, [])}
  end

  def handle_event("cancel", _, socket), do: {:noreply, socket}

  @impl true
  def handle_event("pick", %{"label" => label, "value" => value}, socket) do
    {:noreply,
     socket
     |> assign(:search, label)
     |> assign(:value, value)
     |> assign(:saved_label, label)
     |> assign(:saved_value, value)
     |> assign(:selected_index, -1)
     |> assign(:results, [])}
  end

  defp assign_label(%{assigns: %{value: nil}} = socket) do
    socket
    |> assign(:search, nil)
    |> assign(:saved_value, nil)
  end

  defp assign_label(
         %{
           assigns: %{
             value: value,
             saved_value: saved_value,
             lookup_fn: lookup_fn,
             option_label_key: option_label_key
           }
         } = socket
       )
       when value != saved_value do
    label = Map.get(lookup_fn.(value), option_label_key)

    socket
    |> assign(:saved_label, label)
    |> assign(:search, label)
    |> assign(:saved_value, value)
  end

  defp assign_label(socket), do: socket

  defp expanded?(results, _search, _saved_label) when results != [], do: true

  defp expanded?(_results, search, saved_label)
       when is_binary(search) and search != "" and search !== saved_label,
       do: true

  defp expanded?(_results, _search, _saved_label), do: false
end