Customize an Angular InstantSearch Widget
Highlight and snippet your search results
Search is all about helping users understand the results. This is especially true when using text-based search. When a user types a query in the search box, the results must show why the results are matching the query. That’s why Algolia implements a powerful highlight that lets you display the matching parts of text attributes in the results.
Highlighting is based on the results and you need to customize the hits widget to use the Highlighter. The highlight widget takes two props:
attribute
: the path to the highlighted attribute of the hit (which can be either a string or an array of strings)hit
: a single result object
Notes
- Use the highlight widget when you want to display the regular value of an attribute.
- Use the snippet widget when you want to display the shortened value of an attribute.
Here is an example which leverages the directive ng-template
of the Hit widget. In the results the name
field is highlighted. These examples use the mark
tag to highlight. This is a tag specially made for highlighting pieces of text.
1
2
3
4
5
6
7
<ais-hits>
<ng-template let-hits="hits">
<div *ngFor="let hit of hits">
<ais-highlight attribute="name" [hit]="hit"></ais-highlight>
</div>
</ng-template>
</ais-hits>
Style your widgets
All widgets are shipped with fixed CSS class names.
The format for those class names is ais-NameOfWidget-element--modifier
. Angular InstantSearch follows the naming convention defined by
SUIT CSS.
The different class names used by each widget are described on their respective documentation pages. You can also inspect the underlying DOM and style accordingly.
Loading the theme
CSS isn’t automatically loaded into your page but Algolia provides two themes that you can load manually:
reset.css
satellite.css
You should at least use reset.css to avoid visual side effects caused by browser style sheets.
The reset
theme CSS is included within the satellite
CSS, so there is no need to import it separately when you are using the satellite
theme.
Via CDN
The themes are available on jsDelivr:
-
Unminified:
-
Minified:
You can either copy paste the content into your own app or use a direct link to jsDelivr:
1
2
3
4
5
<!-- Include only the reset -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@8.5.1/themes/reset-min.css" integrity="sha256-KvFgFCzgqSErAPu6y9gz/AhZAvzK48VJASu3DpNLCEQ=" crossorigin="anonymous">
<!-- or include the full Satellite theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/instantsearch.css@8.5.1/themes/satellite-min.css" integrity="sha256-woeV7a4SRDsjDc395qjBJ4+ZhDdFn8AqswN1rlTO64E=" crossorigin="anonymous">
Using Yarn (npm) and Angular command-line interface
instantsearch.css
has been installed as part of Angular InstantSearch dependencies. To load the theme, add it to the apps > styles
array of your angular.json
configuration file:
1
2
3
4
5
6
{
"styles": [
"node_modules/instantsearch.css/themes/satellite.css",
"styles.css"
]
}
Or only the reset:
1
2
3
4
5
6
{
"styles": [
"node_modules/instantsearch.css/themes/reset.css",
"styles.css"
]
}
Translate your widgets
Angular InstantSearch doesn’t have a dedicated API for translating text, but every component exposes attributes to override. For example, in refinement-list:
1
2
3
4
5
6
7
<ais-refinement-list
attribute="brand"
operator="or"
showMoreLabel="More please"
showLessLabel="Less please"
>
</ais-refinement-list>
Templating
Some components of Angular InstantSearch support templating via ng-template directive. For example in hits:
1
2
3
4
5
<ais-stats>
<ng-template let-state="state">
{{state.nbHits}} results found in {{state.processingTimeMS}}ms.
</ng-template>
</ais-stats>
Modify the list of items in widgets
Angular InstantSearch provides two APIs for manipulating lists:
sortBy
, which is the most straightforward API and obviously limited to sortingtransformItems
, which is the most flexible solution but it requires more involvement on your side
The transformItems
prop is a function that takes the whole list of items as a parameter and expects to receive in return another array of items. Most of the examples in this guide will use this API.
Sorting
Using sortBy
1
2
3
4
5
6
<ais-refinement-list
attribute="brand"
operator="or"
[sortBy]="['isRefined', 'name:asc']"
>
</ais-refinement-list>
Using transformItems
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component({
template: `
<ais-refinement-list
// ...
[transformItems]="transformItems"
></ais-refinement-list>
`,
})
export class AppComponent {
transformItems(items) {
return items.sort((a,b) => a.value.localeCompare(b.value))
}
}
Filtering
This example filters out items when the count is lower than 150:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component({
template: `
<ais-refinement-list
// ...
[transformItems]="transformItems"
></ais-refinement-list>
`,
})
export class AppComponent {
transformItems(items) {
return items.filter(item => item.count >= 150)
}
}
Add manual values
By default, the values in a refinement-list or a menu are dynamic. This means that the values are updated with the context of the search. Most of the time this is the expected behavior, but sometimes you may want to have a static list of values that never change.
This example uses a static list of values:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component({
template: `
<ais-refinement-list
// ...
[transformItems]="getStaticValues"
></ais-refinement-list>
`,
})
export class AppComponent {
getStaticValues(items) {
const staticValues = ['Cell Phones', 'Unlocked Cell Phones'];
return staticValues.map(value => {
const item = items.find(item => item.label === value);
return item || {
label: value,
value,
count: 0,
isRefined: false,
highlighted: value,
};
});
}
}
Display facets with no matches
Hiding facets when they don’t match a query can be counter-intuitive. However, because of the way Algolia handles faceting, you have to rely on workarounds on the frontend to display facets when they have no hits.
One way of displaying facets with no matches is by caching the results the first time you receive them. Then, if the amount of real facet hits that Algolia returns is below the limit set, you can append the cached facets to the list.
This solution comes with limitations:
- Facet hits coming from a facet search (“Search for facet values”) can’t work because Algolia doesn’t return facets that don’t match (the highlighting won’t work on cached items).
- You might need to sort items again in the
transformItems
function because the internal sorting happens before this function is called.
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
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
function uniqBy(items, property) {
const seen = {};
return items.filter(item => {
const val = item[property];
if (seen[val]) {
return false;
} else {
seen[val] = true;
return true;
}
});
}
@Component({
template: `
<ais-refinement-list
[attribute]="brandAttribute"
[transformItems]="transformItems"
></ais-refinement-list>
`,
})
export class AppComponent {
public indexName = 'instant_search';
public brandAttribute = 'brand';
public brandLimit = 10;
public initialFacets = [];
config = {
indexName: this.indexName,
searchClient,
};
ngOnInit() {
searchClient
.searchForFacetValues([
{
indexName: this.indexName,
params: {
facetName: this.brandAttribute,
facetQuery: '',
maxFacetHits: this.brandLimit,
},
},
])
.then(([{ facetHits }]) => {
this.initialFacets.push(
...facetHits.map(facet => ({
...facet,
label: facet.value,
value: facet.value,
isRefined: false,
count: 0,
}))
);
});
}
transformItems = items => {
// If Algolia doesn't return enough results, we lose track of a
// potentially refined facet.
// For example, if you refine on "Apple", then search for "chromecast",
// "Apple" is no longer returned, and we don't know that it was selected
// based on the initial facets.
// We need to keep track of the last state to reflect the fact that it
// was refined in the UI.
this.initialFacets.forEach((facet, index) => {
const updatedItem = items.find(item => item.value === facet.value);
if (updatedItem) {
this.initialFacets[index] = {
...updatedItem,
count: 0,
};
}
});
// If a cached facet is already returned by Algolia, we want it to be
// displayed rather than to display its cached value.
// You might need to sort the items again here because the internal
// sorting happens before `transformItems` is called.
return uniqBy([...items, ...this.initialFacets], 'value').slice(
0,
this.brandLimit
);
};
}
Searching long lists
Use the searchable
prop to add a search box to supported widgets:
1
2
3
4
<ais-refinement-list
attribute="categories"
[searchable]="true"
/>
Apply default value to widgets
A question that comes up frequently is “How to instantiate a refinement-list widget with a pre-selected item?”. For this use case, you can use the configure widget.
The following example instantiates a search page with a default query of “apple” and will show a category menu where the item “Cell Phones” is already selected:
1
2
3
4
5
6
7
8
9
10
11
<ais-instantsearch [config]="...">
<ais-configure [searchParameters]="{
query: 'apple',
disjunctiveFacetsRefinements: {
categories: ['Cell Phones'],
},
}"></ais-configure>
<ais-refinement-list [attribute]="categories"></ais-refinement-list>
<ais-search-box></ais-search-box>
<ais-hits></ais-hits>
</ais-instantsearch>
How to provide search parameters
Algolia has a wide range of parameters. If one of the parameters you want to use isn’t covered by any widget, then you should use the configure widget.
Here’s an example configuring the number of results per page:
1
2
3
4
<ais-instantsearch [config]="...">
<ais-configure [searchParameters]="{ hitsPerPage: 3 }"></ais-configure>
<ais-hits></ais-hits>
</ais-instantsearch>
Dynamic update of search parameters
Updating the props of the configure widget will dynamically change the search parameters and trigger a new search.
Filter your results without widgets
Widgets already provide a lot of different ways to filter your results but sometimes you might have more complicated use cases that require the usage of the filters
search parameter.
Don’t use filters on a attribute already used with a widget, it will conflict.
1
<ais-configure [searchParameters]="{ filters: 'NOT categories:\'Cell Phones\'' }"></ais-configure>
Customize the complete UI of the widgets
Extending Angular InstantSearch widgets is the second layer of the API. Read about the two others possibilities in the What’s InstantSearch? guide.
When to extend widgets?
‘Extending widgets’ means being able to redefine the rendering output of an existing widget. Say you want to render the Menu widget as an HTML select
element. To do this you need to extend the Menu widget.
Here are some common examples that require the usage of the connectors API:
- When you want to display the widgets using another UI library
- When you want to have full control on the rendering without having to re-implement business logic
- As soon as you hit a feature wall using the default widgets
How widgets are built
Angular InstantSearch widgets have two parts:
- Business logic code
- Rendering code
The business logic is called connectors
. Those connectors are provided by InstantSearch.js and their interfaces are exposed through the BaseWidget class.
Connectors render API
The aim is to share as much of a common API between all connectors, so that once you know how to use one connector, you can use them all.
TypedBaseWidget
class
The TypedBaseWidget
class helps you create new widgets using the InstantSearch.js connectors with Angular.
It encapsulates the logic to maintain the state of the widget in sync with the search and handles the initialization and the disposal of the widget on Angular lifecycle hooks. Some connectors will have more data than others. Read their API reference to know more. Connectors for every widget are documented in the API reference, for example the menu widget.
This example creates a new custom widget using the TypedBaseWidget
class and the connectMenu
InstantSearch.js connector.
The default menu widget renders a list of links, but you would like to render it as a <select>
element instead.
Extend the TypedBaseWidget
class
First of all, you will need to write some boilerplate code.
Create an MenuSelect
Angular component that:
- Extends the
TypedBaseWidget
class - References the
<ais-instantsearch>
parent component instance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { Component, Inject, forwardRef, Optional } from '@angular/core';
import { TypedBaseWidget, NgAisInstantSearch, NgAisIndex } from 'angular-instantsearch';
@Component({
selector: 'my-menu-select',
template: '<p>It works!</p>'
})
export class MenuSelect extends TypedBaseWidget<any, any> {
constructor(
@Inject(forwardRef(() => NgAisIndex))
@Optional()
public parentIndex: NgAisIndex,
@Inject(forwardRef(() => NgAisInstantSearch))
public instantSearchInstance: NgAisInstantSearch
) {
super('MenuSelect');
}
}
Inject the connectMenu
connector
The TypedBaseWidget
class has a createWidget()
method with two parameters:
- The connector to use
- An object containing the options to use for this connector.
In this case, you will use the connectMenu
connector which accepts multiple options. For simplicity you will only use the attributeName
option which is the only mandatory parameter. (The attributeName
is the name of the attribute for faceting).
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
import { Component, Inject, forwardRef, Optional } from '@angular/core';
import { TypedBaseWidget, NgAisInstantSearch, NgAisIndex } from 'angular-instantsearch';
import connectMenu, {
MenuConnectorParams,
MenuWidgetDescription,
} from 'instantsearch.js/es/connectors/menu/connectMenu';
@Component({
selector: 'my-menu-select',
template: '<p>It works!</p>'
})
export class MenuSelect extends TypedBaseWidget<MenuWidgetDescription, MenuConnectorParams> {
constructor(
@Inject(forwardRef(() => NgAisIndex))
@Optional()
public parentIndex: NgAisIndex,
@Inject(forwardRef(() => NgAisInstantSearch))
public instantSearchInstance: NgAisInstantSearch
) {
super('MenuSelect');
}
public ngOnInit() {
this.createWidget(connectMenu, { attribute: 'categories' });
super.ngOnInit();
}
}
That’s it, your widget is connected to InstantSearch.js and the state of the search itself.
Render from the state
Your component instance has access to a property this.state
which holds the rendering data of the widget.
Most connectors expose into the state the same properties:
items[]
: array of items to display, for example the brands list of a custom Refinement List. Every extended widget displaying a list gets an items property to the data passed to its render function.refine(value|item.value)
: will refine the current state of the widget. Examples include: updating the query for a custom SearchBox or selecting a new item in a custom RefinementList.canRefine
: whether there are refinements to apply.item.isRefined
: whether a particular refinement is currently applied.createURL(value|item.value)
: will return a full URL you can display for the specific refine value given you are using the routing feature.
The menu connector exposes a couple of properties more:
isShowingMore
: equalstrue
if the menu is displaying all the menu itemstoggleShowMore
: toggles the number of values displayed betweenlimit
andshowMoreLimit
canToggleShowMore
: equalstrue
if thetoggleShowMore
button can be activated
If you build your Angular app with the ahead-of-time (AOT) compiler you will need to define the types of the state
class property. In this example it will look like this:
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
import { Component, Inject, forwardRef, Optional } from '@angular/core';
import { TypedBaseWidget, NgAisInstantSearch, NgAisIndex } from 'angular-instantsearch';
import connectMenu, {
MenuConnectorParams,
MenuWidgetDescription,
} from 'instantsearch.js/es/connectors/menu/connectMenu';
@Component({
selector: 'my-menu-select',
template: '<p>It works!</p>'
})
export class MenuSelect extends TypedBaseWidget<MenuWidgetDescription, MenuConnectorParams> {
constructor(
@Inject(forwardRef(() => NgAisIndex))
@Optional()
public parentIndex: NgAisIndex,
@Inject(forwardRef(() => NgAisInstantSearch))
public instantSearchInstance: NgAisInstantSearch
) {
super('MenuSelect');
}
+ public state: MenuWidgetDescription['renderState'] = {
items: [],
refine: () => {},
createURL: () => '#',
canRefine: false,
isShowingMore: false,
canToggleShowMore: false,
toggleShowMore: () => {},
sendEvent: () => {},
};
public ngOnInit() {
this.createWidget(connectMenu, { attribute: 'categories' });
super.ngOnInit();
}
}
Write the template
The last step is to write the component template:
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
import { Component, Inject, forwardRef, Optional } from '@angular/core';
import { TypedBaseWidget, NgAisInstantSearch, NgAisIndex } from 'angular-instantsearch';
import connectMenu, {
MenuConnectorParams,
MenuWidgetDescription
} from 'instantsearch.js/es/connectors/menu/connectMenu';
@Component({
selector: 'my-menu-select',
template: `
<select
class="menu-select"
(change)="state.refine($event.target.value)"
>
<option
*ngFor="let item of state.items"
[value]="item.value"
[selected]="item.isRefined"
>
{{item.label}}
</option>
</select>
`
})
export class MenuSelect extends TypedBaseWidget<MenuWidgetDescription, MenuConnectorParams> {
constructor(
@Inject(forwardRef(() => NgAisIndex))
@Optional()
public parentIndex: NgAisIndex,
@Inject(forwardRef(() => NgAisInstantSearch))
public instantSearchInstance: NgAisInstantSearch
) {
super('MenuSelect');
}
public state: MenuWidgetDescription['renderState'] = {
items: [],
refine: () => {},
createURL: () => '#',
canRefine: false,
isShowingMore: false,
canToggleShowMore: false,
toggleShowMore: () => {},
sendEvent: () => {},
};
public ngOnInit() {
this.createWidget(connectMenu, { attribute: 'categories' });
super.ngOnInit();
}
}
Now you have a fully working example of a Menu widget rendered as a select
HTML element.
Head over to the Discord server if you still have questions about extending widgets.
Complete source code
The complete source code for this customization is on GitHub.