Next.js – Custom Search/Autocomplete With Material UI – Arrow Keys and Clear Search

Table of Contents
Overview
In this article, we will build on top of the last article on how to create a custom autocomplete search field using React and Material UI. By the end of this tutorial, you’ll have the knowledge and skills to build a autocomplete with multiple results with the one list that can be traversed using the arrow keys and selected by hitting Enter. So, let’s dive in and explore the advantages of the additional feature and how to implement them in React using Material UI!
For a simple example on how to build the foundation of this autocomplete please look at the previous article here.
List of features for this example:
- Filter results from two lists
- Posts
- Photos
- List common categories
- Up and Down arrow keys to traverse Posts
- Enter key to choose single Post.
- Closes search and shows dialog box of selected Post
- DIsplay the title of the Post in the search bar
- Mouse click on filtered Posts or Photos will close the search and display a dialog box of selected record.
- If there are more than 1 letter in the search field then show times icon to clear the field in the right side of the search.
- Once the field is clear, programatically put the focus in the search field with useRef() hook.
- Open search filter results section if any key is pressed while cursor is inside the search field.
- Highlight matching search term string in filtered Posts titles with react-highlight-word library.
Base MUI AppBar Search Example
Using the React Material UI library, create an App Bar with a search field that show results as you type. The search bar is available at Material UI Website.
Data For This Example
Data was generated using https://dummyjson.com/ and https://picsum.photos/
Images for the Photos are located under ‘./public/images/’ here
Source files for this example.
GitHubLink to JSON data files here

