Create an HTML input text element with debounced on input callback

Viewed 2335

I have a JavaScript application where users can search for places on a map. The input text provides autocomplete suggestions: as the user types, some suggestions appear at the bottom of the text input itself.

I'm using a third party JavaScript autocomplete library which charge per user request. Unfortunately each keystroke counts as a single request since the library doesn't implement any debouncing when receiving the onInput callback from the input element. So the suggestions look snappy but at a cost of many user requests.

What I'd like to do is to redefine the on input callback inside the input element to implement debouncing (let's say 500ms).

Since the third party library accepts the JavaScript element itself, I cannot use an external debouncing mechanism: (probably the library detects the onInput message sent by the input element itself)

var placesAutocomplete = places({
    appId: 'xxxxxxxxxxx',
    apiKey: 'kkkkkkkkkk',
    container: document.querySelector('#inputelement'), // <- the library accepts the element itself, not an "on input" listener (which could be easily debounced by an external function)
    language: G_lang
});

placesAutocomplete.on('change', function(res){
    // user leaves the input text, set lat lon on my map (code not shown here on SO)
    var lat = res.suggestion.latlng.lat;
    var lon = res.suggestion.latlng.lng;
    finish(lat, lon);
});

the debounce must be provided by the JavaScript element itself. So basically, the element should fire an onInput callback filtered by the debouncing mechanism.

Is it possible to do so by using vanilla JavaScript only?

EDIT

Looks like someone tried to make a pull request for a debounce feature on the GitHub project page but got rejected: https://github.com/algolia/places/issues/281

and someone else forked the library and merged the pull request on its own fork -> https://github.com/AcuityScheduling/places/tree/feature/debounce

2 Answers

Using the official codepen, I made this hackish debounced version:

var client = algoliasearch("latency", "6be0576ff61c053d5f9a3225e2a90f76")
var index = client.initIndex('movies');
var myAutocomplete = autocomplete('#search-input', {
  hint: false
}, [{
  source: autocomplete.sources.hits(index, {
    hitsPerPage: 5
  }),
  displayKey: 'title',
  templates: {
    suggestion: function(suggestion) {
      var sugTemplate = "<img src='" + suggestion.image + "'/><span>" + suggestion._highlightResult.title.value + "</span>"
      return sugTemplate;
    }
  }
}]).on('autocomplete:selected', function(event, suggestion, dataset) {
  console.log(suggestion, dataset);
});

document.querySelector(".searchbox [type='reset']").addEventListener("click", function() {
  document.querySelector(".aa-input").focus();
  this.classList.add("hide");
  myAutocomplete.autocomplete.setVal("");
});

document.querySelector("#debouncer").addEventListener("input", function() {
  var s = document.querySelector("#search-input");
  s.value = this.value;
  clearTimeout(this.tick);
  this.tick = setTimeout(() => s.dispatchEvent(new KeyboardEvent('input')), 500);
});

document.querySelector("#search-input").addEventListener("input", function() {
  var searchbox = document.querySelector(".aa-input");
  var reset = document.querySelector(".searchbox [type='reset']");
  if (searchbox.value.length === 0) {
    reset.classList.add("hide");
  } else {
    reset.classList.remove('hide');
  }
});
body {
  padding: 60px;
}

.searchbox {
  display: inline-block;
  position: relative;
  width: 200px;
  height: 37px;
  white-space: nowrap;
  box-sizing: border-box;
  font-size: 13px;
  font-family: arial, sans-serif;
}

#debouncer {
  position: absolute;
  z-index: 1;
  box-sizing:border-box;
  padding: 0 30px 0 37px;
  width: 100%;
  height: 100%;
  vertical-align: middle;
  white-space: normal;
  font-size: inherit;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  background: none;
  border:0;
}

.algolia-autocomplete {
  display: block;
  height: 100%;
}

.searchbox__wrapper {
  width: 100%;
  height: 100%;
}

