SutraUI.LiveSelect (Sutra UI v0.1.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. The hidden input contains JSON-encoded {label, value} pairs:

def handle_event("form_change", params, socket) do
  # Single mode: params["city"] is a JSON string like "{"label":"NYC","value":"nyc"}"
  city = SutraUI.LiveSelect.decode(params["city"])
  # => %{"label" => "NYC", "value" => "nyc"}

  # Tags mode: params["tags"] is a list of JSON strings
  tags = SutraUI.LiveSelect.decode(params["tags"])
  # => [%{"label" => "Tag1", "value" => "t1"}, ...]

  {: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 | string | 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 Complex Values

When using maps or complex values, decode form params with decode/1:

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

LiveSelect works with Ecto embeds using the value_mapper attribute:

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

# Handle form change
def handle_event("form_change", params, socket) do
  params = update_in(params, ["form", "cities"], &SutraUI.LiveSelect.decode/1)
  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 =
    case SutraUI.LiveSelect.decode(params["category_id"]) do
      %{"value" => id} -> id
      _ -> nil
    end

  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.map(& &1["value"])
    |> Enum.reject(&is_nil/1)

  # 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;
}

CSS Custom Properties

LiveSelect respects CSS custom properties for theming:

:root {
  --live-select-bg: white;
  --live-select-border: #e5e7eb;
  --live-select-option-hover: #f3f4f6;
  --live-select-tag-bg: #3b82f6;
  --live-select-tag-text: white;
}

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")

    # Type in the search input
    view
    |> element("#city-select input[type=text]")
    |> render_change(%{value: "new"})

    # Simulate the search event (normally triggered by JS hook)
    send(view.pid, {:live_select_change, %{"text" => "new", "id" => "city-select"}})

    # Or call handle_event directly in your test
    render_click(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 form with encoded value
  view
  |> form("#city-form", %{
    "form" => %{
      "city" => ~s({"label":"NYC","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 JSON value correctly 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

Decodes JSON-encoded selection values from form params.

Normalizes a list of options into a consistent format.

Functions

decode(values)

Decodes JSON-encoded selection values from form params.

Uses Phoenix.json_library() for flexibility. Handles decode errors gracefully by returning the original value if not valid JSON.

Examples

iex> SutraUI.LiveSelect.decode(nil)
[]

iex> SutraUI.LiveSelect.decode("")
nil

iex> SutraUI.LiveSelect.decode("nyc")
"nyc"

iex> SutraUI.LiveSelect.decode("42")
42

iex> SutraUI.LiveSelect.decode(~s({"name":"Berlin"}))
%{"name" => "Berlin"}

iex> SutraUI.LiveSelect.decode([~s({"id":1}), ~s({"id":2})])
[%{"id" => 1}, %{"id" => 2}]

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