Guides / Solutions / Ecommerce / Autocomplete

Recent Searches

Users often want to revisit past search experiences—for example, when looking for a video they liked in the past few days, to continue a research thread, or to checkout on an e-commerce site.

While this is a common UI pattern in the browser bar, you see it far less during on-site experiences. Bringing such a feature in your search UI can help you distinguish yourself and convert more by providing the user with ways to pick up where they left off.

Requirements

Difficulty
Intermediate
Features Query Suggestions
Prerequisites InstantSearch 3+

Implementation guide

This solution depends on the recent-searches npm package. You can install it with the following command:

1
npm install recent-searches

To let users enter queries and view suggestions, start by creating a template that includes standard <input> and <ul> tags.

Then, create a PredictiveSearchBox class that defines the behavior of an instantsearch widget. This widget controls the actions that occur as a user interacts with the search experience.

Finally, add the widget to the InstantSearch instance with addWidget.

Specifying the search box template

First, you specify the template for your search box container, which includes an <input> to render the search box, as well as a <ul> to populate the recent searches. You can customize the placeholder and value by passing them as arguments:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const renderSearchBoxContainer = (placeholder, value) => {
  return `
      <div id="searchbox">
        <div id="predictive-box" style="display: none;">
          <span id="predictive-box-text"></span>
        </div>
        <div
          id="search-box-container"
          class="search-box-container"
          role="combobox"
          aria-expanded="false"
          aria-haspopup="listbos"
          aria-owns="searches-suggestions"
          >
          <input 
            id="search-box-input"
            autocomplete="off"
            autofocus="true"
            placeholder="${placeholder || "Search"}"
            value="${value || ""}"
            type="text"
            aria-autocomplete="list"
            aria-controls="searches-suggestions"
            aria-activedescendant
          >
        </div>
        <div class="recent-searches-container">
          <ul id="recent-searches-tags" role="listbox" label="Searches" style="display: none">
        </div>
      </div>
    `;
  };

Creating the PredictiveSearchBox widget

Now move on to the main class, PredictiveSearchBox, that defines the widget’s behavior. The main parameters are:

  • maxSuggestions: the number of suggestion items to render
  • maxSavedSearchesPerQuery: the number of recent searches to consider
  • querySuggestionsIndex: the Query Suggestions index to target
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class PredictiveSearchBox {
  constructor(options) {
    Object.assign(this, options);

    if (typeof options.noResultsRenderer !== "function") {
      throw new Error(
        "You are required to pass a noResultRendered function that will render a no result message"
      );
    }
    // Default options
    this.maxSuggestions = this.maxSuggestions || 10;
    this.maxSavedSearchesPerQuery = this.maxSavedSearchesPerQuery || 4;

    this.RecentSearches = new RecentSearches({
      namespace: this.querySuggestionsIndex
    });

    this.client = algoliasearch(options.appID, options.apiKey);
    this.querySuggestionsIndex = this.client.initIndex(
      this.querySuggestionsIndex
    );

    this.tabActionSuggestion = null;
    this.previousSearchBoxEvent = null;
  }
}

When adding the search box to your instantsearch instance, you must specify a container into which you want to render the widget. Additionally, you register a series of event handlers to respond to various interactions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class PredictiveSearchBox {
  // ...

  init(instantSearchOptions) {
    this.helper = instantSearchOptions.helper;
    this.widgetContainer = document.querySelector(this.container);

    if (!this.widgetContainer) {
      throw new Error(
        `Could not find widget container ${this.container} inside the DOM`
      );
    }

    this.widgetContainer.innerHTML = renderSearchBoxContainer(
      this.placeholder,
      instantSearchOptions.helper.state.query
    );

    this.predictiveSearchBox = this.widgetContainer.querySelector(
      "#predictive-box"
    );
    this.predictiveSearchBoxItem = this.widgetContainer.querySelector(
      "#predictive-box-text"
    );
    this.predictiveSearchBoxContainer = this.widgetContainer.querySelector(
      "#search-box-container"
    );

    this.searchBoxInput = this.widgetContainer.querySelector(
      "#search-box-input"
    );
    this.suggestionTagsContainer = this.widgetContainer.querySelector(
      "#recent-searches-tags"
    );

    this.registerSearchBoxHandlers(
      instantSearchOptions.helper,
      this.searchBoxInput
    );
  }
}

The event handlers themselves do a few things:

  • allow for keyboard navigation through the suggestions list,
  • update the suggestion displayed inside the <input> element,
  • and close the suggestions list when clicking a suggestion.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class PredictiveSearchBox {
  // ...
  registerSearchBoxHandlers = (helper, searchBox) => {
    searchBox.addEventListener("keydown", this.onSearchBoxKeyDown);
    searchBox.addEventListener("input", event => {
      this.updateTabActionSuggestion(event);
      helper.setQuery(event.currentTarget.value).search();
    });

    searchBox.addEventListener("focus", event => {
      this.updateTabActionSuggestion(event);
      helper.setQuery(event.currentTarget.value).search();
    });

    document.addEventListener("click", event => {
      if (this.widgetContainer.contains(event.target)) return;
      this.closeSuggestionTags();
    });
  };
}

