Below is a diagram of files in this project relevant to the search functionality:
app
│--App.js
│--constants.js
│
└───context
│ │--archiveContext.js
│
└───components
│ --searchBar.js
│ --cardList.js
We fetch the resources and glossary terms from an Airtable API. App.js
is wrapped in an ArchiveProvider
defined in app/context/archiveContext.js
so that this data can be globally available (and to avoid superfluous prop drilling). ArchiveContext
accepts the following as value props to pass on to the searchBar component and also to render search matches in /components/cardList.js
:
searchTerm
setSearchTerm
searchResults
setSearchResults
glossary
resources
The search magic on shouldiaskforgender.com happens in /components/searchBar.js.
Based on the current location, we add resources or glossary terms to state along with keys to define which nested fields should be searched. Asynchronous handleSearch
updates as a user types in the input and creates a Fuse constructor with our searchable terms and search options.
import React, { useState, useEffect, useContext, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import Fuse from 'fuse.js';
import { ArchiveContext } from '../context/archiveContext';
import { searchOptions } from '../constants';
const searchBar = () => {
const { glossary, resources, setSearchResults, searchTerm, setSearchTerm } = useContext(
ArchiveContext
);
const location = useLocation();
const path = location.pathname.split('/')[1];
const [searchable, setSearchable] = useState([]);
const [options, setOptions] = useState({});
const inputRef = useRef(null);
// Add resources to state, along with fuse options.
useEffect(() => {
if (resources && path === 'resources') {
searchOptions.keys = ['source_author', 'summary', 'title'];
setOptions(searchOptions);
setSearchable(resources);
}
}, [resources, path]);
// Add glossary to state, along with fuse options.
useEffect(() => {
if (glossary && path === 'glossary') {
searchOptions.keys = ['definition', 'term'];
setOptions(searchOptions);
setSearchable(glossary);
}
}, [glossary, path]);
const handleSearch = async event => {
setSearchTerm(event.target.value);
if (searchable.length > 0) {
const fuse = new Fuse(searchable, options);
const foundResults = await fuse.search(searchTerm);
setSearchResults(foundResults);
}
};
const showButton = () => {
const isFocused = inputRef.current?.matches(':focus');
const isHovered = inputRef.current?.matches(':hover');
return searchTerm.length > 0 && (isFocused || isHovered);
};
const clearSearchTerm = () => {
setSearchTerm('');
};
return (
<div>
<div>
<input
type="search"
placeholder="Search..."
value={searchTerm}
onChange={handleSearch}
ref={inputRef}
aria-label="Enter search term."
/>
{showButton() && (
<button onClick={clearSearchTerm}>
<span className="sr-only">Clear Search</span>
</button>
)}
</div>
{searchResults.length === 0 && searchTerm.length > 1 && (
<p>
<em>No results found</em>
<br />
<a href="mailto:info@savaslabs.com">Email us</a> to add a term.
</p>
)}
</div>
);
};
export default searchBar;
As soon as search results are set, they are available in /components/cardList.js
via archiveContext
. There, we render the matches if they are present, otherwise, we render the full list of resources or glossary terms with a no results found message.
To highlight individual search terms in the results, we'd recommend React Highlighter. We used the searchTerm
prop passed via ArchiveContext
as the search prop required by react-highlighter
on each Card
that CardList
renders. To see our implementation of this and more, check out the Should I Ask For Gender repo for the full app build.