This is a post about creating a custom select component for Laravel Livewire applications. If you have a Laravel app you want deployed closer to your users then get started now. You could be up and running in mere minutes.
When it comes to form elements, we might immediately reach for an open-source or paid library. Pre-built components speed up development and using well-tested, robust libraries take a lot of pressure off our shoulders.
But what about when we need something custom? Customizing third-party packages is often harder than making the component ourselves. Also, learning how to make a reusable component improves our general understanding of Livewire.
Today, we will make a custom select component using Livewire and Tailwind. Then we will go further and consider ways of making it accessible using Alpine.js. We will build it fully custom without using an HTML <select>
tag, this gives us a lot of freedom in appearance and UX.
Let’s roll
To keep it simple, we assume you already created a new Laravel project, installed Livewire using composer, and installed Tailwind using npm.
Generate the Livewire component using php artisan make:livewire select
.
It creates two files:
- Component’s class:
app/Http/Livewire/Select.php
- Component’s view:
resources/views/livewire/select.blade.php
Then, edit our welcome.blade.php
file and include the select component.
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Livewire Select</title>
<link href="{{ mix('/css/app.css') }}" rel="stylesheet">
@livewireStyles
</head>
<body class="flex items-center justify-center min-h-screen">
<div class="w-56">
<livewire:select/>
</div>
@livewireScripts
</body>
</html>
Making the layout
Head to the select.blade.php
file. The component has 3 parts, the label of the select, the selected item / placeholder and an absolute positioned list with all the selectable options.
<div>
<label>
Label
</label>
<div class="relative">
<button>
Selected item
</button>
<ul class="absolute z-10">
<li>
Option 1
</li>
<li>
Option 2
</li>
</ul>
</div>
</div>
We can make it pop with a few Tailwind classes.
<div>
<label class="text-gray-500">
Label
</label>
<div class="relative">
<button class="w-full flex items-center h-12 bg-white border rounded-lg px-2">
Selected item
</button>
<ul class="bg-white absolute mt-1 z-10 border rounded-lg w-full">
<li class="px-3 py-2">
Option 1
</li>
<li class="px-3 py-2">
Option 2
</li>
</ul>
</div>
</div>
After dressing it up, it looks like this:
Item rendering and toggle
Now let’s implement the rendering and the opening/closing of the options.
Create some property in the Select.php
file and a toggle()
function
class Select2 extends component
{
public $items;
public $selected = null;
public $label;
public $open = false;
public function toggle()
{
$this->open = !$this->open;
}
...
}
To make the component reusable we pass these from the outside, currently in the welcome.blade.php
<livewire:select
:selected="1"
:items="['Apple','Banana','Strawberry']"
label="Favorite fruit"
/>
Replace a few parts in the select.blade.php
to render dynamically from the given props, and also add a click listener to the <button>
to add the opening / closing functionality.
<div>
<label class="text-gray-500">
{{ $label }}
</label>
<div class="relative">
<button
wire:click="toggle"
class="w-full flex items-center h-12 bg-white border rounded-lg px-2"
>
@if ($selected !== null)
{{ $items[$selected] }}
@else
Choose...
@endif
</button>
@if ($open)
<ul class="bg-white absolute mt-1 z-10 border rounded-lg w-full">
@foreach($items as $item)
<li class="px-3 py-2 cursor-pointer">
{{ $item }}
</li>
@endforeach
</ul>
@endif
</div>
</div>
With those few edits, our items are rendered and we can open and close the options list.
Making the selection
Let’s create a select($index)
function in the Select.php
class. This sets the selected item to the given index, and also handles deselection if the given index is the currently selected item.
public function select($index) {
$this->selected = $this->selected !== $index ? $index : null;
$this->open = false;
}
Add the click event to the <li>
and also a few conditional classes to highlight the selected option. We use the @class
blade directive and the $loop
variable, which is provided by the @foreach
loop
<li wire:click="select({{ $loop->index }})"
@class([
'px-3 py-2 cursor-pointer',
'bg-blue-500 text-white' => $selected === $loop->index,
'hover:bg-blue-400 hover:text-white',
])
>
{{ $item }}
</li>
Now we have a working select component! 🚀
Making it prettier
Before we dive into the Alpine.js part, let’s make some UI improvements. Since we are building this custom, we can make it display how we want for our application. We want an open/close indicator, and it would be cool to include a check icon on the currently selected item. For simplicity, we will use Heroicons and copy-paste SVG-s.
You can find the full final markup of select.blade.php
here:
Making it accessible using Alpine.js
Accessibility is an important part of web development, not just when we think about people with disabilities but making things accessible gives a better UX for everyone.
Our requirements:
- Pressing
TAB
we can focus on our component - Pressing
SPACE
opens / closes the component - Pressing
UP
andDOWN
arrows navigate between the items - Pressing
ENTER
selects the currently highlighted element
All of the above can be implemented by Livewire, but it would make a lot of requests to the backend. Since these things are UI state only, it makes sense to implement them in the browser using Alpine.js, a popular and lightweight Javascript framework.
First, we need to include Alpine’s JS in our welcome.blade.php
<head>
...
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
...
</head>
Then, add an x-data
attribute to our parent div
<div x-data="{}">
...
</div>
To highlight an item even when the mouse is not hovering, we must track which element is currently highlighted. Also, we have to calculate the next and the previous item, and for that, we will need to pass the count of the items from PHP.
<div x-data="{
highlighted: 0,
count: {{ count($items) }},
}">
We also need three functions next
, previous
and select
. We will use the magical modulo operator for the next/previous calculation, which returns the remainder after the division. E.g.: 3 % 2 = 1
, 8 % 3 = 2
To select the currently highlighted item we will use this.$wire
variable’s call()
method.
<div x-data="{
highlighted: 0,
count: {{ count($items) }},
next() {
this.highlighted = (this.highlighted + 1) % this.count;
},
previous() {
this.highlighted = (this.highlighted + this.count - 1) % this.count;
},
select() {
this.$wire.call('select', this.highlighted)
}
}">
Now that we have all the needed data and functions, let’s hook these into our layout.
Add Alpine.js event-listeners to our <button>
<button
wire:click="toggle"
class="w-full flex items-center justify-between h-12 bg-white border rounded-lg px-2"
@keydown.arrow-down="next()"
@keydown.arrow-up="previous()"
@keydown.enter.prevent="select()"
>
Now, to highlight the proper item, we add an x-data
to our <li>
with the current $index
and add some classes using Alpine if the $index
matches the highlighted
variable.
<li wire:click="select({{ $loop->index }})"
x-data="{ index: {{ $loop->index }} }"
class="px-3 py-2 cursor-pointer flex items-center justify-between"
:class="{'bg-blue-400 text-white': index === highlighted}"
@mouseover="highlighted = index"
>
...
</li>
Final touches
We are almost done here, just a few hiccups left.
When we open the select for the first time, it should highlight the currently selected item. Let’s give this information to Alpine using the x-init
attribute.
<div x-data="{...}"
x-init="highlighted = {{ $selected ?: 0 }}"
>
We can handle “click outside” by adding a close()
function to Alpine, and @click.outside
listener to the parent div
to close the popup when the user clicks somewhere else.
<div x-data="{
...
close() {
if (this.$wire.open) {
this.$wire.open = false;
}
}
}"
...
@click.outside="close()"
>
When the select isn’t open and we hit ENTER
it deselects the current item, let’s fix this in Select.php
public function select($index) {
if (!$this->open) {
return;
}
$this->selected = $this->selected !== $index ? $index : null;
$this->open = false;
}
When the highlighted and selected item isn’t the same, we need to change the color of the check icon to blue; otherwise it can’t be seen on the white background. Let’s fix this with some dynamic classes.
@if ($selected === $loop->index)
<div :class="index === highlighted ? 'text-white' : 'text-blue-500'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
@endif
That’s it, we are done! 🚀 👏
As you can see, by dropping multiple <livewire:select />
components into our view each piece does its job.
Closing Thoughts
Sometimes, we don’t appreciate how many things are going on under the hood when using third-party component packages. When we do it ourselves, in addition to learning what’s involved, we also realize it’s something we can do ourselves. So when we need something custom, we won’t be afraid of it.
Another take-away is: If you want Livewire components to be reusable, you should pass every dependency from the outside into them and not use global events and listeners.
We can use Livewire for lots of things, but we should be on the lookout for situations where using Javascript makes more sense. Alpine.js and Livewire work great together, and when we combine them, we can solve lots of problems for our customers and users.
Final Component
Here’s the final version of our component with the Alpine.js improvements included.
<div x-data="{
highlighted: 0,
count: {{ count($items) }},
next() {
console.log(this.highlighted);
this.highlighted = (this.highlighted + 1) % this.count;
},
previous() {
this.highlighted = (this.highlighted + this.count - 1) % this.count;
},
select() {
this.$wire.call('select', this.highlighted)
},
close() {
if (this.$wire.open) {
this.$wire.open = false;
}
}
}"
x-init="highlighted = {{ $selected ?: 0 }}"
@click.outside="close()"
>
<label class="text-gray-500">
{{ $label }}
</label>
<div class="relative">
<button
wire:click="toggle"
class="w-full flex items-center justify-between h-12 bg-white border rounded-lg px-2"
@keydown.arrow-down="next()"
@keydown.arrow-up="previous()"
@keydown.enter.prevent="select()"
>
@if ($selected !== null)
{{ $items[$selected] }}
@else
Choose...
@endif
<div class="text-gray-400">
@if ($open)
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clip-rule="evenodd"/>
</svg>
@else
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"/>
</svg>
@endif
</div>
</button>
@if ($open)
<ul class="bg-white absolute mt-1 z-10 border rounded-lg w-full">
@foreach($items as $item)
<li wire:click="select({{ $loop->index }})"
x-data="{ index: {{ $loop->index }} }"
class="px-3 py-2 cursor-pointer flex items-center justify-between"
:class="{'bg-blue-400 text-white': index === highlighted}"
@mouseover="highlighted = index"
>
{{ $item }}
@if ($selected === $loop->index)
<div :class="true ? 'text-white' : 'text-blue-500'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
fill="currentColor">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"/>
</svg>
</div>
@endif
</li>
@endforeach
</ul>
@endif
</div>
</div>
Happy hacking!