This tutorial is a Part 2 follow-up to the Livewire Sidebar Filters for E-Shop Products: Step-by-Step article. We decided to improve that Sidebar Filter component with new features using Alpine.js.
We will add:
At the end of this tutorial, you will find a link to the repository with both the original Livewire component, and these Alpine.js improvements in the form of Pull Request.
So let's dive in!
First, we need to make changes to the Livewire component. We need to add public properties for categories and manufacturers and make them as an array.
app/Http/Livewire/Sidebar.php:
class Sidebar extends Component{ public array $categories = []; public array $manufacturers = []; // ... public function render(PriceService $priceService): View { $prices = $priceService->getPrices( [], $this->selected['categories'], $this->selected['manufacturers'] ); $categories = Category::withCount(['products' => function (Builder $query) { $this->categories = Category::withCount(['products' => function (Builder $query) { $query->withFilters( $this->selected['prices'], [], $this->selected['manufacturers'] ); }]) ->get() ->toArray(); $manufacturers = Manufacturer::withCount(['products' => function (Builder $query) { $this->manufacturers = Manufacturer::withCount(['products' => function (Builder $query) { $query->withFilters( $this->selected['prices'], $this->selected['categories'], [] ); }]) ->get() ->toArray(); return view('livewire.sidebar', compact('prices', 'categories', 'manufacturers')); return view('livewire.sidebar', compact('prices')); }}
This way we will be able to share the state between Livewire and Alpine.js.
Now, let's go to the resources/views/livewire/sidebar.blade.php
and add more features.
First, we will add a collapse feature.
We will add all features only for categories now, and later for other blocks.
Everything in Alpine.js starts with the x-data
directive. So first thing, we need to wrap the block with a div and add x-data
with the reactive data open
which by default will be true.
<div x-data="{ open: true }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> @foreach($categories as $index => $category) <div> <input type="checkbox" id="category{{ $index }}" value="{{ $category->id }}" wire:model="selected.categories"> <label for="category{{ $index }}"> {{ $category['name'] }} ({{ $category['products_count'] }}) </label> </div> @endforeach</div>
Next, the title, in our case Categories, needs to be transformed into a button.
<div x-data="{ open: true }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> @foreach($categories as $index => $category) <div> <input type="checkbox" id="category{{ $index }}" value="{{ $category->id }}" wire:model="selected.categories"> <label for="category{{ $index }}"> {{ $category['name'] }} ({{ $category['products_count'] }}) </label> </div> @endforeach</div>
When the button is pressed, open
will be set to the opposite value. Also, we bind :class
to rotate the arrow.
Now we need to hide the list when the button is pressed. For this we will wrap the @foreach
blade directive into a div
and will show or hide the list using x-show
directive. Also, we will add some transition animation.
<div x-data="{ open: true }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > @foreach($categories as $index => $category) <div> <input type="checkbox" id="category{{ $index }}" value="{{ $category->id }}" wire:model="selected.categories"> <label for="category{{ $index }}"> {{ $category['name'] }} ({{ $category['products_count'] }}) </label> </div> @endforeach </div> </div>
After all this, we have a similar result to the one below. The left part is showing everything, and on the right it's collapsed.
For the prices block, this will be the only feature. So we need to do the same. Wrap in a div with x-data
, adding a button to show/hide, and wrapping foreach into a div with the x-show
.
resources/views/livewire/sidebar.blade.php:
<div x-data="{ open: true }"> <h3 class="mt-2 mb-1 text-2xl"> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Price <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> </h3> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > @foreach($prices as $index => $price) <div> <input type="checkbox" id="price{{ $index }}" value="{{ $index }}" wire:model="selected.prices"> <label for="price{{ $index }}"> {{ $price['name'] }} ({{ $price['products_count'] }}) </label> </div> @endforeach </div> </div>
Now let's add the search functionality.
First, we need an input and it has to be binded into a new data directive called search
using x-model
.
<div x-data="{ open: true, search: '' }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > <input x-model="search" class="mb-2 w-full rounded-md border border-gray-400 px-2 py-1 text-sm" placeholder="Search for categories }}"> @foreach($categories as $index => $category) <div> <input type="checkbox" id="category{{ $index }}" value="{{ $category->id }}" wire:model="selected.categories"> <label for="category{{ $index }}"> {{ $category['name'] }} ({{ $category['products_count'] }}) </label> </div> @endforeach </div></div>
To get the search results, we will use a JavaScript getter. And now, we need to use Livewire @entangle
, so that we would have a list of categories in the Alpine. We will name this data as options
.
The last thing is to use Alpine to show the list of categories instead of Blade @foreach
.
<div x-data="{ open: true, search: '', options: @entangle('categories'), get searchResults() { return this.options.filter( i => i.name.toLowerCase().includes(this.search.toLowerCase()) ) } }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > <input x-model="search" class="mb-2 w-full rounded-md border border-gray-400 px-2 py-1 text-sm" placeholder="Search for categories }}"> @foreach($categories as $index => $category) <div> <template x-for="(option, index) in searchResults" :key="option.id"> <div> <input type="checkbox" :id="'category' + index" :value="option.id" wire:model="selected.categories"> <label :for="'category' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> <input type="checkbox" id="category{{ $index }}" value="{{ $category->id }}" wire:model="selected.categories"> <label for="category{{ $index }}"> {{ $category['name'] }} ({{ $category['products_count'] }}) </label> </div> @endforeach </div></div>
In the getter searchResults
we filter all the options.
In the filter, we first use toLowerCase
to make all the names lowercase.
Finally, we use the includes
function to check if any part of the name corresponds to the search input.
For showing the list of the categories, we just simply use the x-for
directive.
Note from the docs:
x-for
MUST be declared on a<template>
element That<template>
element MUST contain only one root element.
Now the search should work.
But after clicking any category, the products list wouldn't change. To fix this bug we need to add another @entangle
for the selected value and instead of wire:model
then use x-model
.
<div x-data="{ open: true, search: '', options: @entangle('categories'), selected: @entangle('selected.categories'), get searchResults() { return this.options.filter( i => i.name.toLowerCase().includes(this.search.toLowerCase()) ) } }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > <input x-model="search" class="mb-2 w-full rounded-md border border-gray-400 px-2 py-1 text-sm" placeholder="Search for categories }}"> <div> <template x-for="(option, index) in searchResults" :key="option.id"> <div> <input type="checkbox" :id="'category' + index" :value="option.id" x-model="selected"> <input type="checkbox" :id="'category' + index" :value="option.id" wire:model="selected.categories"> <label :for="'category' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> </div> </div></div>
Now search should work as expected.
The next feature will be to show more or less of the results in the list. Let's say we have 50 categories and to show them all would not be logical.
First, we will add new reactive data startIndex
and endIndex
. To get only the part of the categories list, we will again use a getter.
Don't forget that array starts from 0, so the first value will be 0, not 1.
<div x-data="{ open: true, search: '', startIndex: 0, endIndex: 2, options: @entangle('categories'), selected: @entangle('selected.categories'), get searchResults() { return this.options.filter( i => i.name.toLowerCase().includes(this.search.toLowerCase()) ) }, get results() { return this.options.filter((val, index) => { return index >= this.startIndex && index <= this.endIndex }) } }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > <input x-model="search" class="mb-2 w-full rounded-md border border-gray-400 px-2 py-1 text-sm" placeholder="Search for categories }}"> <div> <template x-for="(option, index) in searchResults" :key="option.id"> <div> <input type="checkbox" :id="'category' + index" :value="option.id" x-model="selected"> <input type="checkbox" :id="'category' + index" :value="option.id" wire:model="selected.categories"> <label :for="'category' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> </div> </div></div>
Now we need to show results. For this we need to use x-if
to show results when the search data length is zero and show search results when the search data length is more than zero.
<div x-data="{ open: true, search: '', startIndex: 0, endIndex: 2, options: @entangle('categories'), selected: @entangle('selected.categories'), get searchResults() { return this.options.filter( i => i.name.toLowerCase().includes(this.search.toLowerCase()) ) }, get results() { return this.options.filter((val, index) => { return index >= this.startIndex && index <= this.endIndex }) } }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > <input x-model="search" class="mb-2 w-full rounded-md border border-gray-400 px-2 py-1 text-sm" placeholder="Search for categories }}"> <template x-if="search.length > 0"> <div> <template x-for="(option, index) in searchResults" :key="option.id"> <div> <input type="checkbox" :id="'category' + index" :value="option.id" x-model="selected"> <input type="checkbox" :id="'category' + index" :value="option.id" wire:model="selected.categories"> <label :for="'category' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> <div> </template> <template x-if="search.length === 0"> <div> <template x-for="(option, index) in results" :key="option.id"> <div> <input type="checkbox" :id="'category' + index" :value="option.id" x-model="selected"> <label :for="'category' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> </div> </template> </div></div>
Now, instead of all categories we should see only three because we set startIndex
to 0 and endIndex
to 2.
All that is left is to add Show more
and Show less
actions.
The Show more
action needs to be shown when endIndex
isn't equal to options.length
. When clicked, endIndex
needs to be set to options.length
.
The Show less
actions will be shown when endIndex
is equal to options.length
. When clicked, endIndex
will be set to the default value. In our case it's 2.
<div x-data="{ open: true, search: '', startIndex: 0, endIndex: 2, options: @entangle('categories'), selected: @entangle('selected.categories'), get searchResults() { return this.options.filter( i => i.name.toLowerCase().includes(this.search.toLowerCase()) ) }, get results() { return this.options.filter((val, index) => { return index >= this.startIndex && index <= this.endIndex }) } }"> <h3 class="mt-2 mb-1 text-3xl">Categories</h3> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > <input x-model="search" class="mb-2 w-full rounded-md border border-gray-400 px-2 py-1 text-sm" placeholder="Search for categories }}"> <template x-if="search.length > 0"> <div> <template x-for="(option, index) in searchResults" :key="option.id"> <div> <input type="checkbox" :id="'category' + index" :value="option.id" x-model="selected"> <input type="checkbox" :id="'category' + index" :value="option.id" wire:model="selected.categories"> <label :for="'category' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> </div> </template> <template x-if="search.length === 0"> <div> <template x-for="(option, index) in results" :key="option.id"> <div> <input type="checkbox" :id="'category' + index" :value="option.id" x-model="selected"> <label :for="'category' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> <template x-if="endIndex != options.length"> <div class="mt-1 cursor-pointer font-medium text-indigo-500" @click="endIndex = options.length">Show More</div> </template> <template x-if="endIndex === options.length"> <div class="mt-1 cursor-pointer font-medium text-indigo-500" @click="endIndex = 2">Show Less</div> </template> </div> </template> </div></div>
Now we should have a similar result to below.
To make all these features for one "block", we have a lot of code. What if the sidebar has 10+ filter options, and not just categories and manufacturers? Then we would have a lot of duplicate code. So this is a very good place to create a reusable blade component.
php artisan make:component FilterGroup
In this component, we will need to accept a few data properties using the @props
blade directive.
resources/views/components/filter-group.blade.php:
@props([ 'title', 'options', 'selected', 'startIndex' => 0, 'endIndex' => 2,])
Here we set set the startIndex
and endIndex
default values. For some filters, we might need to show more results than by default or start showing not from the first one. This way we will be able to set it easily.
Next, we can copy everything we had made for the companies filter into this component and change everywhere to use the props where needed.
@props([ 'title', 'options', 'selected', 'startIndex' => 0, 'endIndex' => 2,]) <div x-data="{ open: true, search: '', startIndex: {{ $startIndex }}, endIndex: {{ $endIndex }}, options: @entangle($options), selected: @entangle($selected), startIndex: 0, endIndex: 2, options: @entangle('categories'), selected: @entangle('selected.categories'), get searchResults() { return this.options.filter( i => i.name.toLowerCase().includes(this.search.toLowerCase()) ) }, get results() { return this.options.filter((val, index) => { return index >= this.startIndex && index <= this.endIndex }) } }"> <h3 class="mt-2 mb-1 text-xl"> <button @click="open = !open" class="flex w-full items-center justify-between text-left"> Categories {{ $title }} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="h-6 w-6 transform duration-300" :class="{'rotate-180': open}"> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" /> </svg> </button> </h3> <div x-show="open" x-transition.scale.origin.top x-transition:enter.duration.500ms x-transition:leave.duration.500ms > <input x-model="search" class="mb-2 w-full rounded-md border border-gray-400 px-2 py-1 text-sm" placeholder="Search for {{ strtolower($title) }}"> <template x-if="search.length > 0"> <div> <template x-for="(option, index) in searchResults" :key="option.id"> <div> <input type="checkbox" :id="'option' + index" :value="option.id" x-model="selected"> <label :for="'option' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> </div> </template> <template x-if="search.length === 0"> <div> <template x-for="(option, index) in results" :key="option.id"> <div> <input type="checkbox" :id="'option' + index" :value="option.id" x-model="selected"> <label :for="'option' + index" x-text="option.name + ' (' + option.products_count + ')'"></label> </div> </template> <template x-if="endIndex != options.length"> <div class="mt-1 cursor-pointer font-medium text-indigo-500" @click="endIndex = options.length">Show More</div> </template> <template x-if="endIndex === options.length"> <div class="mt-1 cursor-pointer font-medium text-indigo-500" @click="endIndex = {{ $endIndex }}">Show Less</div> <div class="mt-1 cursor-pointer font-medium text-indigo-500" @click="endIndex = 2">Show Less</div> </template> </div> </template> </div></div>
Now in the sidebar for the categories
and manufactures
we can call this Blade component, with <x-filter-group>
.
<div> // ... <x-filter-group title="Categories" options="categories" selected="selected.categories" /> <x-filter-group title="Manufacturers" options="manufacturers" selected="selected.manufacturers" /></div>
That's it for this tutorial!
The code for this Alpine.js part can be found here in this commit.