Using Twitter Typeahead with Vuejs

Learn how to integrate twitter typeahead search powered by Bloodhound directly into your Vuejs app. Using Twitter typeahead with Vuejs may seem tricky at first, but its far from the truth. Moreover, it is very well suited for traditional web apps as well as SPAs.

If you are new to twitter Typeahead/Bloodhound, here’s a tutorial –  Smart Search with Twitter Typeahead that I recommend before going ahead.

twitter typeahead vuejs

Requirements:

VueJS (v2.5 and above)
VueRouter (v3.0+)
Typeahead (v1.2+)

We will integrate twitter typeahead with 3 simple steps:

1. Installation:

Include twitter typeahead dependency in your package.json

// package.json
{
"devDependencies": {
    "@symfony/webpack-encore": "^0.20.1",
    "babel-preset-stage-3": "^6.24.1",
    "jquery": "^3.3.1",
    "vue": "^2.5.17",
    "vue-loader": "^15.4.2",
    "vue-template-compiler": "^2.5.17"
  },
"scripts": {
    "dev-server": "encore dev-server",
    "dev": "encore dev",
    "watch": "encore dev --watch",
    "production": "encore production"
  },
"dependencies": {
    "axios": "^0.18.0",
    "corejs-typeahead": "^1.2.1",
    "vue-router": "^3.0.2",
    "vuex": "^3.0.1"
  }
}

Then you can run npm install to install the needed dependencies.

For this basic example, we’ll create a simple Vue component and then configure twitter-typeahead in it.

2. Create, configure Search Component:

Lets create a Vue component called search.vue and update the markup.

Html markup –

The only element essential for initializing twitter-typeahead is the input element. You can either use just a single input element, or add some markup for labels, search icons, etc as shown below.

// search.vue
<template>
    <div class="globalSearchInput">
        <div class="search-input-container">
            <input type="text"
                   placeholder="Search..."
                   v-model.trim="q"
                   @keyup.enter="gotoSearch"
            >
        </div>
        <div class="search-icon-container">
            <i class="material-icons" @click="gotoSearch">search</i>
        </div>
    </div>
</template>

Here there are 2 things to note,
– We use the attribute v-model.trim = "q" for storing the search query entered by the user. trim ensures that there are no leading/trailing spaces.
– We use @keyup.enter="gotoSearch" and @click="gotoSearch" to attach event handlers so that the function gotoSearch() will be called each time the user hits the enter key or clicks on the search icon. We will be defining this gotoSearch() function shortly. But before that, lets initialize Typeahead.

Initialize Typeahead, Bloodhound –

We will update the script part of our search.vue component. We start by importing the required libraries. Notice that I have commented out jQuery since I have exposed it globally in my application, however if you haven’t done the same, you must import it because Typeahead depends on jQuery.

// search.vue
<script>
// import JQuery from 'jquery';
import Bloodhound from 'corejs-typeahead/dist/bloodhound';
import typeahead from 'corejs-typeahead/dist/typeahead.jquery';

export default {
  name: 'search',
  data () {
    return {
      q: '',
      suggestions: null
    }
  },
  methods: {},
  mounted() {}
}
</script>

The component for now is just a bare skeleton and does nothing really. We have declared 2 properties in the data() method. q will hold the user’s search query whereas suggestions will hold the suggestions.

We will initiate Typeahead and Bloodhound in the mounted() hook of Vue.js since this is where the DOM is completely built.
Let’s update the mounted hook as follows:

// search.vue
mounted() {
      // configure datasource for the suggestions (i.e. Bloodhound)
      this.suggestions = new Bloodhound({
          datumTokenizer: Bloodhound.tokenizers.obj.whitespace('title'),
          queryTokenizer: Bloodhound.tokenizers.whitespace,
          identify: function(item) {
              return item.id;
          },
          remote: {
              url: http://domain.com/search + '/%QUERY',
              wildcard: '%QUERY'
          }
      });
      // get the input element and init typeahead on it
      let inputEl = $('.globalSearchInput input');   
      inputEl.typeahead(
          {
              minLength: 1,
              highlight: true,
          },
          {
              name: 'suggestions',
              source: this.suggestions,
              limit: 5,
              display: function(item) {
                  return item.title
              },
              templates: {
                  suggestion: (data) => {
                        return `<div class="ss-suggestion">
                                    <a class="tr_link" data-type="singleTrack" href="${data.slug}">
                                        <div class="sr_title">${data.title}</div>
                                    </a>
                                </div>`;
                  }
              }
          }
      );

      // handle routing when the user clicks on a suggestion
      let searchComponent = this;
      // let $ = JQuery;
      $(".globalSearchInput").on("click", ".ss-suggestion a", function(e) {
          e.preventDefault();
          let slug = $(this).attr("href");
          let type = $(this).data("type");
          searchComponent.$router.push({ name: type, params: { slug: slug }})
      });

      // update "q" if user selects an item from the suggestions
      inputEl.on('typeahead:select', (e, item) => {
          this.q = item.title;
      });
  }