.searchbox__input {
  display: inline-block;
  -webkit-transition: box-shadow .4s ease, background .4s ease;
  transition: box-shadow .4s ease, background .4s ease;
  border: 0;
  border-radius: 19px;
  box-shadow: inset 0 0 0 1px #D9D9D9;
  color:transparent;
  background: #FFFFFF;
  padding: 0;
  padding-right: 30px;
  padding-left: 37px;
  width: 100%;
  height: 100%;
  vertical-align: middle;
  white-space: normal;
  font-size: inherit;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
}

.searchbox__input::-webkit-search-decoration,
.searchbox__input::-webkit-search-cancel-button,
.searchbox__input::-webkit-search-results-button,
.searchbox__input::-webkit-search-results-decoration {
  display: none;
}

#debouncer:hover ~ * .searchbox__input {
  box-shadow: inset 0 0 0 1px silver;
}

#debouncer:focus ~ * .searchbox__input,
#debouncer:active ~ * .searchbox__input {
  outline: 0;
  box-shadow: inset 0 0 0 1px #4098CE;
  background: #FFFFFF;
}

#debouncer::-webkit-input-placeholder {
  color: #AAAAAA;
}

#debouncer::-moz-placeholder {
  color: #AAAAAA;
}

#debouncer:-ms-input-placeholder {
  color: #AAAAAA;
}

#debouncer::placeholder {
  color: #AAAAAA;
}

.searchbox__submit {
  position: absolute; z-index:2;
  top: 0;
  right: inherit;
  left: 0;
  margin: 0;
  border: 0;
  border-radius: 18px 0 0 18px;
  background-color: rgba(255, 255, 255, 0);
  padding: 0;
  width: 37px;
  height: 100%;
  vertical-align: middle;
  text-align: center;
  font-size: inherit;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}

.searchbox__submit::before {
  display: inline-block;
  margin-right: -4px;
  height: 100%;
  vertical-align: middle;
  content: '';
}

.searchbox__submit:hover,
.searchbox__submit:active {
  cursor: pointer;
}

.searchbox__submit:focus {
  outline: 0;
}

.searchbox__submit svg {
  width: 17px;
  height: 17px;
  vertical-align: middle;
  fill: #666666;
}

.searchbox__reset {
  position: absolute; z-index:2;
  top: 8px;
  right: 8px;
  margin: 0;
  border: 0;
  background: none;
  cursor: pointer;
  padding: 0;
  font-size: inherit;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  fill: rgba(0, 0, 0, 0.5);
}

.searchbox__reset.hide {
  display: none;
}

.searchbox__reset:focus {
  outline: 0;
}

.searchbox__reset svg {
  display: block;
  margin: 4px;
  width: 13px;
  height: 13px;
}

.searchbox__input:valid~.searchbox__reset {
  display: block;
  -webkit-animation-name: sbx-reset-in;
  animation-name: sbx-reset-in;
  -webkit-animation-duration: .15s;
  animation-duration: .15s;
}

@-webkit-keyframes sbx-reset-in {
  0% {
    -webkit-transform: translate3d(-20%, 0, 0);
    transform: translate3d(-20%, 0, 0);
    opacity: 0;
  }
  100% {
    -webkit-transform: none;
    transform: none;
    opacity: 1;
  }
}

@keyframes sbx-reset-in {
  0% {
    -webkit-transform: translate3d(-20%, 0, 0);
    transform: translate3d(-20%, 0, 0);
    opacity: 0;
  }
  100% {
    -webkit-transform: none;
    transform: none;
    opacity: 1;
  }
}

.aa-dropdown-menu {
  position: relative;
  top: -6px;
  border-radius: 3px;
  margin: 6px 0 0;
  padding: 0;
  text-align: left;
  height: auto;
  position: relative;
  background: transparent;
  border: none;
  width: 100%;
  left: 0 !important;
  box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2), 0 2px 3px 0 rgba(0, 0, 0, 0.1);
}

