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_schema to model the selectable options and keep track of the state. We’ll use Phoenix.HTML.Form helpers to render the HTML inputs for selecting and deselecting the options.

  • Show the options we’ve already chosen: Define a selected_options assign 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-change to 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.toggle command 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