Ajax Live search with Images and Custom HTML in Laravel using Vue.js


I think everybody is familiar with the concept of Ajax Live Search. Every day we are seeing it when we are using Google or YouTube where we get related results in the drop-down menu associated with input search field. Almost all websites you visited will have a similar search functionality including this. Basically, everybody uses 3rd party javascript libraries to create Ajax Live Search. The Jquery UI Autocomplete and Twitter Typeahead are the most popular one. But one problem I usually face with these libraries is they are extremely difficult to customize according to your needs. You need to follow their rules and limitations. That is why I created a very simple custom Ajax Live Search with custom HTML and Images by using only Vue.js and Vanilla Javascript with almost all same functionality that Jquery UI Autocomplete and Twitter Typeahead offers.

I will be using Laravel as backend in this tutorial, But you can use anything as the only thing you really require is well-formatted JSON search result. Besides Vue.js I will also use Axios for making HTTP API requests and Lodash for easily adding extra functionality in javascript. Before you begin I expect you have very basic knowledge of Javascript, Vue.js and Laravel. Very Basic knowledge is enough as I am not going to make things complicated at all. I will be using Vue.js as it is very simple to use and it helps us to reduce the number of lines of coding we required. You can also use other frameworks like React or Angular etc. If you are a React fan I will make this tutorial on React in future too. 

Let's Start Coding

First thing is to include Vue.js, Axios and Lodash in our application. There are two methods to include them.

1) Using Laravel Mix

Laravel Mix provides a fluent API for defining Webpack build steps for your Laravel application using several common CSS and JavaScript pre-processors. All you need to do is install Nodejs and NPM in your development system and run

npm install

to install all javascript dependencies in your application. The jQuery, Bootstrap, Vuejs, Axios and Lodash are already included in this setup.  Now just run

npm run watch 

to compile your assets. Don't forget to include app.css and app.js files in your Laravel View Layouts.

2) Using CDN

You can also include both Vue.js, Axios and Lodash using traditional CDN approach. This method is recommended if you are not going to make Vue Components in Your application and you are just using Vue in one or Two pages for enhancements. In this small demo application, I am also following this approach for simplicity and convenience. Here are the CDN Links for all 3 dependencies.

<script src="//unpkg.com/[email protected]/dist/vue.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.5/lodash.min.js"></script>

 

Now let's makes our required routes in routes/web.php 

Route::get('demos/vuesearch','[email protected]');
Route::post('demos/vuesearch','[email protected]');

 

The first route is for showing the Initial View Page and the second route is used by ajax call for fetching JSON search result from the database.

Now Let's create our DemoController by running the following command.

php artisan make:controller DemoController

The complete code for DemoController is given below.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Post;

use Helper;

class DemoController extends Controller
{
    public function showVueSearch() 
    {
        return view('demos.vuesearch');
    }

    public function getVueSearch(Request $request)
    {
        $search =  $request->search;

        $posts = '';

        if (trim($request->search)) {
            $posts = Post::where('title','LIKE',"%{$search}%")
                         ->orderBy('created_at','DESC')->limit(5)->get();

            $posts = $posts->map(function ($post, $key) {
                            return [
                                        'title' => $post['title'],
                                        'url'  => url('blog/'.$post['slug']),
                                        'image' => Helper::catch_first_image($post['body']),
                                   ];
                        });          
        }

        return $posts;
    }
}

 

The showVueSearch method is used to just show our view page and getVueSearch does the actual database querying and fetching of the result. Although you can directly return $posts after the database querying I am not doing that because in order to fetch post images I need to call an external Helper function. So I am using map method in Laravel to run over the original collection and then to create a new associative array with only fields I needed. After that, I just return that collection as Laravel will automatically convert it to JSON when returning it. If you are wondering about catch_first_image custom helper function and how to create it you can refer the following Link.

Note: Both controller and routes parts will exclusively depend on your projects you are working on. They are unnecessary in this tutorial as only this they do is give some response data. I just added them only for reference purpose. 

Now let's see the Javascript code on the View Page. 

