UI Libraries / Autocomplete / Filtering Results with Tags

Filtering Results with Tags

As with any search experience, users who know what they’re looking for might want to drill down and access a smaller, more manageable set of data based on meaningful categories. Instead of redirecting to a search page, advanced autocomplete experiences let you refine results further without breaking the search flow.

In an autocomplete, users expect a flawless keyboard navigation experience, including with filters. Autocomplete provides the autocomplete-plugin-tags plugin to let you manage tags in your autocomplete. While there are many uses cases for tags, they’re particularly convenient to represent and apply refinements.

Getting started

First, begin with some boilerplate for the autocomplete implementation. Create a file called index.js in your src directory, and add the code below:

1
2
3
4
5
6
7
8
9
import { autocomplete } from '@algolia/autocomplete-js';

import '@algolia/autocomplete-theme-classic';

autocomplete({
  container: '#autocomplete',
  openOnFocus: true,
  plugins: [],
});

This boilerplate assumes you want to insert the autocomplete into a DOM element with autocomplete as an id. You should change the container to match your markup. Setting openOnFocus to true ensures that the dropdown appears as soon as a user focuses the input.

For now, plugins is an empty array, but you’ll learn how to add the Tags plugin next.

Setting up filters sources

The autocomplete-plugin-tags package provides the createTagsPlugin function to create a Tags plugin out-of-the-box.

You can first create a source in your autocomplete to display available filters. To do so, you can leverage Algolia’s faceting feature and use the getAlgoliaFacets function to retrieve available facet values for a given attribute.

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
import algoliasearch from 'algoliasearch/lite';
import { autocomplete, getAlgoliaFacets } from '@algolia/autocomplete-js';

import '@algolia/autocomplete-theme-classic';

const searchClient = algoliasearch(
  'latency',
  '6be0576ff61c053d5f9a3225e2a90f76'
);

autocomplete({
  // ...
  getSources() {
    return [
      {
        sourceId: 'brands',
        getItems({ query }) {
          return getAlgoliaFacets({
            searchClient,
            queries: [
              {
                indexName: 'instant_search',
                facet: 'brand',
                params: {
                  facetQuery: query,
                  maxFacetHits: 5,
                },
              },
            ],
            transformResponse({ facetHits }) {
              return facetHits[0].map((hit) => ({ ...hit, facet: 'brand' }));
            },
          });
        },
        templates: {
          item({ item, components }) {
            return (
              <div className="aa-ItemWrapper">
                <div className="aa-ItemContent">
                  <div className="aa-ItemContentBody">
                    <div className="aa-ItemContentTitle">
                      <components.Highlight hit={item} attribute="label" />
                    </div>
                  </div>
                </div>
                <div className="aa-ItemActions">
                  <button
                    className="aa-ItemActionButton aa-DesktopOnly aa-ActiveOnly"
                    type="button"
                    title="Filter"
                  >
                    <svg fill="none" viewBox="0 0 24 24" stroke="currentColor">
                      <path
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        strokeWidth={2}
                        d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
                      />
                    </svg>
                  </button>
                </div>
              </div>
            );
          },
        },
      },
    ];
  },
});

In this example, you’re displaying up to five facet values for the brand attribute. By using getAlgoliaFacets, you’re also letting users search within these facet values to find meaningful filters.

For now, nothing happens when selecting an item for this source. In the next step, you’ll bind it to the Tags plugin to apply tags on select.

Adding tags

When selecting a filter from a source, you want to display them so that the user knows what’s impacting their search. The Tags plugin renders applied tags as a source, letting users navigate through them with the keyboard and remove them on select.

To automatically add a tag when selecting an item from your filters source, you can bind it to the plugin by sourceId.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ...
import { createTagsPlugin } from '@algolia/autocomplete-plugin-tags';

import '@algolia/autocomplete-plugin-tags/dist/theme.min.css';

// ...

const tagsPlugin = createTagsPlugin({
  getTagsSubscribers() {
    return [
      {
        sourceId: 'brands',
        getTag({ item }) {
          return item;
        },
      },
    ];
  },
});

autocomplete({
  // ...
  plugins: [tagsPlugin],
});

Whenever you select an item from source with the “brands” sourceId, the plugin adds it as a tag and displays it as a source.

You can customize the default rendering for the tags source, or not use a source and render tags where and how you want. Check the API reference for more information.

Applying filters from tags

Once you’ve set tags in your autocomplete, you can use them to filter results. For example, imagine you’ve set the following tags.

1
2
3
4
5
const tags = [
  { label: 'Apple', facet: 'brand' },
  { label: 'Samsung', facet: 'brand' },
  { label: 'Cell Phones', facet: 'categories' },
];

In this case, you might want to filter your Algolia results to only show Apple and Samsung phones.

You can map tags into Algolia filters, and pass them to getAlgoliaResults.

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
// ...

function mapToAlgoliaFilters(tagsByFacet, operator = 'AND') {
  return Object.keys(tagsByFacet)
    .map((facet) => {
      return `(${tagsByFacet[facet]
        .map(({ label }) => `${facet}:"${label}"`)
        .join(' OR ')})`;
    })
    .join(` ${operator} `);
}

