E-Shop Sidebar Filter: Alpine.js for Search and Show/Hide

E-Shop Sidebar Filter: Alpine.js for Search and Show/Hide
Admin
Tuesday, May 9, 2023 5 mins to read
Share
E-Shop Sidebar Filter: Alpine.js for Search and Show/Hide

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:

  • Collapsable filter options
  • Search for every filter
  • Show more/less for the filter options list

finished improved sidebar

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!


Livewire Component Changes

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.


Collapse

First, we will add a collapse feature.

opened and closed filter block

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.

opened and closed filter block

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>

Search

Now let's add the search functionality.

working search

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.

working search


Show More/Less

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.

show more and less actions

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.

less categories

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.

show more and less actions


Extract into Reusable Blade Component

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.