Package.json
Libraries used in this project
{
"name": "nextjs",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.6",
"@mui/icons-material": "^5.11.11",
"@mui/material": "^5.11.15",
"@next/font": "^13.2.4",
"axios": "^1.3.4",
"eslint": "8.35.0",
"eslint-config-next": "13.2.4",
"next": "13.2.4",
"nextjs-progressbar": "^0.0.16",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-highlight-words": "0.20.0",
"swr": "^2.1.1"
},
"devDependencies": {
"@types/node": "18.15.11",
"@types/react": "18.0.31",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21",
"typescript": "5.1.3"
}
}
Next.js API
This API gets data from two local JSON files under the ‘./data’ folder. Then returns both files into JSON data.
Source code for pages/api/getData.js
import path from 'path';
import { promises as fs } from 'fs';
export default async function handler(req, res) {
//Find the absolute path of the json directory
const jsonDirectory = path.join(process.cwd(), 'data');
//Read the json data files under ./data
const postData = await fs.readFile(jsonDirectory + '/posts.json', 'utf8');
const photosData = await fs.readFile(jsonDirectory + '/photos.json', 'utf8');
//Return the content of the data file in json format
const allData = { photos: JSON.parse(photosData), posts: JSON.parse(postData) };
res.status(200).json(allData);
}
Root Page
This page component has state variables that will be available for all components with the createContext() and useContext() hooks.
The data source is static JSON available at the bottom of the article. Here is a link to the data sources and the full root page component here. Not shown in the source code below due to space constraints.
Note: In the GitHub example there are two additional features available that are standard on many Next.js applications. One is a custom theme that can switch from Light to Dark. Also, a page progress bar that runs along the top of the main section using <NextNProgress/>.
Source code for _app.js
import * as React from 'react';
import PropTypes from 'prop-types';
import Head from 'next/head';
import CssBaseline from '@mui/material/CssBaseline';
import { CacheProvider } from '@emotion/react';
import createEmotionCache from '../src/createEmotionCache';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import { red, grey } from '@mui/material/colors';
import AppLayout from '../components/AppLayout';
import NextNProgress from 'nextjs-progressbar';
import '../styles/globals.css';
import { createContext, Fragment, useState } from "react";
import useSWR from 'swr';
//Write a fetcher function to wrap the native fetch function and return the result of a call to url in json format
const fetcher = (url) => fetch(url).then((res) => res.json());
export const RootCompContext = createContext(null);
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
export default function MainApp(props) {
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
const { data, error } = useSWR('/api/getData', fetcher);
const [darkState, setDarkState] = useState<boolean>(false);
const [searchTerm, setSearchTerm] = useState('');
const [filteredResults, setFilteredResults] = useState<any>([]);
const [historyResults, setHistoryResults] = useState<any>([]);
const [suggestionResults, setSuggestionResults] = useState<any>([]);
const [maxRecordsReturned ] = useState<number>(10);
const [selectProduct, setSelectProduct] = useState<any>(null);
const [postsData, setPostsData] = useState<any>([]);
const [photosData, setPhotosData] = useState<any>([]);
const [filteredPostsData, setFilteredPostsData] = useState<any>([]);
const [filteredPhotosData, setFilteredPhotosData] = useState<any>([]);
const [filteredResultsCategories, setFilteredResultsCategories] = useState<any>([]);
const [arrowKeyLateralListIndex, setArrowKeyLateralListIndex] = useState<number>(0);
const [arrowKeyItemIndex, setArrowKeyItemIndex] = useState<number>(0);
const [arrowKeyLateralItemIndex, setArrowKeyLateralItemIndex] = useState<number>(0);
React.useEffect(() => {
if(data) {
setPostsData(data.posts);
setAlbumData(data.albums);
}
}, [data])
const handleThemeChange = () => {
setDarkState(!darkState);
};
return (
<CacheProvider value={emotionCache}>
<Head>
<meta name="viewport" content="initial-scale=1, width=device-width" />
</Head>
<ThemeProvider theme={theme}>
{/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */}
<CssBaseline />
<NextNProgress
color={theme.palette.mode==='light'?'#CD6155': '#CD6155'}
startPosition={0.3}
stopDelayMs={200}
height={10}
showOnShallow={true}
/>
<RootCompContext.Provider
value={{
searchTerm,
setSearchTerm,
filteredResults,
setFilteredResults,
historyResults,
setHistoryResults,
suggestionResults,
setSuggestionResults,
selectProduct,
setSelectProduct,
maxRecordsReturned,
postsData,
photosData,
filteredPostsData,
setFilteredPostsData,
filteredPhotosData,
setFilteredPhotosData,
filteredResultsCategories,
setFilteredResultsCategories,
arrowKeyItemIndex,
setArrowKeyItemIndex,
arrowKeyLateralItemIndex,
setArrowKeyLateralItemIndex,
arrowKeyLateralListIndex,
setArrowKeyLateralListIndex
}}
>
<AppLayout handleThemeChange={handleThemeChange} darkState={darkState} mainPage={
<>
<Component {...pageProps} />
</>}
/>
</RootCompContext.Provider>
</ThemeProvider>
</CacheProvider>
);
}
MainApp.propTypes = {
Component: PropTypes.elementType.isRequired,
emotionCache: PropTypes.object,
pageProps: PropTypes.object.isRequired,
allProducts: PropTypes.array.isRequired,
};
MainApp.propTypes = {
Component: PropTypes.elementType.isRequired,
emotionCache: PropTypes.object,
pageProps: PropTypes.object.isRequired,
allProducts: PropTypes.array.isRequired,
};
Show Record Dialog Component (Optional)
This dialog box display the record that was clicked on or select with the enter key.
Source for DialogShowRecord.js – Optional
import * as React from 'react';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogContentText from '@mui/material/DialogContentText';
import DialogTitle from '@mui/material/DialogTitle';
import Chip from '@mui/material/Chip';
import { Typography } from '@mui/material';
export default function DialogShowRecord(props) {
return (
<div>
<Dialog
open={props.open}
onClose={props.handleClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">
Record Selected
</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
<Typography variant='h6' sx={{mb: 4}}>
{props.data.title}
</Typography>
<Chip label={props.data.category} variant="outlined" color="primary"/>
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={props.handleClose} autoFocus>
Close
</Button>
</DialogActions>
</Dialog>
</div>
);
}
Layout Component
The Layout function component is derived from the Search & App Bar in the Material UI library. THe component below renders the layout which includes a MUI <AppBar/> and <Drawer/> components. The <AppBar/> use MUI’s styled to build a custom toolbar with flex to space everything in a way that is more aesthetically pleasing.

Source for Layout.js
import { useState, useEffect, useContext, createContext } from "react";
import AppBarMenu from "./AppBarMenu";
import Toolbar from "@mui/material/Toolbar";
import MuiAppBar from "@mui/material/AppBar";
import Typography from "@mui/material/Typography";
import useMediaQuery from "@mui/material/useMediaQuery";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import Box from "@mui/material/Box";
import { useRouter } from "next/router";
import Container from "@mui/material/Container";
import Link from "next/link";
import Button from "@mui/material/Button";
import Drawer from "@mui/material/Drawer";
import { styled, useTheme } from "@mui/material/styles";
import { Divider } from "@mui/material";
import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import MenuIcon from "@mui/icons-material/Menu";
import HomeIcon from "@mui/icons-material/Home";
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import SettingsSuggestIcon from "@mui/icons-material/SettingsSuggest";
import LayoutSearchField from "./search/LayoutSearchField";
const drawerWidth = 240;
const Main = styled("main", { shouldForwardProp: (prop) => prop !== "open" })(
({ theme, open }) => ({
flexGrow: 1,
padding: theme.spacing(3),
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
//marginLeft: `-${drawerWidth}px`,
marginLeft: `10px`,
...(open && {
transition: theme.transitions.create("margin", {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
marginLeft: 250,
}),
})
);
const MUIAppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== "open",
})(({ theme, open }) => ({
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(["margin", "width"], {
easing: theme.transitions.easing.easeOut,
duration: theme.transitions.duration.enteringScreen,
}),
}),
opacity: 1.0,
zIndex: 999,
}));
const DrawerHeader = styled("div")(({ theme }) => ({
display: "flex",
alignItems: "center",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
justifyContent: "flex-end",
}));
const publicNavigation = [
{ name: "Home", href: "/", icon: <HomeIcon />, current: true },
{
name: "My Settings",
href: "/settings",
icon: <SettingsSuggestIcon />,
current: false,
},
];
const ColorModeContext = createContext({ toggleColorMode: () => {} });
export default function AppLayout(props) {
const theme = useTheme();
const isMdUp = useMediaQuery(theme.breakpoints.up("md"));
const router = useRouter();
// const [open, setOpen] = useState(isMdUp ? true : false);
const [open, setOpen] = useState(false);
const colorMode = useContext(ColorModeContext);
useEffect(() => {
// setOpen(isMdUp ? true : false);
setOpen(false);
}, [isMdUp]);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
return (
<>
<MUIAppBar position="fixed" open={open}>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
sx={{ mr: 2, ...(open && { display: "none" }) }}
>
<MenuIcon />
</IconButton>
<Box sx={{ flexGrow: 1 }} />
<LayoutSearchField />
<Box sx={{ flexGrow: 1 }} />
{/* <Button
variant="outlined"
color="inherit"
startIcon={<AccountCircleIcon />}
// component={Link}
// href={"/login"}
sx={{ ml: 2, sm: 'none', lg: 'block', xl: 'block' }}
>
Sign In
</Button>
<>
<AppBarMenu handleDrawerOpen={handleDrawerOpen} session={""} />
</> */}
</Toolbar>
</MUIAppBar>
<Main open={open}>
<DrawerHeader />
<Container maxWidth="false" sx={{ marginTop: "10px" }}>
{props.mainPage}
</Container>
</Main>
<Drawer
sx={{
width: drawerWidth,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
},
}}
variant="persistent"
anchor="left"
open={open}
>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "ltr" ? (
<ChevronLeftIcon />
) : (
<ChevronRightIcon />
)}
</IconButton>
</DrawerHeader>
<Divider />
<List>
{publicNavigation.map((item, index) => (
<>
<ListItem
key={index}
component={Link}
href={item.href}
disablePadding
>
<ListItemButton>
<ListItemIcon
style={{
color: router.asPath === item.href ? "blue" : "black",
}}
>
{item.icon}
</ListItemIcon>
<ListItemText
primary={item.name}
style={{
color: router.asPath === item.href ? "blue" : "black",
}}
/>
</ListItemButton>
</ListItem>
</>
))}
</List>
</Drawer>
</>
);
}
Layout Search Field Component
The Layout Search Field component uses MUI’s styled function to build a custom search field with icons that has an embedded look to blend into the <AppBar/>.
Functions in this component include:
- Handling Click on the Posts or Photo results and show the record in a MUI dialog box.
- Handling Arrow up and down key events to traverse the Post results.
- Handling Enter key event to display the selected Post in a MUI dialog box.
Source for SearchFieldComponent.js
import * as React from "react";
import { useContext, useRef } from "react";
import {RootCompContext} from "@/pages/_app";
import { alpha, styled } from '@mui/material/styles';
import InputBase from "@mui/material/InputBase";
import SearchIcon from "@mui/icons-material/Search";
import ClearIcon from "@mui/icons-material/Clear";
import { Box, Stack } from "@mui/material";
import ClickAwayListener from "@mui/base/ClickAwayListener";
import { useRouter } from 'next/router'
import SearchResultsGrid from "./SearchResultsGrid";
import DialogShowRecord from "./DialogShowRecord";
const Search = styled('div')(({ theme }) => ({
position: 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: alpha(theme.palette.common.white, 0.15),
'&:hover': {
backgroundColor: alpha(theme.palette.common.white, 0.25),
},
marginRight: theme.spacing(2),
marginLeft: 0,
width: '100%',
[theme.breakpoints.up('sm')]: {
marginLeft: theme.spacing(3),
width: 'auto',
},
[theme.breakpoints.down('sm')]: {
display: 'none',
marginLeft: theme.spacing(3),
width: 'auto',
},
}));
const SearchIconWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 2),
height: '100%',
position: 'absolute',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}));
const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: 'inherit',
'& .MuiInputBase-input': {
padding: theme.spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
transition: theme.transitions.create('width'),
width: '100%',
[theme.breakpoints.up('md')]: {
width: '80ch',
},
},
}));
const SearchClearIconWrapper = styled('div')(({ theme }) => ({
padding: theme.spacing(0, 2),
height: '100%',
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
right: 1,
cursor: 'pointer',
zIndex: 99,
}));
interface IPhotoRecord {
id: number,
title: string,
category: string
}
interface IPostRecord {
id: number,
title: string,
category: string
}
export default function LayoutSearchField(props) {
const router = useRouter()
var searchInputRef = useRef<HTMLInputElement>();
const [showRecordDialog, setShowRecordDialog] = React.useState<boolean>(false);
const [showResults, setShowResults] = React.useState(false);
const [chosenRecord, setChosenRecord] = React.useState<IPhotoRecord | undefined>(undefined);
const {
searchTerm,
setSearchTerm,
postsData,
photosData,
setFilteredPostsData,
filteredPostsData,
setFilteredPhotosData,
setFilteredResultsCategories,
arrowKeyItemIndex,
setArrowKeyItemIndex,
setSelectProduct
} = useContext(RootCompContext);
const arrowUpPressed = useKeyPress('ArrowUp');
const arrowDownPressed = useKeyPress('ArrowDown');
// const arrowLeftPressed = useKeyPress('ArrowLeft');
// const arrowRightPressed = useKeyPress('ArrowRight');
const enterKeyPressed = useKeyPress('Enter');
const [ counter, setCounter ] = React.useState(0);
const [ displaySearchTerm, setDisplaySearchTerm ] = React.useState('');
const [ isTraverList, setIsTraverList ] = React.useState(false);
//* U P A N D D O W N A R R O W S P R E S S E D
React.useEffect(() => {
if (arrowUpPressed) {
console.log('arrowUpPressed');
if(arrowKeyItemIndex > 0) {
setCounter(counter - 1)
const index = arrowKeyItemIndex - 1;
setArrowKeyItemIndex(arrowKeyItemIndex - 1);
}
setDisplaySearchTerm(filteredPostsData[arrowKeyItemIndex].title)
// setSearchTerm(filteredPostsData[arrowKeyItemIndex].title)
}
}, [arrowUpPressed]);
React.useEffect(() => {
if (arrowDownPressed) {
if(arrowKeyItemIndex <= filteredPostsData.length-2) {
setCounter(counter + 1)
const index = arrowKeyItemIndex + 1;
setArrowKeyItemIndex(arrowKeyItemIndex + 1);
}
setDisplaySearchTerm(filteredPostsData[arrowKeyItemIndex].title)
// setSearchTerm(filteredPostsData[arrowKeyItemIndex].title)
}
}, [arrowDownPressed]);
React.useEffect(() => {
if (enterKeyPressed) {
const { id, title } = filteredPostsData[arrowKeyItemIndex]
setChosenRecord(filteredPostsData[arrowKeyItemIndex])
setDisplaySearchTerm(title);
setSearchTerm(title);
setShowResults(false);
setShowRecordDialog(true)
// router.push(`/product/${id}`)
}
}, [enterKeyPressed]);
const handleCloseRecordDialogBox = () => {
setShowRecordDialog(false)
}
//* H A N D L E S E L E C T P H O T O R E C O R D
const handleSelectedPhoto = (id, title, category) => {
const chosenRecord: IPhotoRecord = {
id: id,
title: title,
category: category
}
console.log('handleSelectedPhoto', chosenRecord)
setChosenRecord(chosenRecord)
setShowRecordDialog(true)
}
//* H A N D L E S E L E C T P H O T O R E C O R D
const handleSelectedPost = (id, title, category) => {
const chosenRecord: IPhotoRecord = {
id: id,
title: title,
category: category
}
console.log('handleSelectedPhoto', chosenRecord)
setChosenRecord(chosenRecord)
setShowRecordDialog(true)
}
//* O T H E R K E Y S P R E S S E D
React.useEffect(() => {
setArrowKeyItemIndex(0)
}, [searchTerm]);
//* H A N D L E S E A R C H T E R M C H A N G E D
const handleSearchTerm = (e) => {
setSearchTerm(e.target.value);
// Make sure results are showing when typing
if(showResults === false) {
setShowResults(true);
}
const tmpPostsData: IPostRecord[] = [];
const tmpPhotosData: IPhotoRecord[] = [];
const tmpCategoriesData: any = [];
if (e.target.value.length > 0) {
postsData.forEach((element) => {
if (
element.title
.toLowerCase()
.includes(e.target.value.toLowerCase())
) {
tmpPostsData.push(element);
if(!tmpCategoriesData.includes(element.category)){
tmpCategoriesData.push(element.category)
7 }
}
});
photosData.forEach((element) => {
if (
element.title
.toLowerCase()
.includes(e.target.value.toLowerCase())
) {
tmpPhotosData.push(element);
if(!tmpCategoriesData.includes(element.category)){
tmpCategoriesData.push(element.category)
}
}
});
setFilteredResultsCategories(tmpCategoriesData)
setFilteredPostsData(tmpPostsData.splice(0, 9));
setFilteredPhotosData(tmpPhotosData);
}
};
//* H A N D L E O P E N / S H O W S E A R C H
const handleOpenSearchResults = () => {
setShowResults(true);
};
//* H A N D L E S E L E C T P R O D U C T W I T H M O U S E C L I C K
const handleSelectedProduct = (id) => {
setSelectProduct(id);
setShowResults(false);
// router.push(`/product/${id}`)
};
//* H A N D L E C L E A R S E A R C H T E R M C H A N G E D
const handleClearSearchTerm = (e) => {
setSearchTerm('');
setDisplaySearchTerm('')
setFilteredPostsData([]);
setFilteredPhotosData([]);
searchInputRef.current.focus();
};
return (
<>
<Box
alignContent="center"
sx={{ position: "absolute", top: "1vh", left: "20vw", zIndex: 99 }}
>
<ClickAwayListener onClickAway={() => setShowResults(false)}>
<Box textAlign="left" sx={{ width: "50vw", position: "relative" }}>
<Stack spacing={0}>
<Search>
<SearchIconWrapper>
<SearchIcon />
</SearchIconWrapper>
<SearchClearIconWrapper >
{searchTerm.length > 0 &&
<ClearIcon onClick={(e) => handleClearSearchTerm(e)}/>
}
</SearchClearIconWrapper>
<StyledInputBase
inputRef={(input) => { searchInputRef.current = input }}
size="small"
sx={{ ml: 1, flex: 1 }}
placeholder="Search Products"
inputProps={{ "aria-label": "search google maps" }}
onChange={(e) => handleSearchTerm(e)}
onFocus={() => handleOpenSearchResults()}
value={searchTerm}
/>
</Search>
<SearchResultsGrid
handleSelectedPhoto={handleSelectedPhoto}
handleSelectedPost={handleSelectedPost}
showResults={showResults}
searchTerm={searchTerm}
handleSelectedProduct={handleSelectedProduct}
/>
</Stack>
</Box>
</ClickAwayListener>
</Box>
{chosenRecord &&
<DialogShowRecord data={chosenRecord}
handleClose={handleCloseRecordDialogBox}
open={showRecordDialog}/>
}
</>
);
}
//* C U S T O M H O O K T O H A N D L E K E Y B O A R D E V E N T S
const useKeyPress = (targetKey) => {
const [keyPressed, setKeyPressed] = React.useState(false);
React.useEffect(() => {
const downHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(true);
}
};
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
window.addEventListener('keydown', downHandler);
window.addEventListener('keyup', upHandler);
return () => {
window.removeEventListener('keydown', downHandler);
window.removeEventListener('keyup', upHandler);
};
}, [targetKey]);
return keyPressed;
};
Custom Autocomplete Search Filter
The code snippet below creates a box section that is 50vw or 1/2 the view width. Within the box there is a styled InputBase component with search icon prepened and the a close icon appended to the field. The inputs and icons are styled and aligned using MUI’s styled from @mui/material/styles. The <SearchResultGrid /> component is visible when input field is clicked on or has focus and if any characters are typed except for the up and down arrow keys. This will stay within 5-10% of the search input field width. The section appears
<Box
alignContent="center"
sx={{ position: "absolute", top: "1vh", left: "4vw", zIndex: 99 }}
>
<ClickAwayListener onClickAway={() => setShowResults(false)}>
<Box textAlign="left" sx={{ width: "50vw", position: "relative" }}>
<Stack spacing={0}>
<Search>
<SearchIconWrapper>
<SearchIcon />
</SearchIconWrapper>
<StyledInputBase
size="small"
sx={{ ml: 1, flex: 1 }}
placeholder="Search Products"
inputProps={{ "aria-label": "search google maps" }}
onChange={(e) => handleSearchTerm(e)}
onFocus={() => handleOpenSearchResults()}
value={searchTerm}
/>
</Search>
<SearchResultsGrid
showResults={showResults}
searchTerm={searchTerm}
/>
</Stack>
</Box>
</ClickAwayListener>
</Box>
Custom Hook for Key Press Listener
The code snippet below was copied from stackoverflow.com authored by Laith
Link to article: https://stackoverflow.com/questions/59546928/keydown-up-events-with-react-hooks-not-working-properly
const useKeyPress = (targetKey) => {
const [keyPressed, setKeyPressed] = React.useState(false);
React.useEffect(() => {
const downHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(true);
}
};
const upHandler = ({ key }) => {
if (key === targetKey) {
setKeyPressed(false);
}
};
window.addEventListener('keydown', downHandler);
window.addEventListener('keyup', upHandler);
return () => {
window.removeEventListener('keydown', downHandler);
window.removeEventListener('keyup', upHandler);
};
}, [targetKey]);
return keyPressed;
};
Search Grid Results Component
This is where the magic happens. Using the MUI’s <Box/> component, the search results can drop down below the search field in a responsive outlined area (Snapshot below). Having a custom search filter, multiple results can be displayed along with suggestions or categories.
import * as React from "react";
import { styled } from "@mui/material/styles";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Grid from "@mui/material/Grid";
import SearchResultList from "./PostSearchResultList";
import PhotoSearchResultList from './PhotoSearchResultList'
import Chip from '@mui/material/Chip';
import { useContext } from 'react'
import { RootCompContext } from "@/pages/_app";
const Item = styled(Paper)(({ theme }) => ({
backgroundColor: theme.palette.mode === "dark" ? "#1A2027" : "#fff",
...theme.typography.body2,
padding: theme.spacing(1),
textAlign: "center",
color: theme.palette.text.secondary,
}));
export default function BasicGrid(props) {
const {
filteredPostsData,
setFilteredPostsData,
filteredPhotosData,
setFilteredPhotosData,
filteredResultsCategories
} = useContext(RootCompContext);
const [searchTerm, setSearchTerm] = React.useState(null);
React.useEffect(() => {
setFilteredPostsData(props.filteredPostsData);
setFilteredPhotosData(props.filteredPhotosData);
}, [props.data]);
React.useEffect(() => {
setSearchTerm(props.searchTerm);
}, [props.searchTerm]);
return (
<>
{props.showResults &&
<>
<Box
sx={{
width: "96%",
marginLeft: "23px",
height: "60vh",
position: "relative",
zIndex: 99,
display: "block",
// opacity: [0.9, 0.8, 9.5],
opacity: 1.0,
backgroundColor: "#FAFAFA",
"&:hover": {
backgroundColor: "#FAFAFA",
opacity: 1.0,
// opacity: [0.9, 0.8, 1.0],
},
}}
>
<Grid container spacing={1} columns={16}>
<Grid item xs={16}>
{(!Boolean(filteredPostsData) && !Boolean(filteredPhotosData)) &&
<Box sx={{ '& > :not(style)': { m: 0.5 } }}>
{staticCategories.map((elem, index) => (
<Chip label={elem} key={index} variant="outlined" color="primary"/>
))}
</Box>
}
{(Boolean(filteredPostsData) || Boolean(filteredPhotosData)) &&
<Box sx={{ '& > :not(style)': { m: 0.5 } }}>
{filteredResultsCategories.map((elem, index) => (
<Chip label={elem} key={index} variant="outlined" color="primary"/>
))}
</Box>
}
</Grid>
<Grid item xs={8}>
{Boolean(filteredPostsData) &&
<Box style={{margin: 1, maxHeight: '49vh', overflow: 'auto'}}>
{(filteredPostsData.length > 0) && (
<>
<SearchResultList
key={1}
data={filteredPostsData}
handleSelectedPost={props.handleSelectedPost}
searchTerm={props.searchTerm}
/>
</>
)}
</Box>
}
</Grid>
<Grid item xs={8}>
{Boolean(filteredPhotosData) &&
<Box style={{margin: 1, maxHeight: '49vh', overflow: 'auto'}}>
{(filteredPhotosData.length > 0) && (
<>
<PhotoSearchResultList
key={2}
data={filteredPhotosData}
handleSelectedPhoto={props.handleSelectedPhoto}
searchTerm={props.searchTerm}
/>
</>
)}
</Box>
}
</Grid>
</Grid>
</Box>
</>
}
</>
);
}
const staticCategories = [
"Food",
"Pets",
"Electronics",
"Autotmotive",
"Shopping",
"Home",
"Garden",
"Recreation",
"Rock",
"Jazz",
"Pop",
"Dark Synth",
"Funk",
"Punk",
"Nature",
"Landscape"
]
Posts Search Filter List Component
This component is a combination of a CSS generated 3d shadow box that can grow and shrink with a Material List. The list is generated and filtered from the parent class component using ./data/posts.json data and passed down to the SearchResultsGrid.js and then the AlbumSearchResultList.js,
Also, highlight match search term string with react-highlight-word library.
Source for PostSearchResultsList.js
import * as React from 'react';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import People from '@mui/icons-material/People';
import PermMedia from '@mui/icons-material/PermMedia';
import Dns from '@mui/icons-material/Dns';
import Public from '@mui/icons-material/Public';
import { useContext } from 'react'
import { RootCompContext } from "@/pages/_app";
import { Typography } from '@mui/material';
import MessageIcon from '@mui/icons-material/Message';
import Highlighter from "react-highlight-words";
import Link from 'next/link';
const data = [
{ icon: <People />, label: 'Authentication' },
{ icon: <Dns />, label: 'Database' },
{ icon: <PermMedia />, label: 'Storage' },
{ icon: <Public />, label: 'Hosting' },
];
export default function PostSearchResultList(props) {
const [searchTerms, setSearchTerms] = React.useState<string[]>([])
const {searchTerm,
filteredPostsData,
maxRecordsReturned,
arrowKeyItemIndex,
arrowKeyLateralListIndex
} = useContext(RootCompContext);
React.useEffect(() => {
var searchStr: string[] = []
searchStr.push(`${props.searchTerm}`)
setSearchTerms(searchStr)
}, [props.searchTerm])
const Highlight = ({ children, highlightIndex }) => (
<strong className="highlighted-text">{children}</strong>
);
return (
<>
{(filteredPostsData.length > 0 && searchTerm.length > 0) &&
<>
<Typography variant='subtitle1' sx={{mt: 2, ml: 2, color: 'black'}}>
Post Search Results {arrowKeyItemIndex}
</Typography>
<List sx={{ width: '100%', maxWidth: '100%', bgcolor: 'background.paper' }}>
{filteredPostsData.slice(0, maxRecordsReturned).map((item, index) => (
<ListItemButton
key={item.id}
sx={{ py: 0, minHeight: 42, color: 'rgba(5,5,5,.8)', bgcolor: (arrowKeyLateralListIndex ===0 && arrowKeyItemIndex===index) ? '#EFEFEF' : 'background.paper' }}
onClick={() => props.handleSelectedPost(item.id, item.title, item.category)}
>
<ListItemIcon sx={{ color: 'inherit' }}>
<MessageIcon/>
</ListItemIcon>
<Highlighter
style={{color: 'black'}}
highlightClassName={Highlight}
searchWords={searchTerms}
autoEscape={true}
textToHighlight={item.title}
/>
</ListItemButton>
))}
</List>
</>
}
</>
);
}
Photo Search Filter List Component
This component is a combination of a CSS generated 3d shadow box that can grow and shrink with a Material List. The list is generated and filtered from the parent class component using ./data/photos.json data and passed down to the SearchResultsGrid.js and then the PhotosSearchResultList.js,
Source for PhotosSearchResultList.js
import * as React from 'react';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import AlbumIcon from '@mui/icons-material/Album';
import { useContext } from 'react'
import {RootCompContext} from "@/pages/_app";
import { Typography } from '@mui/material';
import Link from 'next/link';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import Avatar from '@mui/material/Avatar';
export default function PhotoSearchResultList(props) {
const [searchTerms, setSearchTerms] = React.useState([])
const {searchTerm,
filteredPhotosData,
filteredResults,
setFilteredResults,
maxRecordsReturned,
arrowKeyItemIndex,
setArrowKeyItemIndex,
arrowKeyLateralItemIndex,
setArrowKeyLateralItemIndex,
arrowKeyLateralListIndex,
setArrowKeyLateralListIndex
} = useContext(RootCompContext);
React.useEffect(() => {
var searchStr = []
searchStr.push(`${props.searchTerm}`)
setSearchTerms(searchStr)
}, [props.searchTerm])
const Highlight = ({ children, highlightIndex }) => (
<strong className="highlighted-text">{children}</strong>
);
return (
<>
{(filteredPhotosData.length > 0 && searchTerm.length > 0) &&
<>
<Typography variant='subtitle1' sx={{mt: 2, ml: 2, color: 'black'}}>
Photos Search Results
</Typography>
<List sx={{ width: '100%', maxWidth: '100%', bgcolor: 'background.paper' }}>
{filteredPhotosData.slice(0, maxRecordsReturned).map((item, index) => (
<>
<ListItemButton
key={item.id}
sx={{ mt: 1, py: 0, minHeight: 42, color: 'rgba(5,5,5,.8)', bgcolor: (arrowKeyLateralListIndex === 1 && arrowKeyItemIndex===index) ? '#EFEFEF' : 'background.paper' }}
onClick={() => props.handleSelectedPhoto(item.id, item.title, item.category)}
>
<ListItemAvatar>
<Avatar alt={item.title} src={item.url} sx={{width: 56, height: 56, mr: 2 }} variant="square"/>
</ListItemAvatar>
{item.title}
</ListItemButton>
</>
))}
</List>
</>
}
</>
);
}
Conclusion
In conclusion, implementing a custom autocomplete search feature with arrow key navigation and a clear search icon button offers an enhanced user experience and improved efficiency. By allowing users to navigate through search suggestions using arrow keys, the feature streamlines the search process and reduces the reliance on manual mouse movements. Additionally, including a clear search icon provides a convenient way for users to reset their search queries, promoting flexibility and ease of use. These enhancements contribute to a more intuitive and user-friendly search interface, improving user experience. By leveraging these features, developers can create a robust and modern search field that caters to the needs and preferences of today’s users.
Now that you know how to build a custom search embedded in a MUI <AppBar />, try storing the search history using the browsers local storage. To really take it even further, place a carousel with images at the bottom or when there is no search term. Check out the search fields from Amazon.com or NewEgg.com to get some ideas.
You must be logged in to post a comment.