Recent Searches
On this page
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 |
|
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 rendermaxSavedSearchesPerQuery
: the number of recent searches to considerquerySuggestionsIndex
: 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>`
})
);