├── .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 | 7 | 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 | 18 | 19 | Star Wars, Episode {props.data.id} 20 | 21 | 22 | 23 | 24 | {props.data.title} 25 | 26 | Director: {props.data.director} 27 | 28 | 29 | 30 | 33 | 34 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typeahead Search Component (React, Next, TypeScript) 2 | 3 | ## List of features for this example 4 | 5 |
    6 |
  1. Up and Down arrow keys to traverse Episodes
  2. 7 |
  3. Enter key to choose single Episode. 8 |
      9 |
    1. Closes search and shows dialog box of selected Episode
    2. 10 |
    3. Display the Title and its Director of the Episode in the search bar
    4. 11 |
    12 |
  4. 13 |
  5. Mouse click on filtered Episodes will close the search and display a dialog box of selected record.
  6. 14 |
  7. 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 |
    1. Once the field is clear, programatically put the focus in the search field with useRef() hook.
    2. 17 |
    3. Open search filter results section if any key is pressed while cursor is inside the search field.
    4. 18 |
    19 |
  8. 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 | --------------------------------------------------------------------------------