Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a903907
Fix combobox selection label and value bindings
ukutaht Oct 1, 2026
1b76b31
Retain combobox selection labels independently of search results
ukutaht Oct 1, 2026
ae11579
Separate combobox search queries from selected labels
ukutaht Oct 1, 2026
aae35ee
fix: consolidate combobox event ownership and cleanup
ukutaht Oct 1, 2026
cef200a
refactor: send combobox searches through custom LiveView events
ukutaht Oct 1, 2026
bcbfa46
fix: refresh combobox options on reopening
ukutaht Oct 1, 2026
b308dbb
Preserve explicit combobox field names
ukutaht Oct 1, 2026
2c08649
Back combobox form values with a native select
ukutaht Oct 1, 2026
cd92207
test: consolidate async combobox regression coverage
ukutaht Oct 1, 2026
bcddb9b
test: remove frozen selection label test
ukutaht Oct 1, 2026
1e52901
chore: trim combobox tests and demo documentation
ukutaht Oct 1, 2026
90f5ed8
refactor: simplify combobox hook and consolidate tests
ukutaht Oct 1, 2026
f1d305c
docs: focus combobox guidance on usage
ukutaht Oct 1, 2026
7743332
feat: support server-provided combobox selection
ukutaht Oct 2, 2026
f010e79
refactor: move combobox selections and name to root component
ukutaht Oct 2, 2026
eab27ca
fix: preserve combobox selections after async search dismissal
ukutaht Oct 2, 2026
c08f2cb
fix: reset combobox search on reconnection
ukutaht Oct 2, 2026
d2f1433
refactor: centralize combobox interaction cleanup
ukutaht Oct 2, 2026
2fe57ba
fix: resolve main merge conflicts in combobox improvements
ukutaht Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
346 changes: 169 additions & 177 deletions assets/js/hooks/combobox.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions demo/config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ config :demo, DemoWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4000],
check_origin: false,
code_reloader: true,
# Reload the local component library alongside the demo that consumes it.
reloadable_apps: [:prima, :demo],
debug_errors: true,
secret_key_base: "t1nFkqTRuxcxKzdQGTRZvrYsUzUuYkwWk7I6F88EbwgvnekiBnP2bPH9xX/KWvj1",
server: true,
Expand Down
24 changes: 18 additions & 6 deletions demo/lib/demo_web/components/code_example.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@ defmodule DemoWeb.CodeExample do
use Phoenix.Component
alias Phoenix.LiveView.JS

@examples_dir "priv/code_examples"
@examples_dir Path.expand("../../../priv/code_examples", __DIR__)
@example_paths @examples_dir
|> Path.join("**/*")
|> Path.wildcard()
|> Enum.filter(&File.regular?/1)
@syntax_theme "molokai"
@code_block_classes "p-4 rounded-b-lg overflow-x-auto text-sm"

@live_component_modules [
DemoWeb.DemoLive.AsyncModalDemo,
DemoWeb.DemoLive.FormModalDemo,
DemoWeb.DemoLive.AsyncComboboxDemo,
DemoWeb.DemoLive.SelectionComboboxDemo,
DemoWeb.DemoLive.ListboxFormDemo,
DemoWeb.DemoLive.DisabledListboxDemo
]

for module <- @live_component_modules, do: Code.ensure_compiled(module)

@highlighted_examples (
examples_path = Path.join(File.cwd!(), @examples_dir)

highlight_file = fn file_path ->
relative_path = Path.relative_to(file_path, examples_path)
relative_path = Path.relative_to(file_path, @examples_dir)
content = File.read!(file_path)

highlighted_html =
Expand All @@ -36,8 +39,7 @@ defmodule DemoWeb.CodeExample do
{relative_path, %{highlighted: highlighted_html, source: content}}
end

Path.wildcard(Path.join([examples_path, "**", "*"]))
|> Enum.filter(&File.regular?/1)
@example_paths
|> Enum.map(highlight_file)
|> Map.new()
)
Expand All @@ -46,6 +48,16 @@ defmodule DemoWeb.CodeExample do
@external_resource Path.join([@examples_dir, file_path])
end

def __mix_recompile__? do
paths =
@examples_dir
|> Path.join("**/*")
|> Path.wildcard()
|> Enum.filter(&File.regular?/1)

paths != @example_paths
end

