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:
Feature | Select | Autocomplete |
---|---|---|
Primary Interaction | Click to browse | Type to search |
Data Size | Small to medium lists | Any size dataset |
Filtering | Client-side only | Client or server-side |
Multiple Selection | Supported | Not supported |
Clearing Selection | Supported | Not 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.
Using with Phoenix Forms (Recommended)
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
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.
Server-side Search
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:
Key | Element Focus | Description |
---|---|---|
Tab /Shift+Tab | Input | Moves focus to and from the input |
↑ | Input | Opens listbox and highlights last option |
↓ | Input | Opens listbox and highlights first option |
↑ | Option | Moves highlight to previous visible option |
↓ | Option | Moves highlight to next visible option |
Enter | Option | Selects the highlighted option |
Escape | Any | Closes the listbox |
Type characters | Input | Filters 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
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 tonil
.name
(:any
) - The form name for the autocomplete. Required when not using thefield
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 tofalse
.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 thefield
attribute.errors
(:list
) - List of error messages to display below the autocomplete. These are automatically handled when using thefield
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"]
- List of strings:
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.