MAIS AMOR POR FAVOR

Highlight Search Results using Web APIs in Gatsby

Fast JS Search on Gatsby

Highlight Search Results using Web APIs in Gatsby

SHARE:

πŸ”₯ Highlight Search Results

We look at how to highlight search results using Web APIs. Although the example uses Gatsby, the React code can be used on Next JS and other popular React based frameworks. This post follows on from the recent post where we used fast JS Search to generate site search results. When we talk about site search we mean the user searching for content on your own site, from your own site. In this post we improve on the user experience, by highlighting search terms in the summaries displayed for results. On top when the user clicks one of the results, we highlight the search term (as well as related terms) on the actual result. One more thing… We scroll the first search match into view for the ultimate user experience.

Highlight Search Results: What we're Building - screen capture showing search results as a bos for each result.  The boxes contain summaries realted to the post they represent. The word camera, also int hesearch box is higlighted in the summaries.
Getting Started with SvelteKit: What we're Building Screen Capture

We will do most of that using semantic HTML and standard browser APIs as much as possible. So to highlight code we will use the HTML <mark> text element and for scrolling, Element.scrollIntoView(), from the browser Web API .

First we will improve the search results we generated last time by adding highlights. After that, we add search to the result itself. Finally we will scroll the first match into view automatically when the user opens a search result.

✨ Add Highlights to Search Results

If you want to follow along, it might be easier to start with the earlier post. Just fire up your dev server by following instructions in that post. No problem though if you are just looking to see what you can lift for your NextJS project.

First we will create a utility function for finding occurrences of a search term and stem equivalents in a block of text. By stem equivalents, we mean if the user searches for β€œfilms” as well as highlight that word, we also highlight the words β€œfilm”, β€œfilmed”, β€œfilming” and so on. For this we will use the same stemmer, we used in the earlier post. Create a file at src/utilities/search.js and add the following content:

src/utilities/search.js
javascript
1import { stemmer } from 'stemmer';
2
3const getWordArrayFromText = (text) => text.split(new RegExp(/[s!._,'@?]+/));
4
5// eslint-disable-next-line import/prefer-default-export
6export const getSimilarWords = (searchTerm, text) => {
7 let unsortedSimilarWords = [];
8 const words = getWordArrayFromText(text);
9 const lowerCaseWords = words.map((element) => element.toLowerCase());
10 getWordArrayFromText(searchTerm).forEach((element) => {
11 const lowerCaseElement = element.toLowerCase();
12 const searchTermStem = stemmer(lowerCaseElement);
13 const additionalWords = lowerCaseWords.filter((word) => stemmer(word) === searchTermStem);
14 if (additionalWords.length > 0) {
15 unsortedSimilarWords = unsortedSimilarWords.concat(additionalWords);
16 } else {
17 unsortedSimilarWords.push(element);
18 }
19 });
20
21 // remove duplicates
22 unsortedSimilarWords = [...new Set(unsortedSimilarWords)];
23
24 // sort similar words by length so that, for example, we find and highlight aardvarks before
25 // aardvark. If we did not do this, for the example, it is possible than only the letters up
26 // to the final 's' in aardvarks would be highlighted.
27 return unsortedSimilarWords.sort((a, b) => b.length - a.length);
28};

Here we convert the block of text into an array of words, using a regular expression, via a utility function in line 3. This makes the subsequent search easier. We convert all words to lower case so we can compare apples with apples, so to speak.

Search Component

You will probably remember, last time we rendered search results in the BlogPostSummary component. We want to highlight search results in that component. For that to work, we need to pass the search term from the Search component to the BlogPostSumary component. Update the Search component:

src/components/Search.jsx
jsx
177return (
178 <article aria-posinset={index} aria-setsize="-1" key={id}>
179 <BlogPostSummary
180 frontmatter={frontmatter}
181 searchTerm={searchQuery.toLowerCase()}
182 slug={value.slug.slice(1)}
183 />
184 </article>
185 );

It is important that we convert the search term to lower case as we won't check it is lower case in the following code.

BlogPostSummary Component

Next, we update the BlogPostSummary component to take the new searchTerm prop we are passing in:

src/components/BlogPostSummary.jsx
jsx
1import dayjs from 'dayjs';
2import { graphql, Link, navigate } from 'gatsby';
3import PropTypes from 'prop-types';
4import React, { useEffect, useRef, useState } from 'react';
5import { v4 as uuidv4 } from 'uuid';
6import { H_ELLIPSIS_ENTITY } from '../constants/entities';
7import { getSimilarWords } from '../utilities/search';
8import { container, content } from './BlogPostSummary.module.scss';
9
10const BlogPostSummary = ({
11 frontmatter: { datePublished, postTitle, seoMetaDescription },
12 searchTerm,
13 slug,
14}) => {

We have also imported our new utility function and added some extra imports which we will use later.

Just like we had to pass the search term from the Search component to the BlogPostSummary component, we need to pass it on to the actual post we will render if the user clicks the summary. This time we will use a different mechanism though. We will pass the search term as a search parameter in the page URL. This makes more sense here as the post is not a child component of the BlogPostSummary component (BlogPostSummary is rendered by the Search component). Anyway, we need to change the link we navigate to. Let's edit the BlogPostSummary component once more:

src/components/BlogPostSummary.jsx
jsx
15const containerNode = useRef();
16 const titleNode = useRef();
17
18 const [similarWords, setSimilarWords] = useState([]);
19
20 const postLink = () => `/${slug}?s=${searchTerm}`;
21
22 useEffect(() => {
23 if (containerNode.current) {
24 // deliberately set style with javascript and not CSS for accessibility reasons
25 containerNode.current.style.cursor = 'pointer';
26 }
27 const listener = (event) => {
28 if (containerNode.current && !titleNode.current.contains(event.target)) {
29 navigate(postLink());
30 }
31 };
32 containerNode.current.addEventListener('mousedown', listener);
33 return () => {
34 if (containerNode.current) {
35 containerNode.current.removeEventListener('mousedown', listener);
36 }
37 };
38 }, [containerNode, titleNode, searchTerm]);

Note we now need this useEffect block (lines 22–38) to run when searchTerm changes, so we include that variable in the array in line 38. The postLink function (line 20) tacks the search term on the end of the slug as a URL search parameter. We have added the similarWords state variable, which we will use next:

src/components/BlogPostSummary.jsx
jsx
40useEffect(() => {
41 setSimilarWords(getSimilarWords(searchTerm, seoMetaDescription));
42 }, [searchTerm]);

Here we are using the new utility function to tell us which words in the summary we will need to highlight. We put those words into an array state variable, similarWords. Naturally, when the search term changes (as the user types in the search box in the Search component), we need to update this array. For that reason, searchTerm is added in line 42.

Highlighting Search Terms and Stem Equivalents

We have set up all the background work needed to highlight the search term (and stem equivalents) in the component. Now let's add the code which does the actual highlighting:

src/components/BlogPostSummary.jsx
jsx
44const highlightSearchStemCommonWords = (text) => {
45 if (similarWords.length > 0) {
46 const parts = text.split(new RegExp(`(${similarWords.join('|')})`, 'gi'));
47 return (
48 <p>
49 {parts.map((part) => {
50 if (part !== '') {
51 if (similarWords.includes(part.toLowerCase())) {
52 return <mark key={uuidv4()}>{part}</mark>;
53 }
54 return <span key={uuidv4()}>{part}</span>;
55 }
56 return null;
57 })}
58 </p>
59 );
60 }
61 const parts = text.split(new RegExp(`(${searchTerm})`, 'gi'));
62 return (
63 <p>
64 {parts.map((part) => (
65 <span key={uuidv4()}>
66 {part.toLowerCase() === searchTerm.toLowerCase() ? <mark>{part}</mark> : part}
67 </span>
68 ))}
69 </p>
70 );
71 };
72
73 const formattedSeoMetaDescription = () => (
74 <p>{highlightSearchStemCommonWords(seoMetaDescription)}</p>
75 );

Let's see what we have done here. In line 46, we split the text at each occurrence of an element in the similarWords array. This generates an array of shorter strings, which (combined) reproduce the original input text. The block in lines 49–57 looks at each of these parts in turn. If the part is a similarWords element, we wrap it in a <mark> element. By default, the browser will highlight this text without us having to do anything else. The rest of the loop just glues everything else back together as it was. We have to add keys to the elements. There is a nice Twitter thread by React guru Dan Abramov on Why React needs keys . We use uuid to generate unique keys. Install this package to get the code to work:

npm i uuid

Finally, there are some cases where there will not be any stem equivalents generated (this might happen for very short search terms), even though, we want to highlight the search term. Lines 64–68 handle this case.

Next in line 72 we define a function that uses the new code above to highlight the meta description. We simply need to call this function to highlight the rendered content:

src/components/BlogPostSummary.jsx
jsx
40return (
41 <div className={container} ref={containerNode}>
42 <div className={content}>
43 <h3 ref={titleNode}>
44 <Link
45 aria-label={`Open ${postTitle} blog post`}
46 aria-describedby={idString}
47 to={postLink()}
48 >
49 {postTitle}
50 </Link>
51 </h3>
52 <p>{`${date.format('D')} ${date.format('MMM')}`}</p>
53 <p>{formattedSeoMetaDescription()}</p>
54 <span aria-hidden id={idString}>
55 Read more {H_ELLIPSIS_ENTITY}
56 </span>
57 </div>
58 </div>
59 );
60};
61
62BlogPostSummary.defaultProps = {
63 searchTerm: '',
64};

There's a couple more updates in here which should make sense without an explanation. That's the first part, and in fact most of the work complete. Next let's look at how we highlight the search term in the blog post.

πŸ–‹οΈ Add Highlights to Blog Post

Now we switch to the blog post template component. We want this component to look exactly the same as it did before, when there is no search term. We pass the search term in as a search parameter in the post URL. When a search parameter is included we will highlight it wherever it occurs in the text, as as well as stem equivalents. We need to run a new search for stem equivalents as we do not have access to the previous results, besides, there may be extra stem equivalents which occur in the full post, but not in the meta description.

Let's start by adding new imports:

src/components/PureBlogPost.jsx
jsx
1import { MDXProvider } from '@mdx-js/react';
2import { Link } from 'gatsby';
3import PropTypes from 'prop-types';
4import React, { useEffect, useState } from 'react';
5import { Helmet } from 'react-helmet';
6import { v4 as uuidv4 } from 'uuid';
7import { getSimilarWords } from '../utilities/search';
8import { isBrowser } from '../utilities/utilities';
9import BannerImage from './BannerImage';
10import { PureLayout as Layout } from './Layout';
11import { ExternalLink, TwitterMessageLink } from './Link';
12import { PureSEO as SEO } from './SEO';

Next we want to pull the search term from the browser URL, we did something similar in the Search component:

src/components/PureBlogPost.jsx
jsx
15const [similarWords, setSimilarWords] = useState([]);
16 const { frontmatter, rawBody, slug } = data.post;
17 const { bannerImage, featuredImageAlt, seoMetaDescription, postTitle } = frontmatter;
18 const { siteUrl } = data.site.siteMetadata;
19
20 let searchTerm = '';
21 if (isBrowser) {
22 const searchParam = new URLSearchParams(window.location.search.substring(1)).get('s');
23 if (searchParam !== null) {
24 searchTerm = searchParam;
25 }
26 }
27
28 useEffect(() => {
29 setSimilarWords(getSimilarWords(searchTerm, rawBody));
30 }, [searchTerm]);

As well as getting the search term, we added a similarWords state variable, just like in the BlogPostSumary component. On top we update this array when the search term updates. We are not expecting the user to change the search term in this component, but the search term may not be available on initial render so once it is updated, we need to update the similarWords array.

Highlight Search Term and Stem Equilvalents

Next we want to highlight the search term in our blog post. This is a little more complicated than before, as out blog post is in MDX and we use MDXRenderer to render it. Luckily, MDXRenderer accepts a wrapper. We will use that wrapper to highlight the code. Let's take a look at how this is done:

src/components/PureBlogPost.jsx
jsx
32const highlightWords = (childrenProp) => {
33 const result = [];
34 React.Children.forEach(childrenProp, (child) => {
35 if (child.props && child.props.children) {
36 const newChild = React.cloneElement(child, {
37 children: highlightWords(child.props.children),
38 key: uuidv4(),
39 });
40 result.push(newChild);
41 } else if (!child.props) {
42 const parts = child.split(new RegExp(`(${similarWords.join('|')})`, 'gi'));
43 const highlightedChild = parts.map((part) => {
44 if (part !== '') {
45 if (similarWords.includes(part.toLowerCase())) {
46 return (
47 <mark className="highlight" key={uuidv4()}>
48 {part}
49 </mark>
50 );
51 }
52 return part;
53 }
54 return null;
55 });
56 result.push(highlightedChild);
57 } else {
58 result.push(child);
59 }
60 });
61 return result;
62 };
63
64 const shortcodes = {
65 ExternalLink,
66 Link,
67 TwitterMessageLink,
68 wrapper:
69 searchTerm === '' ? null : ({ children: mdxChildren }) => <>{highlightWords(mdxChildren)}</>,
70 };

Starting at the bottom, we add the wrapper to the shortcodes, whenever we have a search term (line 69). We were already using the shortcodes in the rendered content so we do not need to change anything with the rendered content we return for our component. The manipulation of the DOM elements using React APIs, in the highlightWords function, is a little more involved. To keep focussed on the task at hand, we will not go into this. You can learn more about what we are doing here in an excellent post on React children on Max Stoibers blog .

Now if you search for a term which is in the text and open up the search result, our code will highlight it. Essentially this just comes from adding <mark> elements. We are almost done now. Last step is to scroll the first occurrence of a highlighted term into view. This is quite handy for users in a hurry, they won't need to scan through a potentially long post to find a match and work out if our post is what they were after.

🌟 Scroll First Match in to View

We have done all of the hard work already. All we need to do now is add one final useEffect block to assist with scroll into view. This will bring the first match to the top of the browser window:

src/components/PureBlogPost.jsx
jsx
72useEffect(() => {
73 const highlights = document.querySelectorAll('.highlight');
74 if (highlights[0]) {
75 highlights[0].scrollIntoView(true);
76 }
77 }, [searchTerm, shortcodes]);

Here we are using the standard web API. The code just finds the first highlight element (note we added this class to highlight terms in the previous step (line 47)).

πŸ’― Try itΒ Out

Highlight Search Results: Try it Out

Try going to the home page, clicking the search icon. Search for β€œoptical”. The word β€œoptics” should be highlighted in the second result returned. Although there is a match within the first post's text, the work does not appear in the rendered content in the summary, so there is nothing to highlight. Click the second result and β€œoptics” should be highlighted and at the top of the browser window. What do you think?

You can see the completed code in the Rodney Lab GitHub page . These is also a live demo site at gatsby-site-search.rodneylab.com .

πŸ™ŒπŸ½ That's all Folks!

In this post we learned:

  • how to use semantic HTML to highlight text,
  • some ways to improve user experience when returning search results,
  • how to use the scrollIntoView Web API in React.

I hope you found this useful. There are more extensions you can add. For example, when the user searches for a term which is in the text but not in the meta description, you could render the passage of text which contains the search term in the summary. Currently we render the meta description, which doesn't give the user much context in this case. On top you could add a drop down list of search terms to the search box. You can then add term selection using up and down arrow keys. Colby Fayock has an excellent video on doing this in NextJS . Finally you can style the mark elements to highlight text with a different colour to the browser default.

Keen to hear how you plan to use this! Is there something from this post you can leverage for a side project or even client project? I do hope so! Let me know if there is anything in the post that I can improve on, for anyone else creating this project. You can leave a comment below, @ me on twitter or try one of the other contact methods listed below.

πŸ™πŸ½ Highlight Search: Feedback

If you have found this post useful, even though you can only afford even a tiny contribution, please consider supporting me through Buy me a Coffee.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on Twitter and also askRodney on Telegram . Also, see further ways to get in touch with Rodney Lab. I post regularly on SvelteKit as well as Gatsby JS among other topics. Also subscribe to the newsletter to keep up-to-date with our latest projects.