<script type="text/javascript">

    var app = new Vue({
        el: '#app',
        data: {
            posts : '',
            search : '',
            count : 0,
            width: 0,
            menuItem : 'menu-item',
            activeClass : 'active'

        },
        methods: {
            getPosts: _.debounce(function() {
                this.posts = "";
                this.count = 0;
                self = this;

                if (this.search.trim() != '') {
                    axios.post("{{ url('demos/vuesearch') }}",{
                        search : self.search
                    })
                   .then(function (response) {
                      self.posts = response.data;
                    })
                   .catch(function (error) {
                        console.log(error);
                    });  
                }

            }, 500),
            selectPost: function(keyCode) {
                // If down arrow key is pressed
                if (keyCode == 40 && this.count < this.posts.length) {
                    this.count++;
                }
                // If up arrow key is pressed
                if (keyCode == 38 && this.count > 1) {
                    this.count--;
                }
                // If enter key is pressed
                if (keyCode == 13) {
                    // Go to selected post
                    document.getElementById(this.count).childNodes[0].click();
                }
            },
            clearData: function(e) {
                if (e.target.id != 'search') {
                    this.posts = '',
                    this.count = 0
                }
            }
        },
        mounted:function(){
            self = this;

            // get width of search input for vue search widget on initial load
            this.width = document.getElementById("search").offsetWidth; 
            // get width of search input for vue search widget when page resize
            window.addEventListener('resize', function(event){
                self.width = document.getElementById('search').offsetWidth; 
            });

            // To clear vue search widget when click on body
            document.body.addEventListener('click',function (e) {
               self.clearData(e);
            });

            document.getElementById('search').addEventListener('keydown', function(e) {
                // check whether arrow keys are pressed
                if(_.includes([37, 38, 39, 40, 13], e.keyCode) ) {
                    if (e.keyCode === 38 || e.keyCode === 40) {
                        // To prevent cursor from moving left or right in text input
                        e.preventDefault();
                    }

                    if (e.keyCode === 40 && self.posts == "") {
                        // If post list is cleared and search input is not empty 
                        // then call ajax again on down arrow key press 
                        self.getPosts();
                        return;
                    }
                        
                    self.selectPost(e.keyCode);

                } else {
                    self.getPosts();
                }
            });
        },
    });
</script>    

 

As you can see I am registering 3 javascript Event listeners when the app is mounted. The resize Event Listener is registered to make our app listen to every browser window resize event so that we can recalculate the search widget's width thus make our application more responsive. The click Event Listener is registered to make our app listen to every body click event so that we can hide our search popup widget when someone clicks on outside the search input field.

The keydown Event Listener is registered to listen to the input field changes. You can also use watch method to listen to search data changes if you want. Inside this Event Listener, i am checking whether it is a arrows keys and enter key press or not because we will be calling separate methods when they pressed. _.includes is a Lodash exclusive helper method which helps us to easily check whether a required value is in the array.

I am using _.debounce helper method of Lodash to delay the getPosts method call by 500 milliseconds thus prevent unwanted frequent network ajax call to the server on every key press. The getPosts method is used to send ajax post request to the server and to fetch its result. I am using Axios for doing that. Since the result is already in JSON format, The Vue.js will automatically parse it to Javascript Object. 

Note: If you want IE support then use jQuery ajax function instead of Axios as IE doesn't support Javascript Promises.

Now let's see the Vue Search Widget Part in our View Page.

<div class="col-sm-10">
    <input type="text" autocomplete="off" v-model="search" id="search"  
                class="form-control input-lg" placeholder="Enter Blog Title Here" />

    <!-- Vue Search List Start-->
    <ul v-cloak v-if="posts" v-bind:style="{ width : width + 'px' }" class="widget">
          <li v-for="(post,key) in posts" :id="key +1"
              v-bind:class="[(key + 1 == count) ? activeClass : '', menuItem]"
           >
                   <a v-bind:href="post.url" >
                       <div class="list_item_container" v-bind:title="post.title">
                            <div class="image">
                                 <img v-bind:src="post.image" >
                             </div>
                             <div class="label">
                                 <h4>@{{ post.title  }}</h4>
                              </div>
                        </div>
                    </a>
            </li>
     </ul>
     <!-- Vue Search List End-->
</div>

 

