DEV Community

Bruno Oliveira
Bruno Oliveira

Posted on

Flask series part 6: Improving user input with autocomplete

Introduction

User input plays a pivotal role in shaping the user experience of your application, and, in some cases, it can make it, or break it.

People are usually drawn in, and "retained" to web sites where the UX is "invisible". Meaning, that it's so intuitive and easy to use, that most end-users don't realize all the work happening under the hood that is seemingly so smooth to the eye.

If you ever used applications such as airline company websites, bookstore websites, Netflix, movie aggregators, etc, you might have come across a myriad of different ways of receiving user input: there might be lots of large, big images on the screen and you can just click on them to select what you want, a date field might pop up a calendar open so you can easily select a date, a selection of movies can appear under an auto-complete coupled even with tiny thumbnail images in front of the movie title, etc.

What distinguishes good websites from bad ones, is how intuitive and accessible they are in providing information and feedback of interactions to the user. This is very important.

Date fields are some of the most tricky ones to get right, even, when using calendar widgets it can be tricky to provide a good user experience.

A great example of a good design of a date selection is for example, Google Flights:

calendar

This does a lot of things very well: it has a clear design, will work well on mobile, it has a big blue button for when you are "done", and, even better, it's contextually amazing. You can actually see the prices for each flight below each date!! This is the proper way to ensure that when an end-user will have to select a date range in your app, it will be in possession of a lot of very important information to aid in the remainder of the booking process.

This is extremely useful, but, also extremely difficult to do and adapt to your own business/domain, especially if it's a highly specialized domain like medicine or financial information (like the stock market) both of which contain highly specialized information that can be hard to show in a meaningful way.

Our current problem

Having to manually enter ingredients and especially having to type them in and even comma-separate them, is too error-prone.

Requiring some logic on our endpoint that ensures that the input will be well formatted in order to be stored in our database is one step too many that can be avoided, if we improve the way we handle user input.

Possible alternatives to implement an autocomplete component

The approach we will follow will be to implement an autocomplete feature that will allow a user to see a list of ingredients as it types.

It will look like this:

autocomplete

There are multiple ways to implement this, it can be done purely as a front-end component where the data is statically stored in-memory (like in an array), or, if we want, we can create an endpoint linked to this component, that will fetch the data from an external source and send it to the front-end, where the normal flow can follow.

It is important that we keep the data source for the auto-complete injectable.

This means that we need to be able to pass it as a parameter to our component, and not tie the component itself to any specific data source.

Actually, this principle is behind the very popular Spring Framework for writing Java applications and it is known as dependency injection.

We will choose to keep the array of ingredients that will feed our autocomplete component completely in-memory, for several reasons:

  • It's memory footprint is quite small, since in essence, it's an array of a few hundred strings at most;

  • It's faster to hook it up to our actual component if we have it directly in the front-end;

  • Allows for a faster feedback loop when developing the component;

Creating the component

To create our component, we will need a combination of Javascript, CSS and linking to our Jinja template via HTML attributes.

The form that represented our search bar can be enriched with a new id value and we can use that id from the JS side to connect the moving pieces.

Form in Jinja

This is how the form in Jinja will look like:

<form method="POST" autocomplete="off">
    <div class='col-xs-12 col-sm-12 col-md-10 col-lg-10'>
        <div class='input-group'>
            <input id="autocomplete" class='form-control' type='text' name='restaurant_name'
                   placeholder='Enter ingredients separated by commas...'/>
            <span class="input-group-btn">
              <button id="searchBtn" type='submit' class='btn btn-default'>
                <span class='glyphicon glyphicon-search'></span>
              </button>
            </span>

        </div>
    </div>
</form>

The property autocomplete needs to be set to off to prevent the browser from remembering previous entered values which would be on top of our auto complete component.

Then we set the id of the input field to be "autocomplete". This id will be what will bind the JS logic to the HTML component. And this is it for the Jinja template.

Setting up the JS to drive the autocomplete

Here is the Javascript:

