Fluxon.Components.Autocomplete (Fluxon v1.0.21)

A modern, accessible autocomplete component with rich search capabilities.

The autocomplete component provides a text input that filters a list of options as the user types, with keyboard navigation and accessibility features. It supports both client-side and server-side search capabilities, making it suitable for both small and large datasets.

Select vs Autocomplete

While both components enable users to choose from a list of options, they offer different interaction patterns that may better suit certain use cases.

Select Component Best suited for browsing through predefined options, especially when users benefit from seeing all choices at once. Supports multiple selection.

Autocomplete Component Optimized for searching through large datasets, with both client and server-side filtering. Features a type-to-search interface with custom empty states and loading indicators.

Key Differences:

FeatureSelectAutocomplete
Primary InteractionClick to browseType to search
Data SizeSmall to medium listsAny size dataset
FilteringClient-side onlyClient or server-side
Multiple SelectionSupportedNot supported
Clearing SelectionSupportedNot supported

Usage

Basic usage with a list of options:

<.autocomplete
  name="country"
  options={[{"United States", "us"}, {"Canada", "ca"}]}
  placeholder="Search countries..."
/>

The autocomplete component follows the same options API as Phoenix.HTML.Form.options_for_select/2, supporting:

  • List of strings: ["Option 1", "Option 2"]
  • List of tuples: [{"Label 1", "value1"}, {"Label 2", "value2"}]
  • List of keyword pairs: [key: "value"]
  • Grouped options: [{"Group", ["Option 1", "Option 2"]}]

A full-feature example would look like this:

<.autocomplete
  name="user"
  label="Select User"
  sublabel="Search by name or email"
  description="Choose a user to assign the task to"
  help_text="Type to search through all registered users"
  placeholder="Search users..."
  search_threshold={3}
  options={@users}
/>

Form Integration

The autocomplete component integrates with Phoenix forms in two ways: using the field attribute for form integration or using the name attribute for standalone inputs.

Use the field attribute to bind the autocomplete to a form field:

<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save">
  <.autocomplete
    field={f[:user_id]}
    label="Assigned To"
    options={@users}
  />
</.form>

Using the field attribute provides:

  • Automatic value handling from form data
  • Error handling and validation messages
  • Form submission with correct field names
  • Integration with changesets
  • Nested form data handling

Example with a complete changeset implementation:

defmodule MyApp.Task do
  use Ecto.Schema
  import Ecto.Changeset

  schema "tasks" do
    field :title, :string
    belongs_to :assigned_to, MyApp.User
    timestamps()
  end

  def changeset(task, attrs) do
    task
    |> cast(attrs, [:assigned_to_id, :title])
    |> validate_required([:assigned_to_id])
  end
end

# In your LiveView
def mount(_params, _session, socket) do
  users = MyApp.Accounts.list_active_users() |> Enum.map(&{&1.name, &1.id})
  changeset = Task.changeset(%Task{}, %{})

  {:ok, assign(socket, users: users, form: to_form(changeset))}
end

def render(assigns) do
  ~H"""
  <.form :let={f} for={@form} phx-change="validate">
    <.autocomplete
      field={f[:assigned_to_id]}
      options={@users}
      label="Assigned To"
      placeholder="Search users..."
    />
  </.form>
  """
end

def handle_event("validate", %{"task" => params}, socket) do
  changeset =
    %Task{}
    |> Task.changeset(params)
    |> Map.put(:action, :validate)

  {:noreply, assign(socket, form: to_form(changeset))}
end

Using Standalone Autocomplete

For simpler cases or when not using Phoenix forms, use the name attribute:

<.autocomplete
  name="search_user"
  options={@users}
  value={@user_id}
  placeholder="Search users..."
/>

When using standalone autocomplete:

  • The name attribute determines the form field name
  • Values are managed through the value attribute
  • Errors are passed via the errors attribute

Search Behavior

