├── .env.production
├── .eslintrc.json
├── styles
└── globals.css
├── public
├── favicon.ico
├── vercel.svg
├── thirteen.svg
└── next.svg
├── jsconfig.json
├── constants
└── index.ts
├── next-env.d.ts
├── pages
├── _document.tsx
├── api
│ └── getData.ts
├── index.tsx
└── _app.tsx
├── src
├── createEmotionCache.ts
├── Copyright.tsx
└── theme.ts
├── components
├── ThemeSwitch.tsx
├── AppLayout.tsx
└── search
│ ├── DialogShowRecord.tsx
│ ├── SearchResultsGrid.tsx
│ ├── PostSearchResultList.tsx
│ └── LayoutSearchField.tsx
├── package.json
├── tsconfig.json
├── README.md
└── .gitignore
/.env.production:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dev-ninja11/home-assesment/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "@/*": ["./*"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/constants/index.ts:
--------------------------------------------------------------------------------
1 | export const CLOUND_NAME = "dck3dtdnr"
2 | export const API_URL = "https://swapi.dev/api/films/"
3 | export const GIT_REPO_URL = "https://github.com/dev-ninja11/home-assesment"
4 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import { Html, Head, Main, NextScript } from 'next/document'
2 |
3 | export default function Document() {
4 | return (
5 |
6 |
8 |
9 |
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/pages/api/getData.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios"
2 | import { API_URL } from "../../constants"
3 |
4 | export default async function handler(req, res) {
5 | try {
6 | const response = await axios.get(API_URL)
7 | const postData = response.data.results
8 | res.status(200).json(postData)
9 | } catch (err) {
10 | return res.json({ error: err })
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/createEmotionCache.ts:
--------------------------------------------------------------------------------
1 | import createCache from "@emotion/cache"
2 |
3 | const isBrowser = typeof document !== "undefined"
4 |
5 | export default function createEmotionCache() {
6 | let insertionPoint
7 |
8 | if (isBrowser) {
9 | const emotionInsertionPoint = document.querySelector(
10 | 'meta[name="emotion-insertion-point"]'
11 | )
12 | insertionPoint = emotionInsertionPoint ?? undefined
13 | }
14 |
15 | return createCache({ key: "mui-style", insertionPoint })
16 | }
17 |
--------------------------------------------------------------------------------
/src/Copyright.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import Typography from '@mui/material/Typography';
3 | import MuiLink from '@mui/material/Link';
4 |
5 | export default function Copyright() {
6 | return (
7 |
8 | {'Copyright © '}
9 |
10 | Your Website
11 | {' '}
12 | {new Date().getFullYear()}.
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/theme.ts:
--------------------------------------------------------------------------------
1 | import { Roboto } from "next/font/google"
2 | import { createTheme } from "@mui/material/styles"
3 | import { red } from "@mui/material/colors"
4 |
5 | export const roboto = Roboto({
6 | weight: ["300", "400", "500", "700"],
7 | subsets: ["latin"],
8 | display: "swap",
9 | fallback: ["Helvetica", "Arial", "sans-serif"],
10 | })
11 |
12 | const theme = createTheme({
13 | palette: {
14 | primary: {
15 | main: "#556cd6",
16 | },
17 | secondary: {
18 | main: "#19857b",
19 | },
20 | error: {
21 | main: red.A400,
22 | },
23 | },
24 | typography: {
25 | fontFamily: roboto.style.fontFamily,
26 | },
27 | })
28 |
29 | export default theme
30 |
--------------------------------------------------------------------------------
/components/ThemeSwitch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { useTheme } from "@mui/material/styles"
3 | import Brightness4Icon from "@mui/icons-material/Brightness4"
4 | import Brightness7Icon from "@mui/icons-material/Brightness7"
5 | import { IconButton } from "@mui/material"
6 |
7 | export default function ThemeSwitch(props) {
8 | const theme = useTheme()
9 |
10 | const handleChange = (event) => {
11 | props.handleThemeChange(event.target.checked)
12 | }
13 |
14 | return (
15 |
16 | {theme.palette.mode === "dark" ? (
17 |
18 | ) : (
19 |
20 | )}
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev -p 3000",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@cloudinary/react": "^1.13.0",
13 | "@cloudinary/url-gen": "^1.19.0",
14 | "@emotion/cache": "^11.10.5",
15 | "@emotion/react": "^11.10.6",
16 | "@emotion/server": "^11.10.0",
17 | "@emotion/styled": "^11.10.6",
18 | "@mui/icons-material": "^5.11.11",
19 | "@mui/material": "^5.11.15",
20 | "axios": "^1.3.4",
21 | "eslint": "8.35.0",
22 | "eslint-config-next": "13.2.4",
23 | "next": "13.2.4",
24 | "nextjs-progressbar": "^0.0.16",
25 | "react": "18.2.0",
26 | "react-dom": "18.2.0",
27 | "react-highlight-words": "0.20.0",
28 | "swr": "^2.1.1"
29 | },
30 | "devDependencies": {
31 | "@types/node": "18.15.11",
32 | "@types/react": "18.0.31",
33 | "autoprefixer": "^10.4.14",
34 | "postcss": "^8.4.21",
35 | "typescript": "5.1.3"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/public/thirteen.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/components/AppLayout.tsx:
--------------------------------------------------------------------------------
1 | import Toolbar from "@mui/material/Toolbar"
2 | import MuiAppBar from "@mui/material/AppBar"
3 | import Container from "@mui/material/Container"
4 | import { styled } from "@mui/material/styles"
5 | import LayoutSearchField from "./search/LayoutSearchField"
6 |
7 | const Main = styled("main")(({ theme }) => ({
8 | flexGrow: 1,
9 | padding: theme.spacing(1),
10 | transition: theme.transitions.create("margin", {
11 | easing: theme.transitions.easing.sharp,
12 | duration: theme.transitions.duration.leavingScreen,
13 | }),
14 | }))
15 |
16 | const DrawerHeader = styled("div")(({ theme }) => ({
17 | display: "flex",
18 | alignItems: "center",
19 | padding: theme.spacing(0, 1),
20 | ...theme.mixins.toolbar,
21 | justifyContent: "flex-end",
22 | }))
23 |
24 | export default function AppLayout(props) {
25 | return (
26 | <>
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 | {props.mainPage}
36 |
37 | >
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/components/*": ["components/*"],
6 | "@/pages/*": ["pages/*"],
7 | "@/src/*": ["src/*"],
8 | "@/styles/*": ["styles/*"],
9 | "@/store/*": ["store/*"],
10 | "@/context/*": ["context/*"],
11 | "@/lib/*": ["lib/*"],
12 | "@/utils/*": ["utils/*"]
13 | },
14 | "target": "es5",
15 | "lib": [
16 | "dom",
17 | "dom.iterable",
18 | "esnext"
19 | ],
20 | "allowJs": true,
21 | "skipLibCheck": true,
22 | "strict": false,
23 | "forceConsistentCasingInFileNames": true,
24 | "noEmit": true,
25 | "incremental": true,
26 | "esModuleInterop": true,
27 | "module": "esnext",
28 | "moduleResolution": "node",
29 | "resolveJsonModule": true,
30 | "isolatedModules": true,
31 | "jsx": "preserve"
32 | },
33 | "include": [
34 | "next-env.d.ts",
35 | "**/*.ts",
36 | "**/*.tsx"
37 | , "components/Pub/GearCalculator/CustomGearRatios/DataGridGearRatios.js", "components/Users/MyGarage/GarageCard.js", "components/ListReviews.jsx", "pages/api/auth/[...nextauth]ts.copy", "pages/auth/protected-two/index.js", "pages/_document.tsa", "components/layout/search-beta/LayoutProductSearchField.jsx" ],
38 | "exclude": [
39 | "node_modules"
40 | ]
41 |
42 |
43 | }
44 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import Box from "@mui/material/Box"
3 | import Typography from "@mui/material/Typography"
4 | import Container from "@mui/material/Container"
5 | import Link from "@mui/material/Link"
6 | import { GIT_REPO_URL } from "../constants"
7 |
8 | function Copyright() {
9 | return (
10 |
11 |
12 | {"Created by Joseph © "}
13 |
14 | Source Code
15 | {" "}
16 | {new Date().getFullYear()}
17 |
18 |
19 | )
20 | }
21 |
22 | export default function Album() {
23 | return (
24 | <>
25 |
26 |
33 | Home Assesment
34 |
35 |
41 | Using the React MUI library, create an App Bar with a search field
42 | that show results as you type.
43 |
44 |
45 |
46 | >
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/components/search/DialogShowRecord.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import Button from "@mui/material/Button"
3 | import Dialog from "@mui/material/Dialog"
4 | import DialogActions from "@mui/material/DialogActions"
5 | import DialogContent from "@mui/material/DialogContent"
6 | import DialogContentText from "@mui/material/DialogContentText"
7 | import DialogTitle from "@mui/material/DialogTitle"
8 | import { Typography } from "@mui/material"
9 | export default function DialogShowRecord(props) {
10 | return (
11 |
12 |
35 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Typeahead Search Component (React, Next, TypeScript)
2 |
3 | ## List of features for this example
4 |
5 |
6 | - Up and Down arrow keys to traverse Episodes
7 | - Enter key to choose single Episode.
8 |
9 | - Closes search and shows dialog box of selected Episode
10 | - Display the Title and its Director of the Episode in the search bar
11 |
12 |
13 | - Mouse click on filtered Episodes will close the search and display a dialog box of selected record.
14 | - 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.
15 |
16 | - Once the field is clear, programatically put the focus in the search field with useRef() hook.
17 | - Open search filter results section if any key is pressed while cursor is inside the search field.
18 |
19 |
20 |
21 |
22 | ## Running locally in development mode
23 |
24 | npm install
25 | npm run dev
26 |
27 | Note: If you are running on Windows run install --noptional flag (i.e. `npm install --no-optional`) which will skip installing fsevents.
28 |
29 | ## Building and deploying in production
30 |
31 | If you wanted to run this site in production, you should install modules then build the site with `npm run build` and run it with `npm start`:
32 |
33 | npm install
34 | npm run build
35 | npm start
36 |
37 | You should run `npm run build` again any time you make changes to the site.
38 |
39 | Note: If you are already running a webserver on port 80 (e.g. Macs usually have the Apache webserver running on port 80) you can still start the example in production mode by passing a different port as an Environment Variable when starting (e.g. `PORT=3000 npm start`).
40 |
--------------------------------------------------------------------------------
/components/search/SearchResultsGrid.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { useContext, useState, useEffect } from "react"
3 | import Box from "@mui/material/Box"
4 | import Grid from "@mui/material/Grid"
5 | import SearchResultList from "./PostSearchResultList"
6 | import { RootCompContext } from "@/pages/_app"
7 |
8 | export default function BasicGrid(props) {
9 | const { filteredPostsData, setFilteredPostsData } =
10 | useContext(RootCompContext)
11 | const [searchTerm, setSearchTerm] = useState(null)
12 |
13 | useEffect(() => {
14 | setFilteredPostsData(props.filteredPostsData)
15 | }, [props.data])
16 |
17 | useEffect(() => {
18 | setSearchTerm(props.searchTerm)
19 | }, [props.searchTerm])
20 |
21 | return (
22 | <>
23 | {props.showResults && searchTerm && filteredPostsData.length > 0 && (
24 | <>
25 |
40 |
41 |
42 | {Boolean(filteredPostsData) && (
43 |
46 | {filteredPostsData.length > 0 && (
47 | <>
48 |
54 | >
55 | )}
56 |
57 | )}
58 |
59 |
60 |
61 | >
62 | )}
63 | >
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 | .pnpm-debug.log*
9 |
10 | # Diagnostic reports (https://nodejs.org/api/report.html)
11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
12 |
13 | # Runtime data
14 | pids
15 | *.pid
16 | *.seed
17 | *.pid.lock
18 |
19 | # Directory for instrumented libs generated by jscoverage/JSCover
20 | lib-cov
21 |
22 | # Coverage directory used by tools like istanbul
23 | coverage
24 | *.lcov
25 |
26 | # nyc test coverage
27 | .nyc_output
28 |
29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
30 | .grunt
31 |
32 | # Bower dependency directory (https://bower.io/)
33 | bower_components
34 |
35 | # node-waf configuration
36 | .lock-wscript
37 |
38 | # Compiled binary addons (https://nodejs.org/api/addons.html)
39 | build/Release
40 |
41 | # Dependency directories
42 | node_modules/
43 | jspm_packages/
44 |
45 | # Snowpack dependency directory (https://snowpack.dev/)
46 | web_modules/
47 |
48 | # TypeScript cache
49 | *.tsbuildinfo
50 |
51 | # Optional npm cache directory
52 | .npm
53 |
54 | # Optional eslint cache
55 | .eslintcache
56 |
57 | # Optional stylelint cache
58 | .stylelintcache
59 |
60 | # Microbundle cache
61 | .rpt2_cache/
62 | .rts2_cache_cjs/
63 | .rts2_cache_es/
64 | .rts2_cache_umd/
65 |
66 | # Optional REPL history
67 | .node_repl_history
68 |
69 | # Output of 'npm pack'
70 | *.tgz
71 |
72 | # Yarn Integrity file
73 | .yarn-integrity
74 |
75 | # dotenv environment variable files
76 | .env
77 | .env.development.local
78 | .env.test.local
79 | .env.production.local
80 | .env.local
81 |
82 | # parcel-bundler cache (https://parceljs.org/)
83 | .cache
84 | .parcel-cache
85 |
86 | # Next.js build output
87 | .next
88 | out
89 |
90 | # Nuxt.js build / generate output
91 | .nuxt
92 | dist
93 |
94 | # Gatsby files
95 | .cache/
96 | # Comment in the public line in if your project uses Gatsby and not Next.js
97 | # https://nextjs.org/blog/next-9-1#public-directory-support
98 | # public
99 |
100 | # vuepress build output
101 | .vuepress/dist
102 |
103 | # vuepress v2.x temp and cache directory
104 | .temp
105 | .cache
106 |
107 | # Docusaurus cache and generated files
108 | .docusaurus
109 |
110 | # Serverless directories
111 | .serverless/
112 |
113 | # FuseBox cache
114 | .fusebox/
115 |
116 | # DynamoDB Local files
117 | .dynamodb/
118 |
119 | # TernJS port file
120 | .tern-port
121 |
122 | # Stores VSCode versions used for testing VSCode extensions
123 | .vscode-test
124 |
125 | # yarn v2
126 | .yarn/cache
127 | .yarn/unplugged
128 | .yarn/build-state.yml
129 | .yarn/install-state.gz
130 | .pnp.*
131 |
--------------------------------------------------------------------------------
/components/search/PostSearchResultList.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { useState, useEffect } from "react"
3 | import List from "@mui/material/List"
4 | import ListItemButton from "@mui/material/ListItemButton"
5 | import ListItemIcon from "@mui/material/ListItemIcon"
6 | import { useContext } from "react"
7 | import { RootCompContext } from "@/pages/_app"
8 | import { Typography, Box } from "@mui/material"
9 | import Highlighter from "react-highlight-words"
10 | import { AdvancedImage } from "@cloudinary/react"
11 | import { Cloudinary } from "@cloudinary/url-gen"
12 | import { fill } from "@cloudinary/url-gen/actions/resize"
13 | import { CLOUND_NAME } from "../../constants"
14 |
15 | export default function PostSearchResultList(props) {
16 | const [searchTerms, setSearchTerms] = useState([])
17 | const {
18 | searchTerm,
19 | filteredPostsData,
20 | maxRecordsReturned,
21 | arrowKeyItemIndex,
22 | arrowKeyLateralListIndex,
23 | } = useContext(RootCompContext)
24 |
25 | useEffect(() => {
26 | let searchStr: string[] = []
27 | searchStr.push(`${props.searchTerm}`)
28 |
29 | setSearchTerms(searchStr)
30 | }, [props.searchTerm])
31 |
32 | const cld = new Cloudinary({
33 | cloud: {
34 | cloudName: CLOUND_NAME,
35 | },
36 | })
37 |
38 | return (
39 | <>
40 | {filteredPostsData.length > 0 && searchTerm.length > 0 && (
41 | <>
42 |
43 | Search Results: {filteredPostsData.length || 0}
44 |
45 |
46 |
53 | {filteredPostsData
54 | .slice(0, maxRecordsReturned)
55 | .map((item, index) => (
56 |
70 | props.handleSelectedPost(item.id, item.title, item.director)
71 | }
72 | >
73 |
74 |
79 |
80 |
81 |
82 |
86 |
91 |
92 |
96 |
101 |
102 |
103 |
104 | ))}
105 |
106 | >
107 | )}
108 | >
109 | )
110 | }
111 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { createContext, useState, useEffect, useMemo } from "react"
3 | import PropTypes from "prop-types"
4 | import Head from "next/head"
5 | import CssBaseline from "@mui/material/CssBaseline"
6 | import { CacheProvider } from "@emotion/react"
7 | import createEmotionCache from "../src/createEmotionCache"
8 | import { ThemeProvider, createTheme } from "@mui/material/styles"
9 | import { red } from "@mui/material/colors"
10 | import CircularProgress from "@mui/material/CircularProgress"
11 | import Box from "@mui/material/Box"
12 | import AppLayout from "../components/AppLayout"
13 | import NextNProgress from "nextjs-progressbar"
14 | import useSWR from "swr"
15 | import "../styles/globals.css"
16 | import { styled, keyframes } from "@mui/system"
17 |
18 | const fetcher = (url) => fetch(url).then((res) => res.json())
19 | const clientSideEmotionCache = createEmotionCache()
20 |
21 | export const RootCompContext = createContext(null)
22 | export default function MainApp(props) {
23 | const { Component, emotionCache = clientSideEmotionCache, pageProps } = props
24 |
25 | const { data, isLoading, error } = useSWR("/api/getData", fetcher)
26 | const [darkState, setDarkState] = useState(false)
27 | const [searchTerm, setSearchTerm] = useState("")
28 | const [filteredResults, setFilteredResults] = useState([])
29 | const [postsData, setPostsData] = useState([])
30 | const [filteredPostsData, setFilteredPostsData] = useState([])
31 |
32 | const [arrowKeyLateralListIndex, setArrowKeyLateralListIndex] =
33 | useState(0)
34 | const [arrowKeyItemIndex, setArrowKeyItemIndex] = useState(0)
35 | const [arrowKeyLateralItemIndex, setArrowKeyLateralItemIndex] =
36 | useState(0)
37 | const [mounted, setMounted] = useState(false)
38 |
39 | useEffect(() => {
40 | setMounted(true)
41 | if (data) {
42 | let titles = []
43 | data.map((item) => {
44 | titles.push({
45 | id: item.episode_id,
46 | title: item.title,
47 | director: item.director,
48 | })
49 | })
50 | setPostsData(titles)
51 | }
52 |
53 | return () => {}
54 | }, [data])
55 |
56 | const theme = useMemo(
57 | () =>
58 | createTheme({
59 | palette: {
60 | primary: {
61 | main: "#4674c3",
62 | },
63 | secondary: {
64 | main: "#11cb5f",
65 | },
66 | warning: {
67 | main: red[300],
68 | },
69 | mode: darkState ? "dark" : "light",
70 | },
71 | }),
72 | [darkState]
73 | )
74 |
75 | const handleThemeChange = () => {
76 | setDarkState(!darkState)
77 | }
78 |
79 | if (isLoading) {
80 | return (
81 |
96 |
97 |
98 | Loading
99 | .
100 | .
101 | .
102 |
103 |
104 | )
105 | }
106 |
107 | return (
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
122 |
139 |
140 |
145 |
146 | >
147 | }
148 | />
149 |
150 |
151 |
152 |
153 | )
154 | }
155 |
156 | MainApp.propTypes = {
157 | Component: PropTypes.elementType.isRequired,
158 | emotionCache: PropTypes.object,
159 | pageProps: PropTypes.object.isRequired,
160 | }
161 |
162 | const blink = keyframes`
163 | 0% { opacity: 0; }
164 | 50% { opacity: 1; }
165 | 100% { opacity: 0; }
166 | `
167 | const LoadingText = styled("div")(({ theme }) => ({
168 | display: "flex",
169 | justifyContent: "center",
170 | alignItems: "center",
171 | marginTop: theme.spacing(2),
172 | }))
173 |
174 | const Dot = styled("span")(({ theme }) => ({
175 | animation: `${blink} 1.4s infinite both`,
176 | animationDelay: "0s, 0.2s, 0.4s",
177 | margin: `0 ${theme.spacing(0.2)}`,
178 | fontSize: "1.5rem",
179 | "&:nth-of-type(2)": {
180 | animationDelay: "0.2s",
181 | },
182 | "&:nth-of-type(3)": {
183 | animationDelay: "0.4s",
184 | },
185 | }))
186 |
--------------------------------------------------------------------------------
/components/search/LayoutSearchField.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { useContext, useRef, useEffect, useState } from "react"
3 | import { RootCompContext } from "@/pages/_app"
4 | import { alpha, styled } from "@mui/material/styles"
5 | import InputBase from "@mui/material/InputBase"
6 | import SearchIcon from "@mui/icons-material/Search"
7 | import ClearIcon from "@mui/icons-material/Clear"
8 | import { Box, Stack } from "@mui/material"
9 | import ClickAwayListener from "@mui/base/ClickAwayListener"
10 | import SearchResultsGrid from "./SearchResultsGrid"
11 | import DialogShowRecord from "./DialogShowRecord"
12 |
13 | const Search = styled("div")(({ theme }) => ({
14 | position: "relative",
15 |
16 | borderRadius: theme.shape.borderRadius,
17 | backgroundColor: alpha(theme.palette.common.white, 0.15),
18 | "&:hover": {
19 | backgroundColor: alpha(theme.palette.common.white, 0.25),
20 | },
21 | marginRight: theme.spacing(2),
22 | marginLeft: 0,
23 | width: "100%",
24 | [theme.breakpoints.up("sm")]: {
25 | width: "auto",
26 | },
27 | [theme.breakpoints.down("sm")]: {
28 | width: "auto",
29 | },
30 | }))
31 |
32 | const SearchIconWrapper = styled("div")(({ theme }) => ({
33 | padding: theme.spacing(0, 2),
34 | height: "100%",
35 | position: "absolute",
36 | pointerEvents: "none",
37 | display: "flex",
38 | alignItems: "center",
39 | justifyContent: "center",
40 | }))
41 |
42 | const StyledInputBase = styled(InputBase)(({ theme }) => ({
43 | color: "inherit",
44 | "& .MuiInputBase-input": {
45 | padding: theme.spacing(1, 1, 1, 0),
46 | paddingLeft: `calc(1em + ${theme.spacing(4)})`,
47 | transition: theme.transitions.create("width"),
48 | width: "100%",
49 | [theme.breakpoints.up("md")]: {
50 | width: "100%",
51 | },
52 | },
53 | }))
54 |
55 | const SearchClearIconWrapper = styled("div")(({ theme }) => ({
56 | padding: theme.spacing(0, 1),
57 | height: "100%",
58 | position: "absolute",
59 | display: "flex",
60 | alignItems: "center",
61 | justifyContent: "center",
62 | right: 1,
63 | cursor: "pointer",
64 | zIndex: 99,
65 | }))
66 |
67 | interface IPostRecord {
68 | id: number
69 | title: string
70 | director: string
71 | }
72 |
73 | export default function LayoutSearchField(props) {
74 | var searchInputRef = useRef()
75 | const [showRecordDialog, setShowRecordDialog] = useState(false)
76 | const [showResults, setShowResults] = useState(false)
77 |
78 | const [chosenRecord, setChosenRecord] = useState(
79 | undefined
80 | )
81 | const {
82 | searchTerm,
83 | setSearchTerm,
84 | postsData,
85 | setFilteredPostsData,
86 | filteredPostsData,
87 | arrowKeyItemIndex,
88 | setArrowKeyItemIndex,
89 | setSelectProduct,
90 | } = useContext(RootCompContext)
91 |
92 | const arrowUpPressed = useKeyPress("ArrowUp")
93 | const arrowDownPressed = useKeyPress("ArrowDown")
94 | const enterKeyPressed = useKeyPress("Enter")
95 | const [counter, setCounter] = useState(0)
96 | const [displaySearchTerm, setDisplaySearchTerm] = useState("")
97 |
98 | useEffect(() => {
99 | if (arrowUpPressed) {
100 | if (arrowKeyItemIndex > 0) {
101 | setCounter(counter - 1)
102 | setArrowKeyItemIndex(arrowKeyItemIndex - 1)
103 | }
104 | setDisplaySearchTerm(filteredPostsData[arrowKeyItemIndex].title)
105 | }
106 | }, [arrowUpPressed])
107 |
108 | useEffect(() => {
109 | if (arrowDownPressed) {
110 | if (arrowKeyItemIndex <= filteredPostsData.length - 2) {
111 | setCounter(counter + 1)
112 | setArrowKeyItemIndex(arrowKeyItemIndex + 1)
113 | }
114 | setDisplaySearchTerm(filteredPostsData[arrowKeyItemIndex].title)
115 | }
116 | }, [arrowDownPressed])
117 |
118 | useEffect(() => {
119 | if (enterKeyPressed) {
120 | const { title } = filteredPostsData[arrowKeyItemIndex]
121 | setChosenRecord(filteredPostsData[arrowKeyItemIndex])
122 | setDisplaySearchTerm(title)
123 | setSearchTerm(title)
124 | setShowResults(false)
125 | setShowRecordDialog(true)
126 | }
127 | }, [enterKeyPressed])
128 |
129 | const handleCloseRecordDialogBox = () => {
130 | setShowRecordDialog(false)
131 | }
132 |
133 | const handleSelectedPost = (id, title, director) => {
134 | const chosenRecord: IPostRecord = {
135 | id: id,
136 | title: title,
137 | director: director,
138 | }
139 | setChosenRecord(chosenRecord)
140 | setShowRecordDialog(true)
141 | }
142 |
143 | useEffect(() => {
144 | setArrowKeyItemIndex(0)
145 | }, [searchTerm])
146 |
147 | const handleSearchTerm = (e) => {
148 | const newSearchTerm = e.target.value
149 | setSearchTerm(newSearchTerm)
150 |
151 | if (!showResults) {
152 | setShowResults(true)
153 | }
154 |
155 | if (newSearchTerm.length === 0) {
156 | setFilteredPostsData([])
157 | return
158 | }
159 |
160 | const filteredPosts = postsData.filter((element) =>
161 | [element.title, element.director].some((text) =>
162 | text.toLowerCase().includes(newSearchTerm.toLowerCase())
163 | )
164 | )
165 | setFilteredPostsData(filteredPosts.slice(0, 9))
166 | }
167 |
168 | const handleOpenSearchResults = () => {
169 | setShowResults(true)
170 | }
171 |
172 | const handleSelectedProduct = (id) => {
173 | setSelectProduct(id)
174 | setShowResults(false)
175 | }
176 |
177 | const handleClearSearchTerm = (e) => {
178 | setSearchTerm("")
179 | setDisplaySearchTerm("")
180 | setFilteredPostsData([])
181 | searchInputRef.current.focus()
182 | }
183 |
184 | return (
185 | <>
186 |
196 | setShowResults(false)}>
197 |
198 |
199 |
200 |
201 |
202 |
203 | {searchTerm.length > 0 && (
204 |
205 | handleClearSearchTerm(e)} />
206 |
207 | )}
208 | {
210 | searchInputRef.current = input
211 | }}
212 | sx={{ width: "100%", paddingRight: "30px" }}
213 | placeholder="Search Products"
214 | inputProps={{ "aria-label": "search google maps" }}
215 | onChange={(e) => handleSearchTerm(e)}
216 | onFocus={() => handleOpenSearchResults()}
217 | value={searchTerm}
218 | />
219 |
220 |
221 |
227 |
228 |
229 |
230 |
231 | {chosenRecord && (
232 |
237 | )}
238 | >
239 | )
240 | }
241 |
242 | const useKeyPress = (targetKey) => {
243 | const [keyPressed, setKeyPressed] = useState(false)
244 |
245 | useEffect(() => {
246 | const downHandler = ({ key }) => {
247 | if (key === targetKey) {
248 | setKeyPressed(true)
249 | }
250 | }
251 |
252 | const upHandler = ({ key }) => {
253 | if (key === targetKey) {
254 | setKeyPressed(false)
255 | }
256 | }
257 |
258 | window.addEventListener("keydown", downHandler)
259 | window.addEventListener("keyup", upHandler)
260 |
261 | return () => {
262 | window.removeEventListener("keydown", downHandler)
263 | window.removeEventListener("keyup", upHandler)
264 | }
265 | }, [targetKey])
266 |
267 | return keyPressed
268 | }
269 |
--------------------------------------------------------------------------------