function autocomplete(inp, arr) {
  /*the autocomplete function takes two arguments,
  the text field element and an array of possible autocompleted values:*/
  var currentFocus;
  /*execute a function when someone writes in the text field:*/
  inp.addEventListener("input", function(e) {
      var a, b, i, val = this.value;
      /*close any already open lists of autocompleted values*/
      closeAllLists();
      if (!val) { return false;}
      currentFocus = -1;
      /*create a DIV element that will contain the items (values):*/
      a = document.createElement("DIV");
      a.setAttribute("id", this.id + "autocomplete-list");
      a.setAttribute("class", "autocomplete-items");
      /*append the DIV element as a child of the autocomplete container:*/
      this.parentNode.appendChild(a);
      /*for each item in the array...*/
      for (i = 0; i < arr.length; i++) {
        /*check if the item starts with the same letters as the text field value:*/
        if (arr[i].substr(0, val.length).toUpperCase() == val.toUpperCase()) {
          /*create a DIV element for each matching element:*/
          b = document.createElement("DIV");
          /*make the matching letters bold:*/
          b.innerHTML = "<strong>" + arr[i].substr(0, val.length) + "</strong>";
          b.innerHTML += arr[i].substr(val.length);
          /*insert a input field that will hold the current array item's value:*/
          b.innerHTML += "<input type='hidden' value='" + arr[i] + "'>";
          /*execute a function when someone clicks on the item value (DIV element):*/
              b.addEventListener("click", function(e) {
              /*insert the value for the autocomplete text field:*/
              inp.value = this.getElementsByTagName("input")[0].value;
              /*close the list of autocompleted values,
              (or any other open lists of autocompleted values:*/
              closeAllLists();
          });
          a.appendChild(b);
        }
      }
  });
  /*execute a function presses a key on the keyboard:*/
  inp.addEventListener("keydown", function(e) {
      var x = document.getElementById(this.id + "autocomplete-list");
      if (x) x = x.getElementsByTagName("div");
      if (e.keyCode == 40) {
        /*If the arrow DOWN key is pressed,
        increase the currentFocus variable:*/
        currentFocus++;
        /*and and make the current item more visible:*/
        addActive(x);
      } else if (e.keyCode == 38) { //up
        /*If the arrow UP key is pressed,
        decrease the currentFocus variable:*/
        currentFocus--;
        /*and and make the current item more visible:*/
        addActive(x);
      } else if (e.keyCode == 13) {
        /*If the ENTER key is pressed, prevent the form from being submitted,*/
        e.preventDefault();
        if (currentFocus > -1) {
          /*and simulate a click on the "active" item:*/
          if (x) x[currentFocus].click();
        }
      }
  });
  function addActive(x) {
    /*a function to classify an item as "active":*/
    if (!x) return false;
    /*start by removing the "active" class on all items:*/
    removeActive(x);
    if (currentFocus >= x.length) currentFocus = 0;
    if (currentFocus < 0) currentFocus = (x.length - 1);
    /*add class "autocomplete-active":*/
    x[currentFocus].classList.add("autocomplete-active");
  }
  function removeActive(x) {
    /*a function to remove the "active" class from all autocomplete items:*/
    for (var i = 0; i < x.length; i++) {
      x[i].classList.remove("autocomplete-active");
    }
  }
  function closeAllLists(elmnt) {
    /*close all autocomplete lists in the document,
    except the one passed as an argument:*/
    var x = document.getElementsByClassName("autocomplete-items");
    for (var i = 0; i < x.length; i++) {
      if (elmnt != x[i] && elmnt != inp) {
      x[i].parentNode.removeChild(x[i]);
    }
  }
}
/*execute a function when someone clicks in the document:*/
document.addEventListener("click", function (e) {
    closeAllLists(e.target);
});
}

