Customize a Laravel Select Component

Image by Annie Ruygt

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 @foreachloop

<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 and DOWNarrows 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-dataattribute 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.

Launching Laravel

Fly.io is a great place to deploy your Laravel app. It's really easy to get started. You can be running in minutes.

Launch your Laravel app today!  

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!