.aa-dropdown-menu:before {
  position: absolute;
  content: '';
  width: 14px;
  height: 14px;
  background: #fff;
  z-index: 0;
  top: -7px;
  border-top: 1px solid #D9D9D9;
  border-right: 1px solid #D9D9D9;
  transform: rotate(-45deg);
  border-radius: 2px;
  z-index: 999;
  display: block;
  left: 24px;
}

.aa-dropdown-menu .aa-suggestions {
  position: relative;
  z-index: 1000;
}

.aa-dropdown-menu [class^="aa-dataset-"] {
  position: relative;
  border: solid 1px #D9D9D9;
  border-radius: 3px;
  overflow: auto;
  padding: 8px 8px 8px;
}

.aa-dropdown-menu * {
  box-sizing: border-box;
}

.aa-suggestion {
  font-size: 1.1em;
  padding: 4px 4px 0;
  display: block;
  width: 100%;
  height: 38px;
  clear: both;
}

.aa-suggestion span {
  white-space: nowrap!important;
  text-overflow: ellipsis;
  overflow: hidden;
  display: block;
  float: left;
  line-height: 2em;
  width: calc(100% - 30px);
}

.aa-suggestion.aa-cursor {
  background: #eee;
}

.aa-suggestion em {
  color: #4098CE;
}

.aa-suggestion img {
  float: left;
  vertical-align: middle;
  height: 30px;
  width: 20px;
  margin-right: 6px;
}
<script src="//cdn.jsdelivr.net/algoliasearch/3/algoliasearch.min.js"></script>
<script src="//cdn.jsdelivr.net/autocomplete.js/0/autocomplete.min.js"></script>

<form novalidate="novalidate" onsubmit="return false;" class="searchbox">
  <div role="search" class="searchbox__wrapper">

    <input id="debouncer" type="text" name="search" autocomplete="off" placeholder="Search for a movie">

    <input id="search-input" type="search" name="autocomplete" autocomplete="off" required="required" class="searchbox__input">
    <button type="submit" title="Submit your search query." class="searchbox__submit">
      <svg role="img" aria-label="Search">
        <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#sbx-icon-search-13"></use>
          </svg>
        </button>
    <button type="reset" title="Clear the search query." class="searchbox__reset hide">
      <svg role="img" aria-label="Reset">
        <use xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="#sbx-icon-clear-3"></use>
        </svg>
      </button>
  </div>
</form>

<div class="svg-icons" style="height: 0; width: 0; position: absolute; visibility: hidden">
  <svg xmlns="http://www.w3.org/2000/svg">
    <symbol id="sbx-icon-clear-3" viewBox="0 0 40 40"><path d="M16.228 20L1.886 5.657 0 3.772 3.772 0l1.885 1.886L20 16.228 34.343 1.886 36.228 0 40 3.772l-1.886 1.885L23.772 20l14.342 14.343L40 36.228 36.228 40l-1.885-1.886L20 23.772 5.657 38.114 3.772 40 0 36.228l1.886-1.885L16.228 20z" fill-rule="evenodd"/></symbol>
    <symbol id="sbx-icon-search-13" viewBox="0 0 40 40"><path d="M26.806 29.012a16.312 16.312 0 0 1-10.427 3.746C7.332 32.758 0 25.425 0 16.378 0 7.334 7.333 0 16.38 0c9.045 0 16.378 7.333 16.378 16.38 0 3.96-1.406 7.593-3.746 10.426L39.547 37.34c.607.608.61 1.59-.004 2.203a1.56 1.56 0 0 1-2.202.004L26.807 29.012zm-10.427.627c7.322 0 13.26-5.938 13.26-13.26 0-7.324-5.938-13.26-13.26-13.26-7.324 0-13.26 5.936-13.26 13.26 0 7.322 5.936 13.26 13.26 13.26z" fill-rule="evenodd"/></symbol>
 </svg>
</div>

This approach is hardly better than this autocomplete implementation itself.. Hope it may help.

I think I managed to find a very good solution. I forked the library and enabled the debounce feature (which was already there, only disabled for the autocomplete form).

End result: 80% usage drop given a small autocomplete lag of 200ms, perfectly acceptable

Related