/*An array containing ingredients*/
var ingredients = ["avocado","apple","allspice","almonds","aspargus","aubergine","arugula","ananas",
"butter", "bread", "beef", "baking soda", "bell peper", "basil", "brown sugar","broccoli","banana",
"cinnamon", "carrot", "chicken", "cream", "cheese","cauliflower",
"dijon", "dill", "dark chocolate", "dry mustard",
"egg", "entrecote", "egg yolk", "eggplant",
"flour", "fusilli", "farfalle",
"garlic", "ginger", "ground beef", "green pepper", "ground meat",
"honey", "heavy cream", "hot pepper sauce", "hot sauce",
"ice", "ice cream", "italian herbs",
"jalapeno", "jam",
"ketchup", "kale", "kiwi", "kosher salt",
"lemon", "lime", "light cream", "lettuce", "lentils", "leek",
"mayonnaise", "mustard", "meat", "milk", "mushrooms",
"nutmeg", "noodles", "nutella",
"olive oil", "onion", "olives", "oregano", "orange",
"pear", "peach", "parmesan", "potatoes", "pineapple",
"quinoa", "red lentils", "red pepper", "romaine lettuce",
"sugar", "sour cream", "soy sauce",
"tomatoes", "thyme", "tomato sauce","tuna",
"vegetable oil", "vanilla", "vodka", "vinegar", "vegetable broth",
"wheat", "walnut", "white wine", "whipped cream", "worcestershire sauce",
"yoghurt", "yeast", "zuchinni"];
autocomplete(document.getElementById("autocomplete"), ingredients);

This contains the logic for driving the search, ensuring the matching letters appear in bold, and other details.

The most relevant pieces of code are the actual in-memory list of all options that we will use to populate the component, and the invocation of the function in the end of the script.

Notice how the ID is the same as the one we added on the Jinja side. This is the link between both parts.

Add CSS

Finally, the CSS can be tweaked of course, but, this is the current version:

body {
  font: 16px Arial;
}
.autocomplete {
  /*the container must be positioned relative:*/
  position: relative;
  display: inline-block;
}
input {
  border: 1px solid transparent;
  background-color: #f1f1f1;
  padding: 10px;
  font-size: 16px;
}
input[type=text] {
  background-color: #f1f1f1;
  width: 100%;
}
input[type=submit] {
  background-color: DodgerBlue;
  color: #fff;
}
.autocomplete-items {
  position: absolute;
  border: 1px solid #d4d4d4;
  border-bottom: none;
  border-top: none;
  z-index: 99;
  /*position the autocomplete items to be the same width as the container:*/
  top: 100%;
  left: 0;
  right: 0;
}
.autocomplete-items div {
  padding: 10px;
  cursor: pointer;
  background-color: #fff;
  border-bottom: 1px solid #d4d4d4;
}
.autocomplete-items div:hover {
  /*when hovering an item:*/
  background-color: #e9e9e9;
}
.autocomplete-active {
  /*when navigating through the items using the arrow keys:*/
  background-color: DodgerBlue !important;
  color: #ffffff;
}

And voilà, with this, our new autocomplete component is finished!

Conclusion

I hope you liked reading, and I hope this post helped understanding just how important context is when designing UI components, as well as their importance in preventing certain classes of errors that otherwise wouldn't arise.

Stay tuned!

Top comments (5)

Collapse
 
tsheyman profile image
Taylor Heyman

Can you share how to supply the array of ingredients NOT in memory?
I'm building something similar, and have a dict of items queried from a database. I'd like to pass that data to the client, as the values may change.

Collapse
 
brunooliveira profile image
Bruno Oliveira

Hi!
Maybe part 13 helps you?
dev.to/brunooliveira/flask-series-...

Collapse
 
rohansawant profile image
Rohan Sawant

What a great read! 🔥

I always end up using - materializecss.com/autocomplete.html

Collapse
 
brunooliveira profile image
Bruno Oliveira

Thanks a lot! I'll keep learning more and writing as I go!

Collapse
 
auct profile image
auct

Same as here?
w3schools.com/howto/howto_js_autoc...
Did they copy your code or you copy code from them?