The autocomplete component offers two distinct search strategies: client-side and server-side filtering. This flexibility allows the component to efficiently handle both small, static datasets and large, dynamic data sources that require server-side processing.

Client-side search provides immediate feedback as users type, filtering the provided options directly in the browser. This approach is ideal for small to medium datasets that do not require server-side processing, such as language selections, predefined categories, list of countries, etc.

<.autocomplete
  name="language"
  search_mode="contains"
  search_threshold={2}
  no_results_text="No languages matching '%{query}'"
  options={[
    {"English", "en"},
    {"Spanish", "es"},
    {"French", "fr"},
    {"German", "de"}
  ]}
/>

The component offers three search modes through the search_mode attribute, each providing different matching behavior:

  • "contains" (default): Matches if the option label contains the search query anywhere
  • "starts-with": Matches only if the option label begins with the search query
  • "exact": Matches only if the option label exactly matches the search query

The search_threshold attribute determines how many characters must be typed before filtering begins, helping to prevent unnecessary filtering operations for very short queries.

For large datasets, dynamic data sources, or when complex search logic is required, the component supports server-side search through LiveView integration. This is particularly useful when dealing with database queries, API calls, or when implementing features like debounced search or typeahead suggestions.

Server-side search is activated by providing the on_search attribute with a LiveView event name. When users type, the component sends this event with the search query as a parameter:

<.autocomplete
  name="movie"
  options={@movies}
  on_search="search_movies"
  search_threshold={2}
  placeholder="Type to search movies..."
/>

In your LiveView, handle the search event to fetch and update the options:

def mount(_params, _session, socket) do
  {:ok, assign(socket, movies: [])}
end

def handle_event("search_movies", %{"query" => query}, socket) when byte_size(query) >= 2 do
  case MyApp.Movies.search(query) do
    {:ok, movies} ->
      {:noreply, assign(socket, movies: movies)}
    {:error, _reason} ->
      {:noreply, assign(socket, movies: [])}
  end
end

The component automatically manages the search state, including:

  • Debouncing requests to prevent excessive server calls
  • Displaying a loading indicator during searches
  • Maintaining the selected value while searching
  • Handling empty states and error conditions

Initial Options

While server-side search is primarily focused on dynamic filtering, you can optionally provide an initial set of options that will be available when the component loads:

def mount(_params, _session, socket) do
  {:ok, assign(socket, movies: MyApp.Movies.featured_movies())}
end
<.autocomplete
  name="movie"
  options={@movies}
  on_search="search_movies"
  search_threshold={2}
  placeholder="Search movies..."
/>

These initial options are accessible as soon as the user interacts with the component, whether by clicking the input field, using arrow keys (↑/↓), or focusing the input when open_on_focus is enabled.

The choice between providing initial options or starting with an empty list depends on your use case:

  • Initial Options: Ideal when you want to showcase popular or recommended choices upfront, reducing the need for typing and improving discoverability. This works well for scenarios like movie recommendations, frequently used items, or recently accessed content.

  • Empty Initial State: Better suited when the dataset is too large to meaningfully preload options, when you want to encourage specific search terms, or when options are highly contextual to the user's input. This is common in scenarios like user search in large organizations or product search in extensive catalogs.

⚠️ Selected Value and Initial Options

When the component is initialized with a value, it attempts to find the corresponding label from the provided options to display in the input. For example, if the component is initialized with value={2} and the options include {"Alice", 2}, the input will display "Alice" as the selected value.

def mount(_params, _session, socket) do
  # Bad: Selected user (id: 2) is not included in initial options
  {:ok, assign(socket,
    selected_user_id: 2,
    users: []  # Empty initial options
  )}
end
def mount(_params, _session, socket) do
  featured_users = MyApp.Accounts.featured_users() |> Enum.map(&{&1.name, &1.id})
  selected_user = MyApp.Accounts.get_user!(2)

  # Good: Include the selected user in initial options
  {:ok, assign(socket,
    selected_user_id: selected_user.id,
    users: [{selected_user.name, selected_user.id} | featured_users]
  )}