If a user presses Tab while a suggestion is active, the widget updates the value of <input>.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class PredictiveSearchBox {
  // ...
  onSearchBoxKeyDown = event => {
    // If there is no suggestion, jump to next element
    // If user presses tab once, highlight selection
    // If user presses tab twice, jump to next element
    // If input value = suggestion, jump to next element
    this.onKeyBoardNavigation(event);

    if (
      !this.tabActionSuggestion ||
      !event.currentTarget.value ||
      (!isKey(event, 9, "Tab") && !isKey(event, 39, "ArrowRight"))
    ) {
      this.previousSearchBoxEvent = null;
      return;
    }

    const isPressingTabTwice =
      this.previousSearchBoxEvent &&
      isKey(event, 9, "Tab") &&
      isKey(this.previousSearchBoxEvent, 9, "Tab");

    const isPressingArrowRightTwice =
      this.previousSearchBoxEvent &&
      isKey(event, 39, "ArrowRight") &&
      isKey(this.previousSearchBoxEvent, 39, "ArrowRight");

    // Store previous event so you can skip navigation later
    this.previousSearchBoxEvent = event;

    if (isPressingTabTwice || isPressingArrowRightTwice) return null;

    event.preventDefault();

    this.setPredictiveSearchBoxValue();
    this.setSearchBoxValue(this.tabActionSuggestion);
    this.closeSuggestionTags();
  };
}

The updateSuggestionTags function takes each suggestion and renders an element with associated click handlers. These handlers ensure that when clicking a suggestion, three things happen:

  • add the clicked suggestion to the RecentSearches object,
  • clear the <input> value,
  • close the suggestions list.

Also, add mouseenter and mouseleave listeners for accessibility purposes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class PredictiveSearchBox {
  // ...
  updateSuggestionTags = hits => {
    if (!this.maxSuggestions || this.maxSuggestions <= 0 || !hits.length) {
      return hits;
    }

    this.clearSuggestionTags();

    hits.slice(0, this.maxSuggestions).forEach(suggestion => {
      const suggestionElement = document.createElement("li");

      suggestionElement.setAttribute("role", "option");
      suggestionElement.setAttribute(
        "id",
        `suggestion-${sanitizeQuery(suggestion.query)}`
      );
      suggestionElement.dataset.query = suggestion.query;

      suggestionElement.classList.add("suggestion-tag");
      suggestionElement.innerHTML = suggestion.__recent__
        ? `<span><i class="fas fa-clock"></i>${suggestion.query}</span>`
        : `<span><i class="fas fa-search"></i>${suggestion._highlightResult.query.value}</span>`;

      suggestionElement.addEventListener("click", () => {
        this.RecentSearches.setRecentSearch(suggestion.query, suggestion);
        this.setPredictiveSearchBoxValue();
        this.closeSuggestionTags();

        this.searchBoxInput.value = suggestion.query;
        this.helper.setQuery(suggestion.query).search();
      });

      suggestionElement.addEventListener("mouseenter", event => {
        const currentSelectedElement = this.suggestionTagsContainer.querySelector(
          '[aria-selected="true"]'
        );

        if (currentSelectedElement) {
          currentSelectedElement.removeAttribute("aria-selected");
        }

        event.currentTarget.setAttribute("aria-selected", true);
      });

      suggestionElement.addEventListener("mouseleave", event => {
        event.currentTarget.removeAttribute("aria-selected");
      });

      this.suggestionTagsContainer.append(suggestionElement);
    });

    this.updateExpandedA11y(hits.length > 0);
  };
}

The updateTabActionSuggestion function displays a value in the <input> which makes it easy for a user to change the query value simply by pressing tab.

It first gets Query Suggestions that match the currently active query. Then, the user’s recent searches are fetched via recent-searches and combined with suggestions coming from the earlier specified Query Suggestions index.

The first suggestion in this combined set is assigned to the tabActionSuggestion variable.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class PredictiveSearchBox {
  // ...
  updateTabActionSuggestion = event => {
    const query = event.currentTarget.value;

    if (!query) {
      this.closeSuggestionTags();
      this.updateExpandedA11y(false);
      this.predictiveSearchBox.style.display = "none";
      return;
    }

    // If new query does not match prefix, reset the prediction
    if (
      this.tabActionSuggestion &&
      !this.tabActionSuggestion.startsWith(query)
    ) {
      this.setPredictiveSearchBoxValue();
    }

    this.querySuggestionsIndex
      .search({ query })
      .then(response => {
        this.suggestionTagsContainer.style.display = "";

        const recentSearches = this.RecentSearches.getRecentSearches(query)
          .slice(0, this.maxSavedSearchesPerQuery)
          .map(suggestion => ({ ...suggestion.data, __recent__: true }));

        const suggestions = filterUniques(
          recentSearches.concat(response.hits),
          query
        );

        if (!suggestions.length) {
          this.clearSuggestions();
          this.suggestionTagsContainer.innerHTML = this.noResultsRenderer(
            query,
            response
          );
          return [];
        }

        const prediction = suggestions[0].query;

        if (prediction.startsWith(query)) {
          this.predictiveSearchBox.style.display = "flex";
          this.setPredictiveSearchBoxValue(prediction);
          this.tabActionSuggestion = prediction;
        } else {
          this.setPredictiveSearchBoxValue();
        }
        return suggestions;
      })
      .then(this.updateSuggestionTags);
  };
}

Now you can include the RecentSearchesWidget in the application code:

1
2
3
4
5
6
7
8
9
10
search.addWidget(
  new RecentSearchesWidget({
    container: "#recent-searches",
    querySuggestionsIndex: "query-suggestions",
    placeholder: "Search with Query Suggestions",
    maxSavedSearchesPerQuery: 5,
    noResultsRenderer: (query, response) =>
      `<li class="no-results">No matching suggestion for <strong>${query}</strong></li>`
  })
);

Did you find this page helpful?