Here’s a step-by-step explanation of whats happening:
– The first thing we did was setting up Bloodhound to fetch suggestions from the remote source.
– Then, we setup Typeahead on the input element. If you notice the markup of the suggestion template, you will see that we passed a data attribute (data-type="singleTrack"). The value of data-type is set to the name of the route to which you wish to navigate to. (as required by Vue-Router to navigate programmatically. A snippet of the router file can be found ahead)
– We attach an onclick handler to be called when the user clicks on one of the displayed link in the suggestions. Using Vue-router’s push() method, we redirect the User to the desired route.
– We also handle the scenario when the user selects an item in the list, but doesn’t actually click on the link. In this case, we update q‘s value to the selected item’s title.


Snippet displaying the configured routes. (specific to this application)

// router/index.js
import VueRouter from 'vue-router'
import track from 'components/track'
import searchResults from 'components/searchResults'

export new VueRouter({
    base: '/',
    mode: 'history',
    routes: [
        {}, // other routes
        {
            path: '/track/:slug',
            name: 'singleTrack',
            component: track
        },
        {
            path: '/search/:slug',
            name: 'search',
            component: searchResults
        },
   ]
});

You can now include your search.vue component in any other Vue component to test it.

// index.js
// import all the dependencies...

new Vue({
    el: '#myApp',
    router,
    template: '<search/>',
    components: { search }
});

Handle User Interaction –

Now, if you start typing in the input box, autocomplete suggestions should be displayed. When you click on the links in these suggestions, you should be routed to the respective route. But what happens if you hit the enter key or click on the search icon?
To answer that, notice the html markup section of search.vue above. We had attached event handlers to the @click and @keyup.enter events that call the gotoSearch() function. This gotoSearch() function needs to be defined. So lets do that. We put this function in the methods object.

// search.vue
methods: {
    gotoSearch(e) {
        if (!this.q.length) return;
        // let slug = slugify(this.q);
        this.$router.push({ name: 'search', params: { slug: slug || this.q }})
        $('.globalSearchInput input').typeahead('close');
    }
  }

The code above is quite straightforward. Slugify is just a helper function to clean the users input. You can use your own implementation depending upon your route’s definition. (hence it is commented out)
As before, we navigate programmatically using Vue Routers push() method. Once the navigation occurs, we close the list of suggestions displayed earlier.

And Viola! You already have a working search box based on Typeahead and Bloodhound. Howbeit, at this point, the search input box might seem painfully ugly for some. Go ahead and style it as per your taste.

3. Style Search Component:

You can make the search input and its suggestions look prettier by adding some custom styles. You can do that in the search component as follows:

// search.vue
<style scoped>
/* your styles */
 .globalSearchInput {
     color: red;      
 }
/* The styling has been left out since its out of the scope of this tutorial */
</style>

The complete Search Component:

Here, you will find the complete search.vue component in its integrity.

// search.vue
<template>
    <div class="globalSearchInput">
        <div class="search-input-container">
            <input type="text"
                   placeholder="Search..."
                   v-model.trim="q"
                   @keyup.enter="gotoSearch"
            >
        </div>
        <div class="search-icon-container">
            <i class="material-icons" @click="gotoSearch">search</i>
        </div>
    </div>
</template>
<script>
// import JQuery from 'jquery';
import Bloodhound from 'corejs-typeahead/dist/bloodhound';
import typeahead from 'corejs-typeahead/dist/typeahead.jquery';

export default {
  name: 'search',

  data () {
    return {
      q: '',
      suggestions: null
    }
  },

  methods: {
    gotoSearch(e) {
        if (!this.q.length) return;
        // let slug = slugify(this.q);
        this.$router.push({ name: 'search', params: { slug: slug || this.q }})
        $('.globalSearchInput input').typeahead('close');
    }
  },

  mounted() {
      // configure datasource for the suggestions (i.e. Bloodhound)
      this.suggestions = new Bloodhound({
          datumTokenizer: Bloodhound.tokenizers.obj.whitespace('title'),
          queryTokenizer: Bloodhound.tokenizers.whitespace,
          identify: function(item) {
              return item.id;
          },
          remote: {
              url: http://domain.com/search + '/%QUERY',
              wildcard: '%QUERY'
          }
      });
      // get the input element and init typeahead on it
      let inputEl = $('.globalSearchInput input');   
      inputEl.typeahead(
          {
              minLength: 1,
              highlight: true,
          },
          {
              name: 'suggestions',
              source: this.suggestions,
              limit: 5,
              display: function(item) {
                  return item.title
              },
              templates: {
                  suggestion: (data) => {
                        return `<div class="ss-suggestion">
                                    <a class="tr_link" data-type="singleTrack" href="${data.slug}">
                                        <div class="sr_title">${data.title}</div>
                                    </a>
                                </div>`;
                  }
              }
          }
      );

      // handle routing when the user clicks on a suggestion
      let searchComponent = this;
      // let $ = JQuery;
      $(".globalSearchInput").on("click", ".ss-suggestion a", function(e) {
          e.preventDefault();
          let slug = $(this).attr("href");
          let type = $(this).data("type");
          searchComponent.$router.push({ name: type, params: { slug: slug }})
      });

      // update "q" if user selects an item from the suggestions
      inputEl.on('typeahead:select', (e, item) => {
          this.q = item.title;
      });
  }
}
</script>
<style scoped>
// your styles
</style>

You can see this code live in action on GospelMusic. Feel free to drop any comments in case you run into something that doesn’t work for you or something that needs further clarification.
Hope that helps 🙂


References

Leave a Reply