Tutorial

View on Github

How to create a custom select with Alpine JS and Phoenix LiveView

formsalpinejsliveview

In this tutorial, I want to go through how to build a custom select field that is used in Tailwind UI. And I will build it with Alpine JS and Phoenix LiveView.

I will use one of the free examples from tailwind UI. And as you can see i can interact with select field by using keyboards and mouse.

To implement this custom select fields is not straight forward and I want to go through how to timplement this in a Phoenix LiveView form.

STEP 1 - Initial setup of the LiveView form

To get started. I want to generate a new resource and since it doesn’t have to have a database, I was just going to go with an embedded resource for now.

mix phx.gen.embedded Todo.Task assignee:string

I will create a minimum context module for this. The only thing I need is the change task functionality, because I am going to build a changeset that is used for the actual form.

# lib/tutorial/todo.ex
defmodule Tutorial.Todo do
  @moduledoc """
  The Todo context.
  """

  alias Tutorial.Todo.Task

  def change_task(%Task{} = task, attrs \\ %{}) do
    Task.changeset(task, attrs)
  end
end

And also while I’m here, I’m going to paste in some hard coded data that I can work with.

# lib/tutorial/todo.ex

  defmodule User do
    defstruct name: nil, image: nil
  end

  def list_users() do
    [
      %User{
        name: "Tom Cook",
        image: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
      },
      %User{
        name: "Hellen Schmidt",
        image: "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
      },
      %User{
        name: "Emil Schaefer",
        image: "https://images.unsplash.com/photo-1561505457-3bcad021f8ee?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
      }
    ]
  end

I am building this in the PageLive module. For the LiveView form, I need the changeset and I need the list of users.

# lib/tutorial_web/live/page_live.ex
defmodule TutorialWeb.PageLive do
  use TutorialWeb, :live_view

  alias Tutorial.Todo
  alias Tutorial.Todo.Task

  @impl true
  def mount(_params, _session, socket) do
    changeset = Todo.change_task(%Task{})
    users = Todo.list_users()

    {
      :ok,
      socket
      |> assign(:changeset, changeset)
      |> assign(:users, users)
    }
  end
end

In the template I will just add a normal LiveView form that has a select-field with all the hard coded users as options.

<!-- lib/tutorial_web/live/page_live.html.leex -->
<section class="prose">
  <%= f = form_for @changeset, "#", phx_change: "update" %>
    <%= label f, :assignee, class: "block text-sm font-medium text-gray-700" %>
    <%= select f, :assignee, Enum.map(@users, &(&1.name)), class: "block w-full px-3 py-2 mt-1 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %>
  </form>
</section>

At this point, I don’t care about actually submitting the form. So I just want to have handle event update when someone changes the select field.

# lib/tutorial_web/live/page_live.ex

  @impl true
  def handle_event("update", %{"task" => task_params}, socket) do
    changeset =
      %Task{}
      |> Todo.change_task(task_params)
      |> Map.put(:action, :validate)

    {:noreply, assign(socket, :changeset, changeset)}
  end

Since I want this component to be reusable, I’m actually going to extract it to a live component. I’ll create a custom select component and later add it to the LiveView template.

# lib/tutorial_web/live/custom_select_component.ex
defmodule TutorialWeb.Live.CustomSelectComponent do
  use TutorialWeb, :live_component

  @impl true
  def update(assigns, socket) do
    {:ok,
      socket
      |> assign(assigns)
    }
  end
end

And for now just create an empty file: lib/tutorial_web/live/custom_select_component.html.leex

Before using the component, add an alias to it for convenience

# lib/tutorial_web/live/page_live.ex
alias TutorialWeb.Live.CustomSelectComponent

Render the component in the LiveView page with:

<!-- lib/tutorial_web/live/page_live.html.leex -->
<%= live_component @socket, CustomSelectComponent, id: "sample-1", f: f, name: :assignee, options: @users %>

With all that in place, its time to build the the actual component.

STEP 2 - Add the initial markup and functionality

The goal here is to setup the component and make the Alpine javascript interactable.

Note that attributes to start with x- something or an @ sign are AlpineJs connected attributes. It attaches state or event triggers.

Also note that x-data="{ open: false }" sets up the Alpine components initial state as open to false.

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
<div
  id="<%= @id %>"
  x-data="{ open: false }"