end

When using server-side search with a selected value, it's crucial to ensure that the selected option is included in the initial options list, even if you're starting with an otherwise empty list. If the selected value's option is not found in the list, the raw value will be displayed in the input, resulting in a poor user experience.

Custom Option Rendering

The autocomplete component supports custom option rendering through its :option slot. For each option in the list, the component passes a tuple {label, value} to the slot:

<.autocomplete
  name="user"
  options={@users}
  placeholder="Search users..."
>
  <:option :let={{label, value}}>
    <div class={[
      "flex items-center gap-3 p-2",
      "[[data-highlighted]_&]:bg-zinc-100 dark:[[data-highlighted]_&]:bg-zinc-800",
      "[[data-selected]_&]:font-medium"
    ]}>
      <img src={user_avatar_url(value)} class="size-8 rounded-full" />
      <div>
        <div class="font-medium">{label}</div>
        <div class="text-sm text-zinc-500">{user_email(value)}</div>
      </div>
    </div>
  </:option>
</.autocomplete>

Empty State

The autocomplete component also supports a custom empty state through the :empty_state slot:

<.autocomplete options={@users}>
  <:empty_state>
    <div class="p-4 text-center">
      <div class="text-zinc-400 dark:text-zinc-500">
        <.icon name="u-user-x" class="size-8 mx-auto mb-2" />
        <p>No users found</p>
        <p class="text-sm">Try a different search term</p>
      </div>
    </div>
  </:empty_state>
</.autocomplete>

Keyboard Support

The autocomplete component provides comprehensive keyboard navigation:

KeyElement FocusDescription
Tab/Shift+TabInputMoves focus to and from the input
InputOpens listbox and highlights last option
InputOpens listbox and highlights first option
OptionMoves highlight to previous visible option
OptionMoves highlight to next visible option
EnterOptionSelects the highlighted option
EscapeAnyCloses the listbox
Type charactersInputFilters options based on input

Common Use Cases

User Search with Avatar

defmodule MyApp.UsersLive do
  use FluxlandWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, socket |> assign(users: fetch_users()) |> assign(form: to_form(%{}, as: :search))}
  end

  def render(assigns) do
    ~H"""
    <.form :let={f} for={@form} phx-change="search">
      <.autocomplete field={f[:user_id]} options={user_options(@users)} on_search="search_users">
        <:option :let={{label, value}}>
          <div class="flex items-center gap-3 p-2 rounded-lg [[data-highlighted]_&]:bg-zinc-100 [[data-selected]_&]:bg-blue-100">
            <img src={user_by_id(value, @users)["image"]} class="size-8 rounded-full" />
            <div>
              <div class="font-medium text-sm">{label}</div>
              <div class="text-xs text-zinc-500">{user_by_id(value, @users)["email"]}</div>
            </div>
          </div>
        </:option>
        <:empty_state>
          <div class="p-4 text-center text-zinc-500">
            <p>No matching users found</p>
            <p class="text-sm">Try searching by name or email</p>
          </div>
        </:empty_state>
      </.autocomplete>
    </.form>
    """
  end

  def handle_event("search_users", %{"query" => query}, socket) do
    {:noreply, assign(socket, users: fetch_users(query))}
  end

  def handle_event("search", _params, socket), do: {:noreply, socket}

  defp fetch_users(query \ "") do
    case Req.get("https://dummyjson.com/users/search", params: [limit: 10, q: query]) do
      {:ok, %{body: %{"users" => users}}} -> users
      _ -> []
    end
  end

  defp user_options(users), do: Enum.map(users, &{&1["firstName"] <> " " <> &1["lastName"], &1["id"]})
  defp user_by_id(id, users), do: Enum.find(users, &(&1["id"] == id))
end

Summary

Components

Renders an autocomplete component with rich search capabilities and full keyboard navigation support.

Components

autocomplete(assigns)

