search
A basic search component for copper-level searches.
Please enter a search term to get started.
Hey There! 👋
Let me walk you through the SimpleSearch component a simple yet powerful search feature that’s built with Livewire. It’s designed to make your life easier by offering real-time search with highlighted results. If you're looking to build something similar, don't worry! I’ve got your back. Let's dive in step by step.
First, we need to create a Livewire component. Don’t panic it’s just one simple Artisan command. Open up your terminal and run this:
php artisan livewire:make Search/Index
This command will generate two things for you:
App/Livewire/Search/Index
.For now, we’ll focus on the backend logic to make the search work.
<?php namespace Livewire\Search; use App\Models; use Livewire\Component; use Livewire\Attributes\Layout; use Illuminate\Support\Collection; use App\Support\Highlighter; class Index extends Component { const CLASSES = 'text-violet-500 font-semibold'; public string $search = ""; public function getResults() { .... } public function getUrl(string $slug):string { .... } public function baseQuery(): Builder { .... } public function render():View { return view('livewire.search.index', [ 'results' => $this->getResults(), ]); } }
we added a $search
property to bind it with the input in the UI
it consider as the search query.
the baseQuery
function is a just an example of the desired model's table to be searched in this case we use the components table as a use case but be sure to configure it to fit your need.
so whenever the search content change (as the user type in real time) the getResults
will run and send the search results to the UI via the results
variable, so let's explore the getResults
function in the following section:
public function getResults():Collection { $search = trim($this->search); if (empty($search)) { return new Collection(); } $results = $this ->baseQuery() ->select('id', 'name','slug') ->where('name', 'like', '%' . $this->search . '%') ->get() ->map(function ($component) use ($search) { $result = new \stdClass(); $result->title = Highlighter::make( text: $component->name, pattern: $search, classes: self::CLASSES ); $result->url = $this->getUrl($component->slug); return $result; }); return $results; }
first we need to trim the search term to prevent uncessary queries like when the search term is space ... so we use the trim
function for that, then we check if search is empty
if (empty($search)) { return new Collection(); }
to return an empty collection and stop going further. if the search is not empty we need to complete our way to querying the database.
The $classes
A CSS class is defined for highlighting matched search patterns
$results = $this ->baseQuery() ->select(['id', 'name', 'slug']) ->where('name', 'like', "%{$this->search}%") ->get() ->map(fn($component) => (object) [ 'title' => Highlighter::make( text: $component->name, pattern: $search, classes: self::CLASSES ), 'url' => $this->getUrl($component->slug), ]);
The query selects the id
, name
, and slug
columns and filters them based on the search term. Each result is highlighted and mapped into an object with a title and URL.
public function getUrl(string $slug): string { return route('components.show', $slug); }
The getUrl function generates a link for each search result.
As I said eirlier this is just an example query.
now let's deep dive into the highliter class wich's the core of highliting queries
<?php declare(strict_types=1); namespace App\Support; final class Highlighter { public static function make(?string $text, ?string $pattern, ?string $styles = '', ?string $classes = ''): string { if (blank($pattern)) { return $text; } $highlightedPattern = '<span'; if (! empty($classes)) { $highlightedPattern .= ' class="'.$classes.'"'; } if (! empty($styles)) { $highlightedPattern .= ' style="'.$styles.'"'; } $highlightedPattern .= '>$0</span>'; return preg_replace('/('.preg_quote($pattern, '/').')/i', $highlightedPattern, $text); } }
the class has make()
static method that accept subject $text
the $pattern
wich is the query term it also accet two optional (but important ) styles
and classes
parameters
First, the method checks if the pattern is empty and returns the original text early if so. Then it constructs the highlighted HTML using a html <span class="..." style="...">$0</span>
structure, where $0
is a regex placeholder for matched text.
Finally, preg_replace()
searches the subject for occurrences of the query term and replaces them with the constructed highlighted pattern.
The preg_quote()
function is used to escape special characters in the pattern to ensure safe regex execution.
This regex-based highlighting approach provides flexibility and simplicity compared to algorithmic alternatives.
now we ensure that the returen query is highlited withing a span let's focus now on the fron-end
while the design is build in different mind that the mind that read this docs, it ensure to build something beautiful, fully accessible, easy to customize.. so let's start the front end journey
First we have the entry point. The index.blade.php
livewire component
<x-modal openEvent="open-global-search" closeEvent="close-global-search" > <x-slot:trigger> .... act as the trigger of the modal </x-slot:trigger> <x-slot:header class="border-b border-gray-300 dark:border-gray-800 px-2"> .... act as search input </x-slot:header> .... act as results wrapper <x-slot:footer> <x-search.footer/> </x-slot:footer> </x-modal>
I styled a nice button look as a button to open our search modal
<x-slot:trigger> <div class="flex items-center w-60 justify-center" > <div class="pointer-events-auto relative bg-white dark:bg-black rounded-lg"> <button class="hidden w-full items-center rounded-lg py-1.5 pl-2 pr-3 text-sm leading-6 text-slate-400 shadow-sm ring-2 ring-purple-500/15 hover:ring-purple-500 transition-all duration-300 dark:hover:bg-[#02031C] lg:flex" type="button" > <x-icon.search size="5" class="mr-3" stroke="3" />Quick search... </button> </div> </div> </x-slot:trigger>
don't forget to copy the icons component from the files tab.
Alright, since now we have the trigger setup correctly let's build the search itself.
we use the header slot and put inside it the form input and bind the $search
with backend
<x-slot:header class="border-b border-gray-300 dark:border-gray-800 px-2"> <form class="relative flex w-full items-center px-1 py-0.5" > <label class="flex items-center justify-center text-gray-400 dark:text-gray-600" id="search-label" for="search-input" > <x-icon.search wire:loading.class="hidden" size="5" stroke="3" /> <div class="hidden" wire:loading.class.remove="hidden"> <x-icon.loading-indicator /> </div> </label> <x-search.input/> <!-- has the binding for the $search variable --> </form> </x-slot:header>
don't forget to copy the icons component from the files tab.
this is getting injected to {{ $slot }}
portion
<div class="py-2"> @unless(empty($search)) <x-search.results :results="$results"/> @else <div class="w-full global-search-modal"> <p class="text-gray-700 p-4 dark:text-gray-200 w-full text-center">Please enter a search term to get started. </p> </div> @endunless </div>
First we check if the search query is empty to render the fallback content to remember the user of starting new search session, if not we need to start handling search results inside the resources/views/components/search/results.blade.php
below :
<div {{ $attributes->class([ 'flex-1 z-10 w-full mt-1 overflow-y-auto h-full bg-white transition dark:bg-transparent ', '[transform:translateZ(0)]', // prevent safari ui bugs ]) }} > @if ($results->isEmpty()) <x-search.no-results/> @else <ul id="search-list" x-data="{ handleKeyUp(){ $focus.getFirst() === $focus.focused() ? document.getElementById('search-input').focus() : $focus.previous(); }, }" x-on:focus-first-element.window="$focus.first()" x-on:keydown.up.stop.prevent="handleKeyUp()" x-on:keydown.down.stop.prevent="$focus.wrap().next()" x-animate > @foreach ($results as $index => $result) <x-search.search-item :title="$result->title" :url="$result->url" :index="$index" /> @endforeach </ul> @endif </div>
livewire re-render results each time we change the $search
value (as user type ...) so we need to check if the searched query came up with some results from the database if not we render the resources/views/components/search/no-results.blade.php
instead.
we use the focus
plugin provided by alpine to manage accessibity the handleKeyUp()
function is used to check when we are in the first search item, if so we go back to our source of truth the input
the x-animate
provided by the alpine animation for making the results changes smoothly alpine animation I am the author.
if there is results we loop over them and display them using the resources/views/components/search/search-item.blade.php
views wich is simple as:
@props([ 'title', 'url', ]) <li role="option" > <a href="{{ $url }}" wire:navigate @class([ 'block scroll-mt-9 mx-1 my-1 dark:bg-white/5 group bg-gray-50 py-6 px-3 duration-300 transition-colors rounded-lg focus:bg-gray-100 dark:focus:bg-white/10 focus:border focus-visible:outline-none focus:border-gray-400 dark:focus:border-white/30 hover:bg-gray-100 dark:hover:bg-white/10 flex justify-between items-center', 'p-3', ]) > <h4 @class([ 'text-md text-start font-medium text-gray-950 dark:text-white', ]) > {{ $title }} </h4> </a> </li>
that's it this our very simple search functionality, if you want to add recent search, favorite them, group search results, suggest something try consulting:
1<?php
2
3declare(strict_types=1);
4
5namespace App\Livewire\Search;
6
7use stdClass;
8use App\Models;
9use Livewire\Component;
10use Livewire\Attributes\Layout;
11use Illuminate\Support\Collection;
12use App\Support\Highlighter;
13use Illuminate\Database\Eloquent\Builder;
14
15final class SimpleSearch extends Component
16{
17 const CLASSES = 'text-violet-500 font-semibold';
18 public string $search = '';
19
20 public function getResults(): Collection
21 {
22 $search = trim($this->search);
23
24 if (empty($search)) {
25 return new Collection();
26 }
27
28
29 $results = $this
30 ->baseQuery()
31 ->select('id', 'name', 'slug')
32 ->where('name', 'like', '%' . $search . '%')
33 ->get()
34 ->map(function ($component) use ($search) {
35 $result = new stdClass();
36 $result->title = Highlighter::make(
37 text: $component->name,
38 pattern: $search,
39 classes: self::CLASSES
40 );
41 $result->url = $this->getUrl($component->slug);
42
43 return $result;
44 });
45
46 return $results;
47 }
48
49 public function getUrl($slug): string
50 {
51 return route('components.show', $slug);
52 }
53
54 public function baseQuery(): Builder
55 {
56 return Models\Component::query();
57 }
58
59 public function render()
60 {
61 return view('livewire.search.index', [
62 'results' => $this->getResults(),
63 ]);
64 }
65}
1<x-modal>
2 <x-slot:trigger>
3 <div
4 class="flex items-center w-60 justify-center"
5 >
6 <div class="pointer-events-auto relative bg-white dark:bg-black rounded-lg">
7 <button
8 class="hidden w-full items-center rounded-lg py-1.5 pl-2 pr-3 text-sm leading-6 text-slate-400 shadow-sm ring-2 ring-purple-500/15 hover:ring-purple-500 transition-all duration-300 dark:hover:bg-[#02031C] lg:flex"
9 type="button"
10 >
11 <x-icon.search
12 size="5"
13 class="mr-3"
14 stroke="3"
15 />Quick search...<span class="ml-auto flex-none pl-3 text-xs font-semibold">Ctrl K</span>
16 </button>
17 </div>
18 </div>
19 </x-slot>
20 <x-slot:header class="border-b border-gray-300 dark:border-gray-800 px-2">
21 <form
22 class="relative flex w-full items-center px-1 py-0.5"
23 >
24 <label
25 class="flex items-center justify-center text-gray-400 dark:text-gray-600"
26 id="search-label"
27 for="search-input"
28 >
29 <x-icon.search
30 wire:loading.class="hidden"
31 size="5"
32 stroke="3"
33 />
34 <div class="hidden" wire:loading.class.remove="hidden">
35 <x-icon.loading-indicator />
36 </div>
37 </label>
38 <x-search.input/>
39 </form>
40 </x-slot:header>
41 <div class="py-2">
42 @unless(empty($search))
43 <x-search.results :results="$results"/>
44 @else
45 <div class="w-full global-search-modal">
46 <p class="text-gray-700 p-4 dark:text-gray-200 w-full text-center">Please enter a search term to get started.
47 </p>
48 </div>
49 @endunless
50 </div>
51 <x-slot:footer>
52 <x-search.footer/>
53 </x-slot:footer>
54</x-modal>
1<?php
2
3declare(strict_types=1);
4
5namespace App\Support;
6
7final class Highlighter
8{
9 public static function make(?string $text, ?string $pattern, ?string $styles = '', ?string $classes = '')
10 {
11
12 if (blank($pattern)) {
13 return $text;
14 }
15
16 $highlightedPattern = '<span';
17
18 if (! empty($classes)) {
19
20 $highlightedPattern .= ' class="'.$classes.'"';
21 }
22
23 if (! empty($styles)) {
24
25 $highlightedPattern .= ' style="'.$styles.'"';
26 }
27
28 $highlightedPattern .= '>$0</span>';
29
30 return preg_replace('/('.preg_quote($pattern, '/').')/i', $highlightedPattern, $text);
31 }
32}
1<input
2 id="search-input"
3 type="search"
4 aria-autocomplete="both"
5
6 aria-controls="search-list"
7 style="border:none; outline:none"
8 wire:model.live.debounce.200ms="search"
9 autocomplete="off"
10 autocorrect="off"
11 x-data="{}"
12 x-on:keydown.down.prevent.stop="$dispatch('focus-first-element')"
13 autocapitalize="none"
14 enterkeyhint="go"
15 spellcheck="false"
16 placeholder="Search for anything ..."
17 x-on:keydown.enter.prevent
18 autofocus="true"
19 maxlength="64"
20 @class([
21 'fi-input block w-full border-none bg-transparent py-1.5 text-base text-gray-950 dark:text-white font-semibold placeholder:font-normal transition duration-75 placeholder:text-gray-400 focus:ring-0 disabled:text-gray-500 sm:text-sm sm:leading-6',
22 ])
23/>
1<div
2 {{
3 $attributes->class([
4 'flex-1 z-10 w-full mt-1 overflow-y-auto h-full bg-white transition dark:bg-transparent ',
5 '[transform:translateZ(0)]',
6 ])
7 }}
8>
9 @if ($results->isEmpty())
10 <x-components::search.no-results/>
11 @else
12 <ul
13 id="search-list"
14 x-data="{
15 handleKeyUp(){
16 $focus.getFirst() === $focus.focused() ? document.getElementById('search-input').focus() : $focus.previous();
17 },
18 }"
19 x-on:focus-first-element.window="$focus.first()"
20 x-on:keydown.up.stop.prevent="handleKeyUp()"
21 x-on:keydown.down.stop.prevent="$focus.wrap().next()"
22 x-animate
23 >
24 @foreach ($results as $index => $result )
25 <x-components::search.search-item
26 :title="$result->title"
27 :url="$result->url"
28 :index="$index"
29 />
30 @endforeach
31 </ul>
32 @endif
33</div>
1<p {{ $attributes->class([' px-4 py-12 w-full text-center rounded-lg text-sm text-gray-500 dark:text-gray-400 bg-white/5']) }}>
2 No Results Found
3</p>
1@props([
2 'title',
3 'url',
4])
5
6<li
7 role="option"
8>
9 <a
10 href="{{ $url }}"
11 wire:navigate
12 @class([
13 'block scroll-mt-9 mx-1 my-1 dark:bg-white/5 group bg-gray-50 py-6 px-3 duration-300 transition-colors rounded-lg focus:bg-gray-100 dark:focus:bg-white/10 focus:border focus-visible:outline-none focus:border-gray-400 dark:focus:border-white/30 hover:bg-gray-100 dark:hover:bg-white/10 flex justify-between items-center',
14 'p-3',
15 ])
16 >
17 <h4
18 @class([
19 'text-md text-start font-medium text-gray-950 dark:text-white',
20 ])
21 >
22 {{ $title }}
23 </h4>
24 </a>
25</li>
1<ul class="m-0 mr-auto flex list-none p-0 text-slate-500 dark:text-gray-400">
2 <li class="items-center flex ltr:mr-1 rtl:pl-2 gap-1 ml-2 ">
3 <span class="rounded flex shadow-sm ring-1 transition duration-75 ring-gray-950/10 dark:ring-white/20 ">
4 <kbd class="flex items-center justify-center p-1 text-gray-700 dark:text-gray-400">
5 <svg class="dark:text-gray-700 text-gray-400" role="img" aria-label="Enter key" width="15" height="15">
6 <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
7 stroke-width="1.2">
8 <path d="M12 3.53088v3c0 1-1 2-2 2H4M7 11.53088l-3-3 3-3">
9 </path>
10 </g>
11 </svg>
12 </kbd>
13 </span>
14 <span class="helper-Label" >to select</span>
15 </li>
16 <li class="items-center mr-1 flex ltr:mr-1 rtl:pl-2 gap-1 ml-2 ">
17 <span class="rounded flex shadow-sm ring-1 transition duration-75 ring-gray-950/10 dark:ring-white/20 ">
18 <kbd class="flex items-center justify-center p-1 text-gray-700 dark:text-gray-400">
19 <svg class="dark:text-gray-700 text-gray-400" role="img" aria-label="Arrow down" width="15" height="15">
20 <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
21 stroke-width="1.2">
22 <path d="M7.5 3.5v8M10.5 8.5l-3 3-3-3">
23 </path>
24 </g>
25 </svg>
26 </kbd>
27 </span>
28 <span class="rounded flex shadow-sm ring-1 transition duration-75 ring-gray-950/10 dark:ring-white/20 ">
29 <kbd class="flex items-center justify-center p-1 text-gray-700 dark:text-gray-400">
30 <svg class="dark:text-gray-700 text-gray-400" role="img" aria-label="Arrow up" width="15" height="15">
31 <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
32 stroke-width="1.2">
33 <path d="M7.5 11.5v-8M10.5 6.5l-3-3-3 3">
34 </path>
35 </g>
36 </svg>
37 </kbd>
38 </span>
39 <span class="helper-Label" >to navigate</span>
40 </li>
41 <li class="items-center flex ltr:mr-1 rtl:pl-2 gap-1 ml-2 ">
42 <span class="rounded flex shadow-sm ring-1 transition duration-75 ring-gray-950/10 dark:ring-white/20 ">
43 <kbd class="flex items-center justify-center p-1 text-gray-700 dark:text-gray-400">
44 <svg class="dark:text-gray-700 text-gray-400" role="img" aria-label="Escape key" width="15" height="15">
45 <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"
46 stroke-width="1.2">
47 <path
48 d="M13.6167 8.936c-.1065.3583-.6883.962-1.4875.962-.7993 0-1.653-.9165-1.653-2.1258v-.5678c0-1.2548.7896-2.1016 1.653-2.1016.8634 0 1.3601.4778 1.4875 1.0724M9 6c-.1352-.4735-.7506-.9219-1.46-.8972-.7092.0246-1.344.57-1.344 1.2166s.4198.8812 1.3445.9805C8.465 7.3992 8.968 7.9337 9 8.5c.032.5663-.454 1.398-1.4595 1.398C6.6593 9.898 6 9 5.963 8.4851m-1.4748.5368c-.2635.5941-.8099.876-1.5443.876s-1.7073-.6248-1.7073-2.204v-.4603c0-1.0416.721-2.131 1.7073-2.131.9864 0 1.6425 1.031 1.5443 2.2492h-2.956">
49 </path>
50 </g>
51 </svg>
52 </kbd>
53 </span>
54 <span class="helper-Label" >to close</span>
55 </li>
56</ul>
1@props([
2 'size'=> 5,
3 'stroke'=>2.5
4])
5<svg
6
7 {{ $attributes->merge(['class'=>"stroke-{$stroke} size-{$size}"]) }}
8 width="20" height="20" viewBox="0 0 20 20">
9 <path
10 d="M14.386 14.386l4.0877 4.0877-4.0877-4.0877c-2.9418 2.9419-7.7115 2.9419-10.6533 0-2.9419-2.9418-2.9419-7.7115 0-10.6533 2.9418-2.9419 7.7115-2.9419 10.6533 0 2.9419 2.9418 2.9419 7.7115 0 10.6533z"
11 stroke="currentColor" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"></path>
12</svg>
1<svg class="size-[18px] overflow-visible " viewBox="0 0 38 38" stroke="currentColor" stroke-opacity=".8">
2 <g fill="none" fill-rule="evenodd">
3 <g transform="translate(1 1)" stroke-width="3">
4 <circle stroke-opacity=".6" cx="18" cy="18" r="18"></circle>
5 <path d="M36 18c0-9.94-8.06-18-18-18">
6 <animateTransform type="rotate" attributeName="transform" from="0 18 18" to="360 18 18" dur="1s"
7 repeatCount="indefinite"></animateTransform>
8 </path>
9 </g>
10 </g>
11</svg>