>
  <label class="block text-sm font-medium text-gray-700" @click="$refs.button.focus()">
    Assignee
  </label>

  <div class="relative mt-1">
    <button
      type="button"
      class="relative w-full py-2 pl-3 pr-10 text-left bg-white border border-gray-300 cursor-default rounded-md shadow-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
      x-ref="button"
      @click="open = !open"
      @keydown.escape.window="open = false"
    >
      <span class="flex items-center">
        <img src="<%= List.first(@options).image %>" class="flex-shrink-0 w-6 h-6 rounded-full">
        <span class="block ml-3 truncate"><%= List.first(@options).name %></span>
      </span>
      <span class="absolute inset-y-0 right-0 flex items-center pr-2 ml-3 pointer-events-none">
        <svg class="w-5 h-5 text-gray-400" x-description="Heroicon name: solid/selector" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"></path>
        </svg>
      </span>
    </button>

    <ul
        x-show="open"
        x-transition:leave="transition ease-in duration-100"
        x-transition:leave-start="opacity-100"
        x-transition:leave-end="opacity-0"
        class="absolute z-10 w-full py-1 mt-1 overflow-auto text-base bg-white shadow-lg max-h-56 rounded-md ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
        @click.away="open = false"
        tabindex="-1"
        role="listbox"
      >

      <%= for {option, idx} <- Enum.with_index(@options)  do %>
        <li
          class="relative py-2 pl-3 text-gray-900 cursor-default select-none pr-9"
          role="option"
        >
          <div class="flex items-center">
            <img src="<%= option.image %>" class="flex-shrink-0 w-6 h-6 rounded-full">
            <span class="block ml-3 font-normal truncate">
              <%= option.name %>
            </span>
          </div>
        </li>
      <% end %>
    </ul>
  </div>
</div>

When I output all the options, I want to iterate with an index. However, that index won’t be used until a little later.

At this point, the only thing the component does it to open and show the list and close.

STEP 3 - Add more functionality

To keep track of open and close state is not enough. I also want to keep track on the selected option and the arrow keys position.

That means that I want to give a few more things to Alpine to keep track on.

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
x-data="{ open: false, idx: -1, selectedIdx: null, max: <%= length(@options) - 1 %> }"
x-init="() => { $watch('idx', val => console.log('idx', idx) ) }"

Note that the $watch inside the x-init watches the value of index and when that changes, it runs a function that in turn just console.log the idx variable.

Add this as attributes to the button element:

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
@keydown.enter.stop.prevent="selectedIdx = idx"
@keydown.arrow-up.prevent="idx = idx === 0 ? max : idx - 1"
@keydown.arrow-down.prevent="idx = idx === max ? 0 : idx + 1"

This means that I can control the idx attribute with both the arrow keys and the mouse arrow.

Within the li element, I want to add som conditional styling dependent on if the list element has the current idx .

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
:class="{ 'text-white bg-indigo-600': idx === <%= idx %>, 'text-gray-900': !(idx === <%= idx %>) }"

Also, within the li element, I can add a click handler and a mouse enter handler. The mouseenter will set the idx to the current li and the click will select the current li (and idx).

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
@click="selectedIdx = idx"
@mouseenter="idx = <%= idx %>"

STEP 4 - Interact with Phoenix LiveView

At this point, there are no form fields and no actual connection to Phoenix LiveView. I want to do that by adding a live view hook. I am calling the hook CustomSelect I will add this to the outer div element where I also setup the AlpineJS component.

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
phx-hook="CustomSelect"

The first thing I want to do in the hook is to listen to the select change and I want to dispatch an event as soon as the new option is selected.

I need to change the x-init that I added above to keep track on the selectedIdx and also dispatch an event:

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
x-init="() => { $watch('selectedIdx', val => $dispatch('selected-change', { selectedIdx: val, id: '#<%= @id %>' }) ) }"

In the LiveView hook I will then listen to that event:

Note that I just use console.log here to see if anything is working.

// assets/js/custom_select.js
export const CustomSelect = {
  mounted() {
    this.el.addEventListener("selected-change", event => {
      this.pushEventTo(event.detail.id, "update", event.detail)
    })
  }
}