attr :file, :string, required: true, doc: "Path to file in priv/code_examples/"
attr :module, :atom, default: nil, doc: "LiveComponent module to render (for .ex files)"
attr :id, :string, default: nil, doc: "Optional ID for the code example container"
Expand Down
17 changes: 7 additions & 10 deletions demo/lib/demo_web/live/demo_live/async_combobox_demo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,16 @@ defmodule DemoWeb.DemoLive.AsyncComboboxDemo do
@impl true
def render(assigns) do
~H"""
<form phx-submit="save">
<.combobox class="w-64" id="demo-async-combobox">
<div>
<.combobox class="group w-64" id="demo-async-combobox" name="user[favourite_fruit]">
<div class="relative mt-2 rounded-md shadow-sm">
<.combobox_input
name="user[favourite_fruit]"
class="block w-full rounded-md border-0 py-2 pl-3 pr-10 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6 peer"
phx-change="async_combobox_search"
class="block w-full rounded-md border-0 py-2 pl-3 pr-10 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
on_search="async_combobox_search"
phx-target={@myself}
placeholder="Type to search..."
/>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 invisible peer-[.phx-change-loading]:visible">
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 search-spinner invisible group-[.phx-hook-loading:has(input[aria-expanded=true])]:visible">
<svg
class="animate-spin -ml-1 h-5 w-5 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
Expand Down Expand Up @@ -69,14 +68,12 @@ defmodule DemoWeb.DemoLive.AsyncComboboxDemo do
</.combobox_option>
</.combobox_options>
</.combobox>
</form>
</div>
"""
end

@impl true
def handle_event("async_combobox_search", params, socket) do
input = get_in(params, params["_target"])

def handle_event("async_combobox_search", %{"query" => input}, socket) do
suggestions =
Enum.filter(@options, fn option ->
String.contains?(String.downcase(option), String.downcase(input))
Expand Down
37 changes: 29 additions & 8 deletions demo/lib/demo_web/live/demo_live/combobox_page.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,30 @@

<h2>Advanced Usage</h2>

<h3>Selection</h3>
<p>
Set <code>selections</code>
on the combobox to supply a selected item.
Use a map with <code>value</code>
and an optional <code>display</code>
label.
Pass the text supplied by <code>:let</code>
to the input's <code>value</code>
to display it immediately.
Choose another country, then click Reset to restore the initial selection from the server.
</p>

<div class="not-prose">
<.code_example
file="combobox/selection_combobox_demo.ex"
module={DemoWeb.DemoLive.SelectionComboboxDemo}
id="selection-combobox-demo"
/>
</div>

<h3>Server-side Search</h3>
<p>
For large datasets or dynamic content, enable server-side search by adding
<code>phx-change</code>
to the input.
The component automatically switches to async mode with debounced search queries.
Set <code>on_search</code> on the input to enable debounced server-side search.
</p>

<div class="not-prose">
Expand All @@ -54,9 +72,8 @@
</div>

<p>
The async pattern sends search queries to your LiveView with automatic debouncing. Handle the
<code>phx-change</code>
event to filter results server-side and update the options dynamically.
Handle the event with <code>{"%{\"query\" => query}"}</code> and return matching options.
Return default options when the query is empty. No enclosing form is required.
</p>

<h3>Create Custom Values</h3>
Expand Down Expand Up @@ -91,7 +108,11 @@
The <code>.combobox_selections</code>
component uses a template-based approach for rendering pills. Customize the appearance using the
<code>:selection</code>
slot - it supports any markup including icons, badges, and remove buttons. The dropdown stays open after each selection for quick multi-select workflows, and selected items are visually highlighted with <code>data-selected="true"</code>.
slot - it supports surrounding markup including icons and badges. Use
<code>.combobox_selection_label</code>
to display the label and <code>.combobox_selection_remove</code>
for a button bound to the selected value.
Selected items are visually highlighted with <code>data-selected="true"</code>.
</p>

<p>
Expand Down
3 changes: 1 addition & 2 deletions demo/lib/demo_web/live/demo_live/form_modal_demo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,8 @@ defmodule DemoWeb.DemoLive.FormModalDemo do
<label class="block text-sm font-medium leading-6 text-gray-900 mb-2">
Category
</label>
<.combobox class="w-full" id="form-modal-combobox">
<.combobox class="w-full" id="form-modal-combobox" name={@form[:category].name}>
<.combobox_input
name={@form[:category].name}
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 cursor-default placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
placeholder="Select a category..."
/>
Expand Down
77 changes: 77 additions & 0 deletions demo/lib/demo_web/live/demo_live/selection_combobox_demo.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
defmodule DemoWeb.DemoLive.SelectionComboboxDemo do
@moduledoc false
use DemoWeb, :live_component
import Prima.Combobox

@countries [
%{value: "GB", display: "United Kingdom"},
%{value: "US", display: "United States"},
%{value: "DE", display: "Germany"}
]

@impl true
def mount(socket) do
{:ok, assign(socket, countries: @countries, selection: hd(@countries))}
end

@impl true
def render(assigns) do
~H"""
<form class="max-w-sm" phx-change="select-country" phx-target={@myself}>
<label for="selected-country-input" class="block text-sm font-medium text-gray-700 mb-2">
Country
</label>
<.combobox
:let={input_value}
id="selected-country"
class="w-64"
name="country"
selections={@selection}
>
<.combobox_input
value={input_value}
id="selected-country-input"
placeholder="Search countries..."
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm"
/>

<.combobox_options
id="selected-country-options"
offset={4}
class="max-h-60 w-64 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-gray-200 focus:outline-none sm:text-sm z-50"
>
<.combobox_option
:for={country <- @countries}
value={country.value}
display={country.display}
class="cursor-default select-none py-2 px-3 text-gray-900 data-focus:bg-indigo-600 data-focus:text-white data-selected:font-semibold"
>
{country.display}
</.combobox_option>
</.combobox_options>
</.combobox>

<button
id="reset-country"
type="button"
phx-click="reset"
phx-target={@myself}
class="mt-3 rounded-md border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Reset
</button>
</form>
"""
end

@impl true
def handle_event("select-country", params, socket) do
selection = Enum.find(@countries, &(&1.value == params["country"]))
{:noreply, assign(socket, :selection, selection)}
end

@impl true
def handle_event("reset", _params, socket) do
{:noreply, assign(socket, :selection, hd(@countries))}
end
end
32 changes: 27 additions & 5 deletions demo/lib/demo_web/live/fixtures_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ defmodule DemoWeb.FixturesLive do
]

