SutraUI.LiveSelect (Sutra UI v0.4.0)

View Source

A 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}
end

If 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)}
end

Attributes

AttributeTypeDefaultDescription
idstringrequiredUnique identifier for the component
fieldPhoenix.HTML.FormField.t()requiredForm 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

# 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 options
  • Enter - Select highlighted option (or create tag if user_defined_options)
  • Escape - Close dropdown
  • Backspace - 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)
  # ...
end

Programmatic 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))}
end

Using 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))}
end

For 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))}
end

Styling 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
end

LiveView 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
end

Testing 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"
end

Troubleshooting

Search event not firing

  • Ensure update_min_len is set appropriately (default: 1)
  • Check browser console for JS errors
  • Verify the LiveView is handling live_select_change event

Selection not updating form

  • Ensure your form has phx-change handler
  • 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/2 is called with correct id
  • 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
  # ...
end

Crash 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 id remains 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))

  # ...
end

Performance with large option lists

  • Limit options returned from search (e.g., limit: 20)
  • Increase debounce to 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

decode(values)

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"

normalize_options(options)

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}]