function groupBy(items, predicate) {
  return items.reduce((acc, item) => {
    const key = predicate(item);

    if (!acc.hasOwnProperty(key)) {
      acc[key] = [];
    }

    acc[key].push(item);

    return acc;
  }, {});
}

autocomplete({
  // ...
  getSources({ query, state }) {
    const tagsByFacet = groupBy(
      state.context.tagsPlugin.tags,
      (tag) => tag.facet
    );

    return [
      // ...
      {
        sourceId: 'products',
        getItems() {
          return getAlgoliaResults({
            searchClient,
            queries: [
              {
                indexName: 'instant_search',
                query,
                params: {
                  filters: mapToAlgoliaFilters(tagsByFacet),
                  hitsPerPage: 5,
                },
              },
            ],
          });
        },
        // ...
      },
    ];
  },
});

In this example, tags are derived into a conjunction (ANDs) of disjunctions (ORs), and passed on to the filters search parameter. The tags would result in the following filter:

1
(categories:Phone) AND (brands:Apple OR brands:Samsung)

Excluding already applied tags from sources

If you’re displaying your filters as Autocomplete sources using getAlgoliaFacets, you might want to exclude already applied tags from the list. This way, users can discover more possible filters.

To do so, you can derive negative filters from your tags.

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
// ...

function mapToAlgoliaNegativeFilters(tags, facetsToNegate, operator = 'AND') {
  return tags
    .map(({ label, facet }) => {
      const filter = `${facet}:"${label}"`

      return facetsToNegate.includes(facet) && `NOT ${filter}`
    })
    .filter(Boolean)
    .join(` ${operator} `)
}

autocomplete({
  // ...
  getSources({ query, state }) {
    // ...

    return [
      {
        sourceId: 'brands',
        getItems() {
          return getAlgoliaFacets({
            searchClient,
            queries: [
              {
                indexName: 'instant_search',
                facet: 'brand',
                params: {
                  facetQuery: query,
                  maxFacetHits: 3,
                  filters: mapToAlgoliaNegativeFilters(
                    state.context.tagsPlugin.tags,
                    ['brand']
                  ),
                },
              },
            ],
            transformResponse({ facetHits }) {
              return facetHits[0].map((hit) => ({
                ...hit,
                facet: 'brand',
              }));
            },
          });
        },
        // ...
      },
      // ...
    ];
  },
});

The filters search parameter works for most cases, but you can adjust the logic to generate facetFilters, numericFilters, tagFilters or optionalFilters.

Removing applied tag from the query

Using getAlgoliaFacets to populate your filters list lets you search within filters as well as products. For example, if you’re looking for a specific brand, you can start typing it out, then select it from the refined filters.

When you apply a tag you’ve found after typing, you might want to delete it from the query. For example, if a user types “app” then selects “Apple”, you likely want to remove “app” from the query. For users, it feels like the autocomplete understands their intent, and it lets them type the rest of the query without having to clear it first.

To do so, you can use onSelect to clear the query after applying a tag.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ...

autocomplete({
  // ...
  getSources({ query, state }) {
    // ...

    return [
      {
        sourceId: 'brands',
        onSelect({ item, setQuery }) {
          if (item.label.toLowerCase().includes(query.toLowerCase())) {
            setQuery('')
          }
        },
        // ...
      },
      // ...
    ];
  },
});

Filtering from external refinements

A great way to help users make more meaningful searches is to contextualize the autocomplete behavior based on intent.

  • In an ecommerce site, a user searching from the “Video games” section might expect different results than if they were on the home page.
  • In a dashboard application, you might want to display different results on an empty query for users belonging to the “Billing” group than those in the “Technical” one.

The Tags plugin lets you apply tags from external state by passing initial tags and manually updating them.

Passing initial tags

When starting your Autocomplete instance with the Tags plugin, you can pass initial filters based on external state. For example, you could parse the current URL to retrieve the active category and turn it into a tag.

1
2
3
4
5
6
7
8
9
10
11
// Current URL: https://example.org/?categories=Video games

const parameters = Array.from(new URLSearchParams(location.search));
const initialTags = parameters.map(([facet, label]) => ({ label, facet })); // [{ label: 'Video games', facet: 'categories' }]

const tagsPlugin = createTagsPlugin({ initialTags });

autocomplete({
  // ...
  plugins: [tagsPlugin],
});

You can then apply filters based on these tags. Note that in such a situation, since the applied tags don’t come from an explicit user action, you may want to split your results in two sources: first the ones in the current category, then all results in other categories.

This lets users find other results while acknowledging context.

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
// ...