@impl true
def mount(_params, _session, socket) do
def mount(params, _session, socket) do
socket =
socket
|> assign(async_modal_open?: false)
|> assign(selected_fruit: nil)
|> assign(form_change_count: 0)
|> assign(submission_multiple: params["multiple"] == "true")
|> assign(
submission_selection:
if(params["selection"], do: Jason.decode!(params["selection"], keys: :atoms!))
)
|> assign(submission_change: %{})
|> assign(listbox_disabled?: false, submitted_fruit: "not submitted")
|> assign(trigger_label: "Open Dropdown")
|> assign(modal_title: "Good news")
Expand Down Expand Up @@ -50,9 +56,7 @@ defmodule DemoWeb.FixturesLive do
end

@impl true
def handle_event("async_combobox_search", params, socket) do
input = get_in(params, params["_target"])

def handle_event("async_combobox_search", %{"query" => input}, socket) do
suggestions =
Enum.filter(@options, fn option ->
String.contains?(String.downcase(option), String.downcase(input))
Expand All @@ -62,7 +66,8 @@ defmodule DemoWeb.FixturesLive do
end

@impl true
def handle_event("form_changed", %{"fruit" => fruit}, socket) do
def handle_event("form_changed", params, socket) do
fruit = params["fruit"]
# Treat empty string as nil for display purposes
selected_fruit = if fruit == "", do: nil, else: fruit

Expand All @@ -74,6 +79,23 @@ defmodule DemoWeb.FixturesLive do
{:noreply, socket}
end

@impl true
def handle_event("submission_changed", params, socket) do
selection =
for value <- List.wrap(params["fruits"] || params["fruit"]) do
Enum.find(List.wrap(socket.assigns.submission_selection), &(to_string(&1.value) == value)) ||
%{value: value}
end

selection = if socket.assigns.submission_multiple, do: selection, else: List.first(selection)

{:noreply,
socket
|> assign(submission_change: params)
|> assign(submission_selection: selection)
|> update(:form_change_count, &(&1 + 1))}
end

@impl true
def handle_event("toggle-listbox-disabled", _params, socket) do
{:noreply, update(socket, :listbox_disabled?, &(!&1))}
Expand Down
17 changes: 17 additions & 0 deletions demo/lib/demo_web/live/fixtures_live.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@
<.multi_select_combobox_fixture />
</div>

<div :if={@live_action == :selection_template_combobox}>
<.selection_template_combobox_fixture />
</div>

<div :if={@live_action == :combobox_form_tab}>
<.combobox_form_tab_fixture />
</div>
Expand All @@ -93,6 +97,19 @@
<.combobox_change_fixture {assigns} />
</div>

<div :if={@live_action == :combobox_submission}>
<.combobox_submission_fixture
multiple={@submission_multiple}
selection={@submission_selection}
/>
<div
id="submission-state"
data-change-count={@form_change_count}
data-change={Jason.encode!(@submission_change)}
>
</div>
</div>

<div :if={@live_action == :async_combobox_form_change}>
<.async_combobox_form_change_fixture {assigns} />
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
<form phx-submit="save">
<.combobox id="demo-async-combobox">
<.combobox_input
name="user[favourite_fruit]"
phx-change="async_combobox_search"
placeholder="Type to search..."
/>
<.combobox id="demo-async-combobox" name="user[favourite_fruit]">
<.combobox_input
on_search="async_combobox_search"
placeholder="Type to search..."
/>

<.combobox_options
phx-update="stream"
class="w-64 py-1"
id="demo-async-combobox-options"
>
<%= for {id, option} <- @streams.suggestions do %>
<.combobox_option
id={id}
value={option}
>
{option}
</.combobox_option>
<% end %>
</.combobox_options>
</.combobox>
</form>
<.combobox_options
phx-update="stream"
class="w-64 py-1"
id="demo-async-combobox-options"
>
<%= for {id, option} <- @streams.suggestions do %>
<.combobox_option
id={id}
value={option}
>
{option}
</.combobox_option>
<% end %>
</.combobox_options>
</.combobox>
Loading
Loading