The widget only shows if the length of posts is greater than zero ie if there are posts in posts data. Its width is calculated at the time of page loading and page resizing. We loop each search result in a li tag with default class 'menu-item' and a unique id. When we select a list using down arrow or up arrow the active class it embedded in selected li tag. Inside li tag, you can see some custom HTML tags and images. You can customize this part according to your requirements.

Now let's apply some styling

<style>
/* Common for all */
[v-cloak] {
  display: none;
}
.active {
    background-color: #f5f5f5;
}

/* Styling Vue Search Widget - You can customize it as you wish */
.widget {
    border: 1px solid #c5c5c5;
    background: white;
    position: absolute;
    cursor: pointer;
    list-style: none;
    padding: 0;
    z-index: 100;
}
.menu-item{
    height: 80px;
    border: 1px solid #ececf9;
}
.list_item_container {
    width: 98%;
    float: left;
}

.image {
    width: 10%;
    float: left;
    padding: 10px;
}
.image img{
    width: 80px;
    height : 60px;
}
.label{
    width: 89%;
    float:right;
    color: rgb(124,77,255);
    font-weight: bold;
    text-align: left;
    text-overflow: ellipsis;
    overflow: hidden;
    white-space: nowrap;
}
@media only screen and (max-width:768px) {

    .image img{
        width: 70px;
        height : 55px;
    }
    .label{
        width: 70%;
    }
    .label h4{
        font-size: 15px;
    }
}

/* To get a indigo focus color in search input - Not nessary */
input[type="text"]:focus{
  border-color: #5b518b !important; 
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 4px #5b518b !important;
  outline: 0 none;
}

</style>

 

Feel free to customize it according to your taste and application. I just customize it according to my demo app look and feel. One important thing is your widget position should be absolute so that it floats over other elements. 

Now let's see the complete View page codes which I used to build this demo. Remember you will see some fancy class names but don't worry about it as CSS and HTML parts changes according to your requirements. I just used Material design lite and Bootstrap 3 in creating ShareurCodes.

@extends('main')

@section('title','Ajax Live search with Images and Custom HTML in Laravel using Vue.js - ')

@section('stylesheets')

<style>
/* Common for all */
[v-cloak] {
  display: none;
}
.active {
    background-color: #f5f5f5;
}

/* Styling Vue Search Widget - You can customize it as you wish */
.widget {
    border: 1px solid #c5c5c5;
    background: white;
    position: absolute;
    cursor: pointer;
    list-style: none;
    padding: 0;
    z-index: 100;
}
.menu-item{
    height: 80px;
    border: 1px solid #ececf9;
}
.list_item_container {
    width: 98%;
    float: left;
}

.image {
    width: 10%;
    float: left;
    padding: 10px;
}
.image img{
    width: 80px;
    height : 60px;
}
.label{
    width: 89%;
    float:right;
    color: rgb(124,77,255);
    font-weight: bold;
    text-align: left;
    text-overflow: ellipsis;
    overflow: hidden;
    white-space: nowrap;
}
@media only screen and (max-width:768px) {

    .image img{
        width: 70px;
        height : 55px;
    }
    .label{
        width: 70%;
    }
    .label h4{
        font-size: 15px;
    }
}

/* To get a indigo focus color in search input - Not nessary */
input[type="text"]:focus{
  border-color: #5b518b !important; 
  box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 4px #5b518b !important;
  outline: 0 none;
}

</style>

@endsection

@section('content')

<div id="app" > 
    
    <p>&nbsp;</p>
    <h3 class="text-center title-color">
        Ajax Live search with Images and Custom HTML in Laravel using Vue.js
    </h3>
    <p>&nbsp;</p>

    <!-- search box container starts  -->
    <div class="well">
        <div class="row">
            <div class="col-sm-2">
                <!-- This is a addon added to improve this demo app look and feel -->
                <span class="input-group-addon" 
                style="color: white; background-color: #5b518b; height: 46px">
                    BLOG SEARCH
                </span>
            </div>

            <div class="col-sm-10">
                <input type="text" autocomplete="off" v-model="search" id="search"  
                class="form-control input-lg" placeholder="Enter Blog Title Here" />

                <!-- Vue Search List Start-->
                <ul v-cloak v-if="posts" v-bind:style="{ width : width + 'px' }" class="widget">
                    <li v-for="(post,key) in posts" :id="key +1"
                        v-bind:class="[(key + 1 == count) ? activeClass : '', menuItem]"
                    >
                        <a v-bind:href="post.url" >
                            <div class="list_item_container" v-bind:title="post.title">
                                <div class="image">
                                    <img v-bind:src="post.image" >
                                </div>
                                <div class="label">
                                    <h4>@{{ post.title  }}</h4>
                                </div>
                            </div>
                        </a>
                    </li>
                </ul>
                <!-- Vue Search List End-->
            </div>
        </div>
    </div>
    <!-- search box container ends  -->


