SutraUI.LiveSelect (Sutra UI v0.4.0)
View SourceA searchable selection component with single, tags, and quick_tags modes.
Basic Usage
<.live_component
module={SutraUI.LiveSelect}
id="city-select"
field={@form[:city]}
/>Handling Search Events
When the user types, the JS hook sends a live_select_change event directly to the parent
(or the target specified by phx-target):
def handle_event("live_select_change", %{"text" => text, "id" => id, "field" => field}, socket) do
options = MyApp.search_cities(text)
send_update(SutraUI.LiveSelect, id: id, options: options)
{:noreply, socket}
endIf your form is in a LiveComponent, add
phx-target={@myself}to LiveSelect.
Handling Selection Changes
Selection changes are communicated via the form's standard phx-change event.
Hidden inputs use structured names with plain submitted values:
def handle_event("form_change", params, socket) do
# Single mode: params["city"] is %{"value" => "nyc"}
city = SutraUI.LiveSelect.decode(params["city"])
# => "nyc"
# Tags mode: params["tags"] is %{"id" => ["elixir", "phoenix"]}
tags = SutraUI.LiveSelect.decode(params["tags"])
# => ["elixir", "phoenix"]
{:noreply, assign(socket, city: city, tags: tags)}
endAttributes
| Attribute | Type | Default | Description |
|---|---|---|---|
id | string | required | Unique identifier for the component |
field | Phoenix.HTML.FormField.t() | required | Form field for the hidden input |
| mode | :single | :tags | :quick_tags | :single | Selection mode |
| placeholder | string | nil | Placeholder text for the input |
| disabled | boolean | false | Disable the input |
| allow_clear | boolean | false | Show clear button (single mode) |
| debounce | integer | 100 | Debounce time in ms for search |
| update_min_len | integer | 1 | Minimum characters before search |
| user_defined_options | boolean | false | Allow creating tags by typing (tags modes) |
| max_selectable | integer | 0 | Max tags allowed (0 = unlimited) |
| phx-target | selector or @myself | nil | Target for search events (for LiveComponents) |
| class | string | nil | Additional CSS classes |
| value_mapper | function | & &1 | Function to map form values to options (for Ecto) |
Options Format
Options can be provided in many formats:
# Simple strings/atoms/numbers (label = value)
["New York", "Los Angeles", "Chicago"]
# Tuples {label, value}
[{"New York", "nyc"}, {"Los Angeles", %{lat: 34.05, lng: -118.24}}]
# Tuples with disabled flag {label, value, disabled}
[{"New York", "nyc", false}, {"Coming Soon", "tbd", true}]
# Maps with :label and :value
[%{label: "New York", value: "nyc"}]
# Maps with :key (alias for :label) and :value
[%{key: "New York", value: "nyc"}]
# Maps with :value only (value becomes label)
[%{value: "nyc"}]
# Keywords
[[label: "New York", value: "nyc"]]
# Map as options (sorted, keys become labels)
%{NYC: "nyc", LA: "la"}Optional Fields
%{
label: "New York", # Required
value: "nyc", # Required
disabled: true, # Cannot be selected
sticky: true, # Cannot be removed (tags mode)
tag_label: "NY" # Alternative label for tag display
}Duplicate Detection
LiveSelect prevents duplicate selections by comparing the value field, not the label.
This means two options with the same value but different labels are considered duplicates:
# These are considered the same option (same value)
%{label: "New York", value: "nyc"}
%{label: "NYC", value: "nyc"}
# These are different options (different values)
%{label: "New York", value: "new_york"}
%{label: "New York", value: "nyc"}This is intentional - the value is the semantic identifier, while label is just for display.
Modes
:single- Select one option, input shows selected label:tags- Multi-select with removable tags, dropdown closes on select:quick_tags- Multi-select, dropdown stays open for rapid selection
Slots
<.live_component module={SutraUI.LiveSelect} id="city-select" field={@form[:city]}>
<:option :let={option}>
<span class="font-bold"><%= option.label %></span>
</:option>
<:tag :let={option}>
<span><%= option.tag_label || option.label %></span>
</:tag>
</.live_component>Complete Examples
Single Select with Search
# In your LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, form: to_form(%{"city" => nil}))}
end
def handle_event("live_select_change", %{"text" => text, "id" => id}, socket) do
options = Repo.all(from c in City, where: ilike(c.name, ^"%#{text}%"), limit: 10)
send_update(SutraUI.LiveSelect, id: id, options: Enum.map(options, &{&1.name, &1.id}))
{:noreply, socket}
end
# In your template
<form phx-change="form_change">
<.live_component
module={SutraUI.LiveSelect}
id="city-select"
field={@form[:city]}
placeholder="Search cities..."
allow_clear
/>
</form>Tags Mode with Max Selection
<.live_component
module={SutraUI.LiveSelect}
id="tags-select"
field={@form[:tags]}
mode={:tags}
placeholder="Add tags..."
max_selectable={5}
/>User-Defined Tags (Quick Tags)
<.live_component
module={SutraUI.LiveSelect}
id="custom-tags"
field={@form[:custom_tags]}
mode={:quick_tags}
placeholder="Type and press Enter..."
user_defined_options
/>Keyboard Navigation
ArrowDown/ArrowUp- Navigate optionsEnter- Select highlighted option (or create tag ifuser_defined_options)Escape- Close dropdownBackspace- Remove last tag (when input is empty, tags mode)
Styling
LiveSelect uses CSS classes from sutra_ui.css. The main classes are:
.live-select- Container element.live-select-input- Text input field.live-select-dropdown- Dropdown container.live-select-option- Individual option.live-select-option-selected- Selected option (in quick_tags mode).live-select-option-disabled- Disabled option.live-select-tags- Tags container.live-select-tag- Individual tag.live-select-tag-remove- Tag remove button.live-select-clear- Clear button (single mode)
To customize, override these classes in your CSS. Pass additional classes via the class attribute.
Decoding Submitted Values
Use decode/1 on the submitted form params. Current hidden inputs submit
plain values; legacy JSON strings are still decoded for backwards
compatibility.
def handle_event("save", %{"form" => params}, socket) do
params = update_in(params, ["city"], &SutraUI.LiveSelect.decode/1)
# ...
endProgrammatic Value Setting
Set initial selection via the value attribute in your template:
<.live_component
module={SutraUI.LiveSelect}
id="city-select"
field={@form[:city]}
value={@initial_city}
/>The value attribute only applies on initial mount. To update selection after mount
(e.g., to clear it), use send_update/2 with reset_value:
# Clear selection
send_update(SutraUI.LiveSelect, id: "city-select", reset_value: [])
# Set new selection
send_update(SutraUI.LiveSelect, id: "tags-select", reset_value: [
%{label: "Tag1", value: "t1"},
%{label: "Tag2", value: "t2"}
])Using with Ecto Embeds
Use stable IDs or string tokens as submitted values, then load or build embed
data in the parent LiveView. value_mapper is useful for rendering initial
values as options:
# Schema
embedded_schema do
embeds_many(:cities, City, on_replace: :delete)
end
# Template
<.live_component
module={SutraUI.LiveSelect}
id="cities-select"
field={@form[:cities]}
mode={:tags}
value_mapper={&city_to_option/1}
/>
# Helper
defp city_to_option(%City{name: name} = city) do
%{label: name, value: city.id}
end
# Handle form change
def handle_event("form_change", params, socket) do
city_ids = SutraUI.LiveSelect.decode(params["form"]["cities"])
params = put_in(params, ["form", "city_ids"], city_ids)
changeset = MyForm.changeset(params)
{:noreply, assign(socket, form: to_form(changeset))}
endUsing with Ecto Associations
For belongs_to associations, map the foreign key:
# Schema
schema "posts" do
belongs_to :category, Category
end
# Template
<.live_component
module={SutraUI.LiveSelect}
id="category-select"
field={@form[:category_id]}
placeholder="Select category..."
/>
# Handle search
def handle_event("live_select_change", %{"text" => text, "id" => id}, socket) do
categories = Repo.all(from c in Category, where: ilike(c.name, ^"%#{text}%"), limit: 10)
options = Enum.map(categories, &{&1.name, &1.id})
send_update(SutraUI.LiveSelect, id: id, options: options)
{:noreply, socket}
end
# Handle form change - extract just the value (id)
def handle_event("form_change", %{"post" => params}, socket) do
category_id = SutraUI.LiveSelect.decode(params["category_id"])
changeset = Post.changeset(socket.assigns.post, %{params | "category_id" => category_id})
{:noreply, assign(socket, form: to_form(changeset))}
endFor many_to_many associations with tags mode:
# Schema
schema "posts" do
many_to_many :tags, Tag, join_through: "posts_tags", on_replace: :delete
end
# Template
<.live_component
module={SutraUI.LiveSelect}
id="tags-select"
field={@form[:tag_ids]}
mode={:tags}
placeholder="Add tags..."
/>
# Handle form change - extract tag IDs
def handle_event("form_change", %{"post" => params}, socket) do
tag_ids =
params["tag_ids"]
|> SutraUI.LiveSelect.decode()
|> Enum.reject(&(&1 in ["", nil]))
# Load tags and put_assoc in changeset
tags = Repo.all(from t in Tag, where: t.id in ^tag_ids)
changeset =
socket.assigns.post
|> Post.changeset(params)
|> Ecto.Changeset.put_assoc(:tags, tags)
{:noreply, assign(socket, form: to_form(changeset))}
endStyling Guide
LiveSelect is styled via CSS classes defined in sutra_ui.css. To customize:
Override Default Styles
Target the CSS classes directly in your application CSS:
/* Custom dropdown styling */
.live-select-dropdown {
max-height: 300px;
border: 1px solid var(--border-color);
border-radius: 8px;
}
/* Custom option hover state */
.live-select-option[data-active="true"] {
background-color: var(--primary-100);
}
/* Custom tag styling */
.live-select-tag {
background-color: var(--primary-500);
color: white;
border-radius: 9999px;
padding: 2px 8px;
}Add Custom Classes
Pass additional classes via the class attribute:
<.live_component
module={SutraUI.LiveSelect}
id="styled-select"
field={@form[:city]}
class="my-custom-select"
/>Then style with:
.my-custom-select .live-select-input {
font-size: 1.125rem;
}Theme Tokens
LiveSelect uses the shared Sutra UI theme tokens (--background, --input,
--popover, --accent, --primary, and related foreground tokens). Override
those tokens for broad theming, or target the .live-select-* classes for a
one-off customization.
Testing LiveViews with LiveSelect
Unit Testing decode/1 and normalize_options/1
test "decodes single selection" do
encoded = ~s({"label":"NYC","value":"nyc"})
assert SutraUI.LiveSelect.decode(encoded) == %{"label" => "NYC", "value" => "nyc"}
end
test "decodes tags selection" do
encoded = [~s({"label":"A","value":"a"}), ~s({"label":"B","value":"b"})]
decoded = SutraUI.LiveSelect.decode(encoded)
assert length(decoded) == 2
endLiveView Integration Testing
Use Phoenix.LiveViewTest to test the full flow:
defmodule MyAppWeb.CitySelectLiveTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "searches and selects a city", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/cities")
# Simulate the hook event sent as the user types
render_hook(view, "live_select_change", %{
"text" => "new",
"id" => "city-select",
"field" => "city"
})
# Assert options are displayed
assert render(view) =~ "New York"
end
endTesting Form Submission
test "submits form with selected value", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/cities")
# Simulate selection by submitting the structured hidden input shape
view
|> form("#city-form", %{
"form" => %{
"city" => %{"value" => "nyc"}
}
})
|> render_submit()
# Assert the selection was processed
assert render(view) =~ "Selected: NYC"
endTroubleshooting
Search event not firing
- Ensure
update_min_lenis set appropriately (default: 1) - Check browser console for JS errors
- Verify the LiveView is handling
live_select_changeevent
Selection not updating form
- Ensure your form has
phx-changehandler - Check that hidden input is being updated (inspect DOM)
- Verify you're decoding the submitted value with
decode/1
Options not appearing
- Ensure
send_update/2is called with correctid - Check that options are in a valid format (see Options Format section)
- Verify the component received options by inspecting assigns
LiveComponent target issues
If LiveSelect is inside a LiveComponent, add phx-target:
<.live_component
module={SutraUI.LiveSelect}
id="city-select"
field={@form[:city]}
phx-target={@myself}
/>And handle the event in your LiveComponent:
def handle_event("live_select_change", params, socket) do
# ...
endCrash recovery not working
LiveSelect stores selection in the JS hook and sends a recover event on reconnect.
If recovery fails:
- Check browser console for errors during reconnect
- Ensure your LiveView doesn't clear component state on mount
- Verify the component
idremains consistent across reconnects
Empty selection submitting [""] instead of []
This is expected HTML behavior for empty array inputs. Handle it in your code:
def handle_event("form_change", %{"tags" => tags}, socket) do
tags =
tags
|> SutraUI.LiveSelect.decode()
|> Enum.reject(&(&1 == "" or &1 == nil))
# ...
endPerformance with large option lists
- Limit options returned from search (e.g.,
limit: 20) - Increase
debounceto reduce server calls (e.g.,debounce={300}) - Consider virtual scrolling for very large lists (not built-in)
Summary
Functions
Extracts the selected value from form params.
Normalizes a list of options into a consistent format.
Functions
Extracts the selected value from form params.
Hidden inputs now use plain values (not JSON). Form params arrive as:
- Single mode:
%{"value" => "nyc", "label" => "New York"}→ returns"nyc" - Tags mode:
%{"id" => ["nyc", "london"]}→ returns["nyc", "london"] - Legacy JSON strings are still decoded for backwards compatibility.
Examples
iex> SutraUI.LiveSelect.decode(nil)
[]
iex> SutraUI.LiveSelect.decode("")
nil
iex> SutraUI.LiveSelect.decode(%{"value" => "nyc", "label" => "New York"})
"nyc"
iex> SutraUI.LiveSelect.decode(%{"id" => ["nyc", "london"]})
["nyc", "london"]
iex> SutraUI.LiveSelect.decode("nyc")
"nyc"
Normalizes a list of options into a consistent format.
This is useful for pre-processing options before passing them to
send_update/2. Each option is normalized to a map with :label,
:value, :disabled, :sticky, and :tag_label keys.
Examples
iex> SutraUI.LiveSelect.normalize_options(["NYC", "LA"])
[%{label: "NYC", value: "NYC", disabled: false},
%{label: "LA", value: "LA", disabled: false}]
iex> SutraUI.LiveSelect.normalize_options([{"New York", "nyc"}])
[%{label: "New York", value: "nyc", disabled: false}]