Tutorial

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

This post was updated 27 Mar

forms liveview alpinejs

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 the select field by using keyboards and mouse.

To implement this custom select field is not straightforward and I want to go through how to implement 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/my_app/todo.ex
defmodule MyApp.Todo do
@moduledoc """
The Todo context.
"""
alias MyApp.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/my_app/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/my_app_web/live/page_live.ex
defmodule MyAppWeb.PageLive do
use MyAppWeb, :live_view
alias MyApp.Todo
alias MyApp.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/my_app_web/live/page_live.html.heex -->
<section class="prose">
<.form for={@changeset} phx-change="update">
<.label>Assignee</.label>
<select name="task[assignee]" 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">
<%= for user <- @users do %>
<option>{user.name}</option>
<% end %>
</select>
</.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/my_app_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/my_app_web/live/custom_select_component.ex
defmodule MyAppWeb.Live.CustomSelectComponent do
use MyAppWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)}
end
end

And for now just create an empty file: lib/my_app_web/live/custom_select_component.html.heex

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

# lib/my_app_web/live/page_live.ex
alias MyAppWeb.Live.CustomSelectComponent

Render the component in the LiveView page with:

<!-- lib/my_app_web/live/page_live.html.heex -->
<.live_component module={CustomSelectComponent} id="sample-1" f={f} name={:assignee} options={@users} />

With all that in place, it’s time to build 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 that start with x- something or an @ sign are Alpine JS connected attributes. It attaches state or event triggers.

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

<!-- lib/my_app_web/live/custom_select_component.html.heex -->
<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 is 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 of the selected option and the arrow keys position.

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

<!-- lib/my_app_web/live/custom_select_component.html.heex -->
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.logs the idx variable.

Add this as attributes to the button element:

<!-- lib/my_app_web/live/custom_select_component.html.heex -->
@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 some conditional styling dependent on if the list element has the current idx.

<!-- lib/my_app_web/live/custom_select_component.html.heex -->
: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/my_app_web/live/custom_select_component.html.heex -->
@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 LiveView hook. I am calling the hook CustomSelect and I will add this to the outer div element where I also setup the Alpine JS component.

<!-- lib/my_app_web/live/custom_select_component.html.heex -->
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 of the selectedIdx and also dispatch an event.

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 to 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 don’t do anything more than send back a close event. That means that if I selected anything from the list, the list should close.

# lib/my_app_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 to 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.

Alpine JS’s 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/my_app_web/live/custom_select_component.html.heex -->
x-on:reset="open = false"

STEP 5 - Form integration

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

<!-- lib/my_app_web/live/custom_select_component.html.heex -->
<input type="hidden" name={"task[#{@name}]"} value={@selected_option && @selected_option.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/my_app_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/my_app_web/live/custom_select_component.html.heex -->
<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 it’s updated when I interact with the custom select. In a real world scenario I would remove it.

Related Tutorials

Published 04 May - 2021
Updated 27 Mar

How to combine Phoenix LiveView with Alpine.js

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,..

Published 12 Feb - 2022
Updated 27 Mar

CSV Export with Phoenix and LiveView

A common need in web apps is to export data to different file formats. One common, or even maybe the most common format, is exporting CSV files. CSV..