Building a Reusable Phoenix LiveView Multi-Select Component
Have you ever wanted a feature that lets your users select multiple items from a list, and performs some action on their selection? This is a common feature to build. Today we’ll walk through building a reusable Phoenix LiveView multi-select component, and hook it up to let users filter a book collection by category. A single book can be both a “Romance” and a “Thriller” at the same time. We want to let users choose any combination of categories using the multi-select component we’re about to build.
Roadmap
We’ll define our component using the Phoenix.LiveComponent module, so it can manage its own state and events. We want this component to:
Display a list of selectable options: Use
embedded_schemato model the selectable options and keep track of the state. We’ll usePhoenix.HTML.Formhelpers to render the HTML inputs for selecting and deselecting the options.Show the options we’ve already chosen: Define a
selected_optionsassign to track and render the component’s selections.Send selection updates to the parent LiveView: Each time an option is selected, the multi-select component should inform the parent LiveView of any changes to the selection. This will happen using
phx-changeto emit an event when an input has changed.Update when the selection changes: Take advantage of the LiveComponent life cycle to propagate updates and render changes.
Show/hide the list of selectable options: Use the
JS.togglecommand to hide and show the options list.
Once we finish our work, we’ll have this component ready to be used!
Creating a LiveComponent
We start by defining a skeleton LiveComponent—which we name MultiSelectComponent—with placeholder render/1 and update/2 callbacks.
defmodule PhoenixFilesWeb.MultiSelectComponent do
use PhoenixFilesWeb, :live_component
alias PhoenixFiles.MultiSelect
def render(assigns) do
~H"""
<div>
</div>
"""
end
def update(%{id: id} = params, socket) do
{:ok, assign(socket, :id, id)}
end
end
Defining a data model
Each option in our list needs a label to display, and some way to keep track of whether it is selected or not.
defmodule PhoenixFiles.MultiSelect do
use Ecto.Schema
embedded_schema do
embeds_many :options, PhoenixFiles.MultiSelect.SelectOption
end
defmodule SelectOption do
use Ecto.Schema
embedded_schema do
field :selected, :boolean, default: false
field :label, :string
end
end
end
This defines an embedded schema called MultiSelect, where the :options field embeds a list of SelectOption schemas.
Rendering the selectable options within a form
The update/2 function adds :selectable_options (a list of SelectOption schemas) and a yet-to-be-defined enclosing :form to the component’s assigns.
def update(params, socket) do
%{options: options, form: form, id: id} = params
socket =
socket
|> assign(:id, id)
|> assign(:selectable_options, options)
|> assign(:form, form)
{:ok, socket}
end
Then render/1 uses those assigns to put the pieces together in our template:
def render(assigns) do
~H"""
<div class="multiselect">
<div class="fake_select_tag"
id={"#{@id}-selected-options-container"}>
...
</div>
<div id={"#{@id}-options-container">
<%= inputs_for @form, :options, fn opt -> %>
<div class="form-check">
<div class="selectable-option">
<%= checkbox opt, :selected,
value: opt.data.selected
%>
<%= label opt, :label, opt.data.label %>
</div>
</div>
<% end %>
</div>
</div>
"""
end
The main feature here is our inputs_for/4 function, which attaches our nested :options data to the form and iterates over the options, invoking the checkbox/3 and label/3 functions to render those elements.
Displaying the set of selected options
We want to display a list of selected options. We write a private function filter_selected_options to find all the SelectOptions with selected == true, and add these to our assigns:
def update(params, socket) do
%{options: options, form: form, id: id} = params
socket =
socket
|> assign(:id, id)
|> assign(:selectable_options, options)
|> assign(:form, form)
|> assign(:selected_options, filter_selected_options(options))
{:ok, socket}
end
defp filter_selected_options(options) do
Enum.filter(options, fn opt ->
opt.selected in [true, "true"]
end)
end
We can iterate over @selected_options and display their labels:
<div class="fake_select_tag"
id={"#{@id}-selected-options-container"}>
<%= for option <- @selected_options do %>
<div class="selected_option">
<%= option.label %>
</div>
<% end %>
<div class="icon">
...
</div>
</div>
Sending selection updates
To update the checkboxes, we need to emit an event every time items are selected or deselected:
<%= checkbox value, :selected,
value: value.data.selected,
phx_change: "checked",
phx_target: @myself
%>
Now let’s see how to handle the checked event:
def handle_event(
"checked",
%{"multi_select" => %{"options" => values}},
socket
) do
[{index, %{"selected" => selected?}}] = Map.to_list(values)
index = String.to_integer(index)
selectable_options = socket.assigns.selectable_options
current_option = Enum.at(selectable_options, index)
updated_options =
List.replace_at(selectable_options,
index,
%{current_option | selected: selected?}
)
send(self(), {:updated_options, updated_options})
{:noreply, socket}
end
When the event is emitted, it tells us when a selection change was made.
Showing and hiding the selectable options
We can add a chevron-up icon to match the chevron-down—to toggle visibility of icons and options list.
<div class="icon">
<svg id={"#{@id}-down-icon"}
phx-click={
JS.toggle()
|> JS.toggle(to: "##{@id}-up-icon")
|> JS.toggle(to: "##{@id}-options-container")
}>
<path ... />
</svg>
<svg id={"#{@id}-up-icon" class="hidden"
phx-click={
JS.toggle()
|> JS.toggle(to: "##{@id}-down-icon")
|> JS.toggle(to: "##{@id}-options-container")
}>
<path .... />
</svg>
</div>
MultiSelectComponent in action
Now we define the assigns we use in the parent LiveView:
def mount(_params, _session, socket) do
categories =
[
%SelectOption{id: 1, label: "Fantasy", selected: false},
%SelectOption{id: 2, label: "Horror", selected: true},
%SelectOption{id: 3, label: "Literary Fiction", selected: false},
]
{:ok, set_assigns(socket, categories)}
end
categories contains our category options in the shape of the SelectOption schema. Our set_assigns/2 function sets assigns we’ll need:
defp set_assigns(socket, categories) do
socket
|> assign(:changeset, build_changeset(categories))
|> assign(:books, filter_books(categories))
|> assign(:categories, categories)
end
We create a MultiSelect changeset with the build_changeset/1 function:
defp build_changeset(options) do
%MultiSelect{}
|> Ecto.Changeset.change()
|> Ecto.Changeset.put_embed(:options, options)
end
Once the changeset is created, we use it to create our form:
<.form let={f} for={@changeset} id="multiselect-form">
</.form>
Finally, we render our MultiSelectComponent inside our form:
<.form let={f} for={@changeset} id="multiselect-form">
<.live_component
id="multi"
module={MultiSelectComponent}
options={@categories}
form={f}
/>
</.form>
When a category is selected in the MultiSelectComponent, an update is sent to the parent LiveView:
def handle_info({:updated_options, options}, socket) do
# update books list, the selected categories and the changeset
{:noreply, set_assigns(socket, options)}
end
Bonus: Form recovery
To avoid losing the selected options, we will make use of form recovery after reconnections. We mark our form with the phx-change binding:
<.form
let={f}
for={@changeset}
id="multiselect-form"
phx-change="validate"
>
...
</.form>
def handle_event(
"validate",
%{"multi_select" => multi_form},
socket
) do
options = build_options(multi_form["options"])
{:noreply, set_assigns(socket, options)}
end
Bonus 2: Customized behavior when options are selected.
We send a function to execute when an element is selected:
<.live_component
id="multi"
module={MultiSelectComponent}
options={@categories}
form={f}
selected={fn opts -> send(self(), {:updated_options, opts}) end}
/>
Wrap-Up
We designed a solid base to reuse our component and customize it for our needs. The LiveView structure allows us to keep both the parent and MultiSelectComponent in sync effectively.
Where else would you use this multi-select component? You can find this example here: bemesa21/phoenix_files