</div>
    
@stop

@section('scripts')

<script src="//unpkg.com/[email protected]/dist/vue.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.5/lodash.min.js"></script>

<script type="text/javascript">

    var app = new Vue({
        el: '#app',
        data: {
            posts : '',
            search : '',
            count : 0,
            width: 0,
            menuItem : 'menu-item',
            activeClass : 'active'

        },
        methods: {
            getPosts: _.debounce(function() {
                this.posts = "";
                this.count = 0;
                self = this;

                if (this.search.trim() != '') {
                    axios.post("{{ url('demos/vuesearch') }}",{
                        search : self.search
                    })
                   .then(function (response) {
                      self.posts = response.data;
                    })
                   .catch(function (error) {
                        console.log(error);
                    });  
                }

            }, 500),
            selectPost: function(keyCode) {
                // If down arrow key is pressed
                if (keyCode == 40 && this.count < this.posts.length) {
                    this.count++;
                }
                // If up arrow key is pressed
                if (keyCode == 38 && this.count > 1) {
                    this.count--;
                }
                // If enter key is pressed
                if (keyCode == 13) {
                    // Go to selected post
                    document.getElementById(this.count).childNodes[0].click();
                }
            },
            clearData: function(e) {
                if (e.target.id != 'search') {
                    this.posts = '',
                    this.count = 0
                }
            }
        },
        mounted:function(){
            self = this;

            // get width of search input for vue search widget on initial load
            this.width = document.getElementById("search").offsetWidth; 
            // get width of search input for vue search widget when page resize
            window.addEventListener('resize', function(event){
                self.width = document.getElementById('search').offsetWidth; 
            });

            // To clear vue search widget when click on body
            document.body.addEventListener('click',function (e) {
               self.clearData(e);
            });

            document.getElementById('search').addEventListener('keydown', function(e) {
                // check whether arrow keys are pressed
                if(_.includes([37, 38, 39, 40, 13], e.keyCode) ) {
                    if (e.keyCode === 38 || e.keyCode === 40) {
                        // To prevent cursor from moving left or right in text input
                        e.preventDefault();
                    }

                    if (e.keyCode === 40 && self.posts == "") {
                        // If post list is cleared and search input is not empty 
                        // then call ajax again on down arrow key press 
                        self.getPosts();
                        return;
                    }
                        
                    self.selectPost(e.keyCode);

                } else {
                    self.getPosts();
                }
            });
        },
    });
</script>    
    
@stop

 

I used v-cloak directives to hide the uncompiled Vue templates from showing in HTML at time of loading. This directive will remain on the element until the associated Vue instance finishes compilation. Combined with CSS rules such as [v-cloak] { display: none }, this directive can be used to hide un-compiled moustache bindings until the Vue instance is ready. 

The output image of above program is given below.

Ajax Live search with Images and Custom HTML in Laravel using Vue.js

 

The Demo

You can demo the above application by visiting following link.

https://shareurcodes.com/demos/vuesearch

If anybody found this method difficult and time taking then feel free to use jQuery UI Autocomplete and Twitter Typeahead. I already made a simple tutorial about jQuery UI Autocomplete with Images and Custom HTML in Laravel and Twitter Typeahead integration in Laravel for you guys and feel free to check them by visiting following links.

jQuery UI Autocomplete with Images and Custom HTML in Laravel

Twitter Typeahead integration in Laravel

If anybody has any suggestions or doubts or need any help comment below and I try will respond to every one of you as early as possible.


Web development
4th Mar 2018 08:49:31 PM
PHP Laravel Javascript Ajax Vue.js
3572

ShareurCodes

ShareurCodes is a code sharing site for programmers to learn, share their knowledge with younger generation.