autocomplete({
  // ...
  getSources({ query, state }) {
    const tagsByFacet = groupBy(
      state.context.tagsPlugin.tags,
      (tag) => tag.facet
    );

    return [
      {
        sourceId: 'productsInCurrentCategory',
        getItems() {
          return getAlgoliaResults({
            searchClient,
            queries: [
              {
                indexName: 'instant_search',
                query,
                params: {
                  filters: mapToAlgoliaFilters(tagsByFacet),
                  hitsPerPage: 5,
                },
              },
            ],
          });
        },
        templates: {
          header() {
            return (
              <Fragment>
                <span className="aa-SourceHeaderTitle">
                  In {tagsByFacet.categories[0].label} category
                </span>
                <div className="aa-SourceHeaderLine" />
              </Fragment>
            );
          },
          // ...
        },
        // ...
      },
      {
        sourceId: 'productsInOtherCategories',
        getItems() {
          return getAlgoliaResults({
            searchClient,
            queries: [
              {
                indexName: 'instant_search',
                query,
                params: {
                  filters: mapToAlgoliaNegativeFilters(
                    state.context.tagsPlugin.tags,
                    ['categories']
                  ),
                  hitsPerPage: 5,
                },
              },
            ],
          });
        },
        templates: {
          header() {
            return (
              <Fragment>
                <span className="aa-SourceHeaderTitle">
                  In other categories
                </span>
                <div className="aa-SourceHeaderLine" />
              </Fragment>
            );
          },
          // ...
        },
        // ...
      },
    ];
  },
});

Updating tags manually

If you’re using a single-page application with client-side routing, or you’re deriving tags from a local, dynamic state, you might need to update tags manually after the instance has started. A typical use case is when using Autocomplete along with InstantSearch. When applying new refinements with a refinementList, you might want to update the list of tags in your autocomplete to reflect the current refinements.

You can imperatively update the list of tags outside the Autocomplete instance using the exposed API on the plugin.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const tagsPlugin = createTagsPlugin({
  // ...
});

const search = instantsearch({
  // ...
  onStateChange({ uiState }) {
    const refinements = uiState.refinementList;
    const tags = Object.keys(refinements)
      .flatMap((key) =>
        refinements[key].map((refinement) => ({
          label: refinement,
          facet: key,
        }))
      );

    tagsPlugin.data.setTags(tags);
  },
});

By default, the Tags plugin displays tags as a source. This makes it straightforward to navigate through applied filters with the keyboard, as you would with any source.

Yet, a popular pattern is to display tags in the search box, near the input. It feels natural to users, especially when used in conjunction to a filters source using getAlgoliaFacets. As they type, the filters source suggests facets that can apply without leaving the keyboard. Applied tags display near the search input, making them more convenient to remove using the backspace key. This creates a more seamless experience where tags feel like they’re part of the query.

First, you need to keep the plugin from rendering tags as a source. You can do so using the transformSource option.

1
2
3
4
5
6
const tagsPlugin = createTagsPlugin({
  // ...
  transformSource() {
    return undefined;
  },
});

Then, you can display tags in the search box using the DOM API. In autocomplete-js, the search box exposes a .aa-InputWrapperPrefix element before the search input where you can inject tags. The plugin lets you perform custom logic with the onChange option, which you can leverage to update the rendered tags.

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
// ...
import { render } from 'preact';

const tagsPlugin = createTagsPlugin({
  // ...
  onChange({ tags, setIsOpen }) {
    requestAnimationFrame(() => {
      const container = document.querySelector('.aa-InputWrapperPrefix');
      const oldTagsContainer = document.querySelector('.aa-Tags');

      const tagsContainer = document.createElement('div');
      tagsContainer.classList.add('aa-Tags');

      render(
        <div className="aa-TagsList">
          {tags.map((tag) => (
            <TagItem
              key={label}
              label={label}
              onRemove={() => {
                remove()
                requestAnimationFrame(() => setIsOpen(true))
              }}
            />
          ))}
        </div>,
        tagsContainer
      );

      if (oldTagsContainer) {
        container.removeChild(oldTagsContainer);
      }

      container.appendChild(tagsContainer);
    });
  },
});

function TagItem({ label, onRemove }) {
  return (
    <div className="aa-Tag">
      <span className="aa-TagLabel">{label}</span>
      <button
        className="aa-TagRemoveButton"
        onClick={() => onRemove()}
        title="Remove this tag"
      >
        <svg
          fill="none"
          stroke="currentColor"
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeWidth={2}
          viewBox="0 0 24 24"
        >
          <path d="M18 6L6 18"></path>
          <path d="M6 6L18 18"></path>
        </svg>
      </button>
    </div>
  )
}

This example uses Preact’s render function with JSX to simplify injecting HTML and attaching event listeners. You can use HTML template strings along with Element.innerHTML if you don’t use JSX.

When users press the backspace key, you can remove the last tag of the list. This feels as if they were removing them from the query.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ...

const searchInput = document.querySelector('.aa-Input');

searchInput.addEventListener('keydown', (event) => {
  if (
    event.key === 'Backspace' &&
    searchInput.selectionStart === 0 &&
    searchInput.selectionEnd === 0
  ) {
    const newTags = tagsPlugin.data.tags.slice(0, -1);
    tagsPlugin.data.setTags(newTags);
  }
});

This solution doesn’t work in Detached mode. You can turn it off manually.

Next steps

Autocomplete Tags are convenient to represent refinements and derive filters, but you can use them for a wide variety of use cases. For example, you can leverage them to represent navigation steps when using your autocomplete to browse nested hierarchies, or indicating a state or mode that makes your autocomplete behave differently.

Did you find this page helpful?