Note that this.pushEventTo needs the DOM id of the LiveView component in case you have several components in the LiveView. It should only send the event to a specific one.

But before this works, I need to import the hook and connect it the Phoenix LiveView javascript:

// assets/js/app.js
import {CustomSelect} from "./custom_select"

Hooks.CustomSelect = CustomSelect

In the CustomSelectComponent, I can receive the event and at this point dont do anything more than send back a close event. That means that if I selected anything from the list, the list should close.

# lib/tutorial_web/live/custom_select_component.ex

  @impl true
  def handle_event("update", %{"selectedIdx" => idx, "id" => id}, socket) do
    value = Enum.at(socket.assigns.options, idx)

    {
      :noreply,
      socket
      |> push_event("close-selected", %{id: id, value: value})
    }
  end

Inside the LiveView hook and inside the mounted-function I can listen for that event and in turn dispatch a reset event to Alpine so I can close the select.

// assets/js/custom_select.js
this.handleEvent("close-selected", data => {
  const element = document.querySelector(data.id)

  if (!element) return
  if (data.id !== `#${this.el.id}`) return

  element.dispatchEvent(new CustomEvent("reset"))
})

Note that I need to compare the received DOM id the one for the element that the hook is attached to. Otherwise I risk interaction with other components that also have the same LiveView hook attached to them.

AlpineJS way to listen for incoming events is the x-on: and in this case when it receives the reset-event, It will just set open to false and close the select.

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
x-on:reset="open = false"

STEP 5 - Form integration

Now its time to add the actual form field. Note that @f and @name comes passed down in the assigns when the LiveComponent was invoked in the LiveView.

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
<%= hidden_input @f, @name %>

I need to change the update function in CustomSelectComponent to get the initial selected option. It will fallback to the first option in the list if none is selected.

 
# lib/tutorial_web/live/custom_select_component.ex
def update(assigns, socket) do
  %{f: f, name: name, options: options} = assigns

  value = Map.get(f.params, "#{name}") || Map.get(f.data, name)
  selected_option = Enum.find(options, & &1.name == value) || List.first(options)

  {
    :ok,
    socket
    |> assign(assigns)
    |> assign(:selected_option, selected_option)
  }
end

def handle_event("update", %{"selectedIdx" => idx, "id" => id}, socket) do
  selected_option = Enum.at(socket.assigns.options, idx)

  {
    :noreply,
    socket
    |> push_event("close-selected", %{id: id, value: selected_option.name})
    |> assign(:selected_option, selected_option)
  }
end

And also in handle_event update I need to find the selected_option and update the LiveComponent with that.

I will extend the javascript hook to add a functionality for updating the value in the hidden form field with the selected value and also emit an event to Phoenix Liveview that the form field has changed.

// assets/js/custom_select.js
this.el.querySelector('input').value = data.value
this.el.querySelector('input').dispatchEvent(new Event("input", {bubbles: true}))

Since I now have the @selected_option that changes as soon as a new option is picked, I can populate the template with that.

<!-- lib/tutorial_web/live/custom_select_component.html.leex -->
<span class="flex items-center">
  <img src="<%= @selected_option.image %>" class="flex-shrink-0 w-6 h-6 rounded-full">
  <span class="block ml-3 truncate"><%= @selected_option.name %></span>
</span>

With that in place, I should now have everything in place for interacting with the LiveView form and have it wrapped in a reusable LiveComponent. In the demo, the original select field is still there and you can see that its updated when I interact with the custom select. In a real world scenario I would remove it.

Phoenix Boilerplate

Generate a Phoenix Boilerplate and save hours on your next project.

Try now

SAAS Starter Kit

Get started and save time and resources by using the SAAS Starter Kit built with Phoenix and LiveView.

Learn More

Related Tutorials

Published 26 Dec - 2020

Bootstrap 5 and Phoenix LiveView

bootstrapformsmodalliveviewesbuildphoenix-1.6

This tutorial is updated for Phoenix 1.6 with Esbuild Even though a large part of the Phoenix community seem to embrace Tailwind, there are still a..

Published 04 May

How to combine Phoenix LiveView with Alpine.js

alpinejsliveviewphoenixtailwind

No matter how great Phoenix LiveView is, there is still some use case for sprinking some JS in your app to improve UX. For example, tabs, dropdowns,..