Search with autosuggestion in Vanilla JS

I was asked this question in one of my interviews at PingIdentity.

The problem statement reads as:

  • Create an Auto Suggestion Box in Vanilla JS
  • Create a suggestion area bottom to the input box that shows the suggestion list
  • The list is visible when the input box is in focus or when user types, it hides when the input box is blurred
  • getSuggestions(text); method will act as mock server and will return random text based on the inputs with 0 – 200 millisceond latency and may fail
  • If a suggestion is clicked, populate the input box with its value and bring input box in focus

Search with auto-suggestion in Vanilla Js

They had also provided this mock-server code that mimics the network with a latency.

// Mock Server
const FAILURE_COUNT = 10;
const LATENCY = 200;

function getRandomBool(n) {
  const threshold = 1000;
  if (n > threshold) n = threshold;
  return Math.floor(Math.random() * threshold) % n === 0;
}

function getSuggestions(text) {
  var pre = 'pre';
  var post = 'post';
  var results = [];
  if (getRandomBool(2)) {
    results.push(pre + text);
  }
  if (getRandomBool(2)) {
    results.push(text);
  }
  if (getRandomBool(2)) {
    results.push(text + post);
  }
  if (getRandomBool(2)) {
    results.push(pre + text + post);
  }
  return new Promise((resolve, reject) => {
    const randomTimeout = Math.random() * LATENCY;
    setTimeout(() => {
      if (getRandomBool(FAILURE_COUNT)) {
        reject();
      } else {
        resolve(results);
      }
    }, randomTimeout);
  });
}

getSuggestions(text) will randomly throw an error (fail) or resolve with any array of strings, the list can also be empty, so I had to handle it effectively.

This is my implementation of how I solved this question.

Reading the problem statement, the first thing I did was create the HTML layout with a search input field where the values can be typed and then a suggestion area below that.

<main>
  <input type="search" id="search" placeholder="Enter your query"/>
  <div id="suggestion-area"></div>
</main>

You can create this all through JavaScript, but remember, during interviews, it is better to spend the time on the logic building rather than on anything else.

I added a simple style to the layout.

main{
  width: 500px;
  margin: 10px auto 0 auto;
}

#search{
  padding: 10px;
  width: 100%;
}

#suggestion-area{
  border: 1px solid red;
  margin-top: 10px;
  min-height: 100px;
  padding: 5px;
  position: relative;
  display: none;
}

If you notice, by default, #suggestion-area is displayed as none and will be changed dynamically through the JavaScript, as we have to show this only when the input field is in focus.

The next thing is writing the script to handle all the logic. I have wrapped everything under the IIFE (immediately invoked function expression), as I wanted the variables and methods to be private and should not conflict with each other.

(function(){
  ... rest of the code will go here
}());

Inside this, select the input-search and suggestion areas and assign the event listeners to them.

const input = document.getElementById("search");
const suggestionArea = document.getElementById("suggestion-area");

input.addEventListener('focus', onFocus);
input.addEventListener('keyup', onChange);
suggestionArea.addEventListener('click', onClick, true);

We are listening to the focus event to enable or display the suggestion area, and then to the keyup event to check what the user is typing. Unlike React, where the onChange event fires as you type in the input field, the vaniall js change event fires when the user is done typing and goes out of focus. Knowing the basics really helps.

We are listening to the click event in the suggestion area so that we can populate the input search with the suggestion that is clicked. We have passed true to the useCapture to enable the event capture, and so rather than assigning an event to each suggested item, we can assign the listener to the parent and then capture the event fired on its child.

onFocus handler, update the style of suggestion-area and make it display block.

const onFocus = () => {
  suggestionArea.style.display = "block";
}

onChange handler, we have to pass the text to the getSuggestions(text) and render the returned suggested list. Remember, getSuggestions(text) returns a promise, so we will be creating an async function and handling the response accordingly.

const onChange = (e) => {
    const {value} = e.target;
    processData(value);
  }
  
  const processData = async (value) => {
    suggestionArea.style.display = "block";
    suggestionArea.innerHTML = "";
    
    if(!value){
      return;
    }
    
    try{
      const resp = await getSuggestions(value);
      if(resp.length > 0){
        const list = document.createElement('ul');
        resp.forEach((e) => {
          const listItems = document.createElement('li');
          listItems.style.cursor = "pointer";
          listItems.innerText = e;
          list.appendChild(listItems);
        });
        
        suggestionArea.innerHTML = "";
        suggestionArea.appendChild(list);
      }
    }catch(e){
      console.error("Error while making network call", e);
    }
  }

We are creating an unordered list at runtime, populating the suggested list in it, and then adding this unordered list to the suggestion area.

onClick handler, to update the input search when any suggested item is clicked.

const onClick = (e) => {
    if(e.target === suggestionArea){
      return;
    }
    
    const text = e.target.innerText;
    input.value = text;
    input.focus();
}

If you click anywhere in the suggestion area, nothing will happen, if you click on any of the suggestion lists, the input box will be populated with them.

The final piece is to hide the suggestion area when the input box is blurred or goes out of focus, and if we are clicking anywhere on the window, expect to see the suggestion area and the input box.

Thus, we will listen to the click event on the window, and in the handler, we will check if the click target is not the suggestion area or input search, then hide the suggestion area.

const onBlur = (e) => {
    if(e.target === input || e.target === suggestionArea){
      return;
    }
    
    suggestionArea.style.display = "none";
  }

window.addEventListener('click', onBlur);

Complete script code for search with autosuggestion in Vanilla JS

(function(){
  const input = document.getElementById("search");
  const suggestionArea = document.getElementById("suggestion-area");
  
  const onFocus = () => {
    suggestionArea.style.display = "block";
  }
  
  const onBlur = (e) => {
    if(e.target === input || e.target === suggestionArea){
      return;
    }
    
    suggestionArea.style.display = "none";
  }
  
  const onChange = (e) => {
    const {value} = e.target;
    processData(value);
  }
  
  const processData = async (value) => {
    suggestionArea.style.display = "block";
    suggestionArea.innerHTML = "";
    
    if(!value){
      return;
    }
    
    try{
      const resp = await getSuggestions(value);
      if(resp.length > 0){
        const list = document.createElement('ul');
        resp.forEach((e) => {
          const listItems = document.createElement('li');
          listItems.style.cursor = "pointer";
          listItems.innerText = e;
          list.appendChild(listItems);
        });
        
        suggestionArea.innerHTML = "";
        suggestionArea.appendChild(list);
      }
    }catch(e){
      console.error("Error while making network call", e);
    }
  } 
  
  const onClick = (e) => {
    if(e.target === suggestionArea){
      return;
    }
    
    const text = e.target.innerText;
    input.value = text;
    input.focus();
  }
  
  input.addEventListener('focus', onFocus);
  window.addEventListener('click', onBlur);
  input.addEventListener('keyup', onChange);
  suggestionArea.addEventListener('click', onClick, true);
}());

Notice that we have the event listeners at the bottom and all the functions at the top, as the functions are declared with variables, and accessing variables defined with Let and Const can result in a temporal dead zone.