Creating a basic typeahead in Vanilla JavaScript

Written by Tom Wilkins
Sun, 19 Jan 2020

Make it easy for users to find your content with a typeahead!

Creating a basic typeahead in Vanilla JavaScript

In this post, we're going to make a typeahead input field for my recipe section. Specifically, this typeahead input here:

    You'll most likely have seen this kind of thing on the web, and on the face of it it can seem challenging, but breaking it down into steps makes it simpler.

    1. Making the data available to your script.
    2. Creating an input field and listening to events.
    3. Returning data matching the search input, and sorting accordingly.
    4. Listen to arrow key events to allow the user to navigate options with their keyboard.
    5. Styling with CSS.

    Step 1: Making data available

    The data I'll be using is the recipe section of this website. There's not a lot in it, so in the real-world there's not much need for a typeahead, but it serves a purpose for demonstrating this technique.

    This site is built using 11ty, and all of the recipe pages are in a 'recipe' collection. So the first step for me is to make an 11ty filter to help me bring the relevant collection data into my page.

    .eleventy.js

    eleventyConfig.addFilter('pageMeta', collection => {
    return JSON.stringify(collection.map(page => {
    const { permalink, name } = page.data;
    return {
    permalink,
    name
    }
    }));

    Then, in the markdown template for this exact page, I assign that page data to a global variable. The data is now in this page and available to scripts. You can see the data structure viewing the source of this page!

    typeahead-vanilla-javascript.md

    <script>
    const recipes = {{ collections.recipe | pageMeta | safe }};
    </script>

    Step 2: Creating an input and handling events

    Let's begin by adding an html input field:

    <div class="typeahead">
    <input class="inputfield" id="typeahead" placeholder="Search for recipes" />
    <ul id="typeahead-results"></ul>
    </div>

    I've added a class to keep styling consistent with other input fields I have on the site. The ID is there for my script to target this element. While you can target elements with classes, it's best practice to use ID - particularly when there is just one element that will be targeted with your JavaScript.

    The ul with the id typeahead-results is where we'll display the result set.

    Now let's attach an event to the input. I've decided to scope this as an object, so that everything relating to this feature is contained and organised.

    const typeahead = {
    init: function() {
    this.input = document.getElementById("typeahead");
    if (!this.input) return;
    this.input.addEventListener("input", this.handleInput.bind(this));
    this.resultHolder = document.getElementById("typeahead-results");
    }
    };

    typeahead.init();

    The init function sets everything up. It targets our input element, and if it is there attaches an event listener. If the element is not there, nothing else defined in the script will happen. This safeguards us against instances where the element might disappear, or the script might be included on other pages where this element won't appear.

    We assign the input element as a property on the typeahead object - that's because we'll need to access it in various methods that are (or will be) included on the object.

    The event listener requires two parameters, the event (I've chosen input here, which fires with each keystroke on the input field), and a callback function to be invoked when the event occurs.

    What might look interesting or unusual is that I've defined this.handleInput.bind(this) as the callback function. I've done this because I want the this keyword to refer to the typeahead object in my callback function, as I'll be accessing properties and methods within the callback. If I didn't do this, this would refer to the input field itself when the callback is invoked.

    typeahead.init() invokes the init function to initialise the object and set everything up.

    Step 3: Returning data and sorting by relevance

    The handleInput function is where we return the relevant data. Here's how it will be defined:

    handleInput: function() {
    this.clearResults();
    const { value } = this.input;
    if (value.length < 1) return;
    const strongMatch = new RegExp("^" + value, "i");
    const weakMatch = new RegExp(value, "i");
    const results = recipes
    .filter(recipe => weakMatch.test(recipe.name))
    .sort((a, b) => {
    if (strongMatch.test(a.name) && !strongMatch.test(b.name)) return -1;
    if (!strongMatch.test(a.name) && strongMatch.test(b.name)) return 1;
    return a.name < b.name ? -1 : 1;
    });
    for (const recipe of results) {
    const item = document.createElement("li");
    const matchedText = weakMatch.exec(recipe.name)[0];
    item.innerHTML = recipe.name.replace(
    matchedText,
    "<strong>" + matchedText + "</strong>"
    );
    item.dataset.permalink = recipe.permalink;
    this.resultHolder.appendChild(item);
    item.addEventListener('click', this.handleClick);
    }
    },
    handleClick: function() {
    window.location.href = this.dataset.permalink;
    },
    clearResults: function() {
    while (this.resultHolder.firstChild) {
    this.resultHolder.removeChild(this.resultHolder.firstChild);
    }
    1. First, we clearResults to ensure that no results from previous inputs are left in the view. Without this step, you would get duplication in the results as you type - we don't want that!

    2. Then, we take the value from the input. If the value is less than one character long, we'll exit the function. Otherwise, we'll continue and use the input value to construct two regular expressions - a strong match, and a weak match. The difference being, the strong match must start with the characters entered by the user, whereas the weak match only needs to contain the characters entered.

    3. Then, we use the recipes variable we defined earlier and filter it using our weak match regular expression, as that covers both cases. I'm using the filter array method to do this, which you can read more about in my Array Method blog post.

    4. The sort method is then used to order the results - this is where the strong match regular expression comes in. When comparing two recipes, we'll check to see if one matches the strong regex versus the other, to ensure stronger matches are shown first. We then fallback to an alphabetical sort.

    5. Finally, we bring our results into view for the user. To do that, I'm using a for...of loop to create an li element for each result, populate the text with the recipe name and make the permalink available. I'm also using the exec regular expression method to return the matching text for each recipe name, so I can wrap a <strong> tag around it. Each result is given a "click" event listener so that clicking a result takes the user to the relevant page, based on the permalink.

    And now we have the typeahead returning results to the user!

    Step 4: Allow users to navigate the results

    A common piece of UI you will see on the web with a typeahead is the ability to navigate the results with the arrow keys on your keyboard, so let's build that in.

    We'll start with a static member on the typeahead object:

    const typeahead = {
    selectedIndex: -1
    // ...as shown above
    };

    This is our key, to keep track of where the users has navigated to on the list.

    Now we need to listen to keydown events on the input. We'll set that up in our init method (notice I have used bind again):

    this.input.addEventListener("keydown", this.handleKeydown.bind(this));

    And then define the handleKeyDown method:

    handleKeydown: function(event) {
    const { keyCode } = event;
    const results = this.getResults();
    if (keyCode === 40 && this.selectedIndex < results.length - 1) {
    this.selectedIndex++;
    } else if (keyCode === 38 && this.selectedIndex >= 0) {
    this.selectedIndex--;
    } else if (keyCode === 13 && results[this.selectedIndex]) {
    window.location.href = results[this.selectedIndex].dataset.permalink;
    }
    for (let i = 0; i < results.length; i++) {
    const result = results[i];
    const selectedClass = "selected";
    if (i === this.selectedIndex) {
    result.classList.add(selectedClass);
    } else if (result.classList.contains(selectedClass)) {
    result.classList.remove(selectedClass);
    }
    }
    },
    getResults: function() {
    return this.resultHolder.children;
    }

    This extracts the keyCode from the event to determine which key has been pressed. Here's what the mean:

    • 40: arrow down
    • 38: arrow up
    • 13: enter/return

    We then have some simple if statements to determine what to do:

    • If the key is arrow down, and our current index is not the last result, increment the index up.
    • If the key is arrow up, and our current index is at or after the first result, increment the index down.
    • If the key is enter/return, and the current index relates to a valid result, direct the user to the permalink defined in the result's dataset.

    After performing these checks, we then loop through all results to either add or remove the class selected based on the current index, and the index in the loop.

    We also need to reset this index when the user input changes, we'll add that to the clearResults method:

      clearResults: function() {
    this.selectedIndex = -1;
    while (this.resultHolder.firstChild) {
    this.resultHolder.removeChild(this.resultHolder.firstChild);
    }

    We now have all the logic we need for this functionality to work, but the additional selected class isn't going to do much without some CSS.

    Step 5: Styling with CSS

    I'm using scss on this blog so my associated CSS looks like this:

    .typeahead {
    position: relative;
    }
    #typeahead-results {
    list-style: none;
    position: absolute;
    top: 4.5em;
    left: 0;
    background-color: $tertiaryColor;
    width: 250px;
    margin: 0 0.5em;
    padding: 0;
    &:empty {
    display: none;
    }
    @mixin selected {
    background-color: $primaryColor;
    color: $tertiaryColor;
    }
    li {
    border-bottom: $border;
    cursor: pointer;
    padding: 1em;
    &.selected {
    @include selected;
    }
    @include hover {
    @include selected;
    }
    }
    }

    First off, I don't want the standard bullet style that comes with ul tags so I'm making the list style none. I'm also making the list position: absolute; so that the results appear as an overlay and don't disrupt the document flow.

    The key for the typeahead is the styling attached to our selected class. When a result is selected, we want to use a different colour scheme for the background and text. I've put this into a SASS @mixin to allow me to reuse the same styles when a result is hovered.

    If you're wondering why I have hover as a mixin, it's because most of the time I only want hover styles to apply on desktop devices - I've found the behaviour to be strange when :hover styles are applied on mobile.

    Other bits like padding, margin and border have been applied, but that's down to personal preference.

    TL;DR

    The finished script looks like this:

    const typeahead = {
    selectedIndex: -1,
    init: function() {
    this.input = document.getElementById("typeahead");
    if (!this.input) return;
    this.resultHolder = document.getElementById("typeahead-results");
    this.input.addEventListener("input", this.handleInput.bind(this));
    this.input.addEventListener("keydown", this.handleKeydown.bind(this));
    },
    handleInput: function() {
    this.clearResults();
    const { value } = this.input;
    if (value.length < 1) return;
    const strongMatch = new RegExp("^" + value, "i");
    const weakMatch = new RegExp(value, "i");
    const results = recipes
    .filter(recipe => weakMatch.test(recipe.name))
    .sort((a, b) => {
    if (strongMatch.test(a.name) && !strongMatch.test(b.name)) return -1;
    if (!strongMatch.test(a.name) && strongMatch.test(b.name)) return 1;
    return a.name < b.name ? -1 : 1;
    });
    for (const recipe of results) {
    const item = document.createElement("li");
    const matchedText = weakMatch.exec(recipe.name)[0];
    item.innerHTML = recipe.name.replace(
    matchedText,
    "<strong>" + matchedText + "</strong>"
    );
    item.dataset.permalink = recipe.permalink;
    this.resultHolder.appendChild(item);
    item.addEventListener("click", this.handleClick);
    }
    },
    handleClick: function() {
    window.location.href = this.dataset.permalink;
    },
    getResults: function() {
    return this.resultHolder.children;
    },
    clearResults: function() {
    this.selectedIndex = -1;
    while (this.resultHolder.firstChild) {
    this.resultHolder.removeChild(this.resultHolder.firstChild);
    }
    },
    handleKeydown: function(event) {
    const { keyCode } = event;
    const results = this.getResults();
    if (keyCode === 40 && this.selectedIndex < results.length - 1) {
    this.selectedIndex++;
    } else if (keyCode === 38 && this.selectedIndex >= 0) {
    this.selectedIndex--;
    } else if (keyCode === 13 && results[this.selectedIndex]) {
    window.location.href = results[this.selectedIndex].dataset.permalink;
    }
    for (let i = 0; i < results.length; i++) {
    const result = results[i];
    const selectedClass = "selected";
    if (i === this.selectedIndex) {
    result.classList.add(selectedClass);
    } else if (result.classList.contains(selectedClass)) {
    result.classList.remove(selectedClass);
    }
    }
    }
    };

    typeahead.init();

    Thank you for reading

    You have reached the end of this blog post. If you have found it useful, feel free to share it on Twitter using the button below.

    Tags: TIL, JavaScript, Blog, Typeahead