Tutorial
How to create a custom select with Alpine JS and Phoenix LiveView
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(<i>params, </i>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<i>event("update", %{"task" => task</i>params}, socket) do
changeset =
%Task{}
|> Todo.change<i>task(task</i>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/tutorialweb/live/customselect_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:
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 !== <code class="inline-code">#${this.el.id}</code>) 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<i>option, selected</i>option)
}
enddef 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 handleevent update I need to find the selectedoption 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.