Renders an autocomplete component with rich search capabilities and full keyboard navigation support.

This component provides a flexible way to build search interfaces with real-time filtering, server-side search capabilities, and rich option rendering. It includes built-in form integration, error handling, and accessibility features.

Attributes

  • id (:any) - The unique identifier for the autocomplete component. Defaults to nil.

  • name (:any) - The form name for the autocomplete. Required when not using the field attribute.

  • field (Phoenix.HTML.FormField) - The form field to bind to. When provided, the component automatically handles value tracking, errors, and form submission.

  • class (:any) - Additional CSS classes to apply to the autocomplete component. These classes are applied to the listbox container.

    Defaults to nil.

  • label (:string) - The primary label for the autocomplete. This text is displayed above the input and is used for accessibility purposes.

    Defaults to nil.

  • sublabel (:string) - Additional context displayed to the side of the main label. Useful for providing extra information without cluttering the main label.

    Defaults to nil.

  • help_text (:string) - Help text to display below the autocomplete. This can provide additional context or instructions for using the input.

    Defaults to nil.

  • description (:string) - A longer description to provide more context about the autocomplete. This appears below the label but above the input element.

    Defaults to nil.

  • placeholder (:string) - Text to display in the input when empty. This text appears in the search input and helps guide users to start typing.

    Defaults to nil.

  • autofocus (:boolean) - Whether the input should have the autofocus attribute. Defaults to false.

  • disabled (:boolean) - When true, disables the autocomplete component. Disabled inputs cannot be interacted with and appear visually muted.

    Defaults to false.

  • size (:string) - Controls the size of the autocomplete component:

    • "sm": Small size, suitable for compact UIs
    • "base": Default size, suitable for most use cases
    • "lg": Large size, suitable for prominent inputs
    • "xl": Extra large size, suitable for hero sections

    Defaults to "base".

  • search_threshold (:integer) - The minimum number of characters required before showing suggestions. This helps prevent unnecessary searches and improves performance.

    Defaults to 0.

  • no_results_text (:string) - Text to display when no options match the search query. Use %{query} as a placeholder for the actual search term. This is only shown when no custom empty state is provided.

    Defaults to "No results found for \"%{query}\".".

  • on_search (:string) - Name of the LiveView event to be triggered when searching. If provided, filtering will be handled server-side. The event receives %{"query" => query} as parameters.

    Defaults to nil.

  • search_mode (:string) - The mode of the client-side search to use for the autocomplete:

    • "contains": Match if option contains the search query (default)
    • "starts-with": Match if option starts with the search query
    • "exact": Match only if option exactly matches the search query

    Defaults to "contains".

  • open_on_focus (:boolean) - When true, the listbox opens when the input is focused, even if no search query has been entered yet.

    Defaults to false.

  • value (:any) - The current selected value of the autocomplete. When using forms, this is automatically handled by the field attribute.

  • errors (:list) - List of error messages to display below the autocomplete. These are automatically handled when using the field attribute with form validation.

    Defaults to [].

  • options (:list) (required) - A list of options for the autocomplete. Can be provided in multiple formats:

    • List of strings: ["Option 1", "Option 2"]
    • List of tuples: [{"Label 1", "value1"}, {"Label 2", "value2"}]
    • List of keyword pairs: [key: "value"]
  • animation (:string) - The animation style for the listbox. This controls how the listbox appears and disappears when opening/closing.

    Defaults to "transition duration-150 ease-in-out".

  • animation_enter (:string) - CSS classes applied to the listbox when it enters (opens). Defaults to "opacity-100 scale-100".

  • animation_leave (:string) - CSS classes applied to the listbox when it leaves (closes). Defaults to "opacity-0 scale-95".

Slots

  • option - Optional slot for custom option rendering. When provided, each option can be fully customized with rich content. The slot receives a tuple {label, value} for each option.

  • empty_state - Optional slot for custom empty state rendering. When provided, this content is shown instead of the default "no results" message when no options match the search query.