diff --git a/src/App.css b/src/App.css index b9d355d..56e3d65 100644 --- a/src/App.css +++ b/src/App.css @@ -1,42 +1,134 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; +* { + box-sizing: border-box } -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; +html { + font-size: 16px } -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); + +body { + justify-content: center; +} + +.search-result { + width: 100%; + position: absolute; + border-radius: 4px; + min-height: 100px; + border: solid 1px #ddd; + margin-top: 4px; + max-height: 400px; + overflow-y: auto; +} + +.lists { + display: flex; + flex-direction: column } -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); + +.item { + padding: 1em 2em +} + +.item:hover { + background-color: #eee; + cursor: pointer +} + +.no-result { + font-style: italic; + color: #333; + text-align: center } -@keyframes logo-spin { - from { - transform: rotate(0deg); +.error-message { + color: red; + font-style: italic; + padding: .5em 1em +} + +.loader-container { + height: 50px; + width: 50px; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(.8) +} + +.loader { + animation: rotate 1s infinite; + height: 50px; + width: 50px; + position: absolute +} + +.loader:before, +.loader:after { + border-radius: 50%; + content: ""; + display: block; + height: 20px; + width: 20px +} + +.loader:before { + animation: ball1 1s infinite; + background-color: #ccc; + box-shadow: 30px 0 #333; + margin-bottom: 10px +} + +.loader:after { + animation: ball2 1s infinite; + background-color: #333; + box-shadow: 30px 0 #ccc +} + +@keyframes rotate { + 0% { + transform: rotate(0) scale(.8) + } + + 50% { + transform: rotate(360deg) scale(1.2) } + to { - transform: rotate(360deg); + transform: rotate(720deg) scale(.8) } } -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; +@keyframes ball1 { + 0% { + box-shadow: 30px 0 #333 } -} -.card { - padding: 2em; -} + 50% { + box-shadow: 0 0 #333; + margin-bottom: 0; + transform: translate(15px, 15px) + } -.read-the-docs { - color: #888; + to { + box-shadow: 30px 0 #333; + margin-bottom: 10px + } } + +@keyframes ball2 { + 0% { + box-shadow: 30px 0 #ccc + } + + 50% { + box-shadow: 0 0 #ccc; + margin-top: -20px; + transform: translate(15px, 15px) + } + + to { + box-shadow: 30px 0 #ccc; + margin-top: 0 + } +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index afe48ac..0e92994 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,34 +1,14 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' import './App.css' +import Input from './components/Input' function App() { - const [count, setCount] = useState(0) + + const handleSelectItem = (item: string) => { + console.log(item) + } return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- + ) } diff --git a/src/components/Input/SearchResults.tsx b/src/components/Input/SearchResults.tsx new file mode 100644 index 0000000..42047c0 --- /dev/null +++ b/src/components/Input/SearchResults.tsx @@ -0,0 +1,39 @@ +import React from "react" +import Loader from "../Loader" + +interface SearchResultsProps { + isLoading: boolean + errorMessage: string | null + searchResults: string[] + onSelectItem: (item: string) => void +} + +const SearchResults: React.FC = ({ + isLoading, + errorMessage, + searchResults, + onSelectItem +}) => { + return ( +
+ {isLoading && } + {errorMessage &&

{errorMessage}

} + {searchResults.length === 0 && !isLoading && !errorMessage && ( +

No results found

+ )} +
+ {searchResults.map((result, index) => ( +
onSelectItem(result)} + > + {result} +
+ ))} +
+
+ ) +} + +export default SearchResults \ No newline at end of file diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 453c742..949e2ef 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,23 +1,76 @@ -import "./input.scss"; -import { fetchData } from "../../utils/fetch-data"; -import { debounce } from "../../utils/deboucne"; -import Loader from "../Loader"; - +import "./input.scss" +import { useReducer, useEffect, useCallback } from "react" +import { fetchData } from "../../utils/fetch-data" +import { debounce } from "../../utils/deboucne" +import SearchResults from "./SearchResults" +import { reducer, initialState } from "./inputReducer" export interface InputProps { /** Placeholder of the input */ - placeholder?: string; + placeholder?: string /** On click item handler */ - onSelectItem: (item: string) => void; + onSelectItem: (item: string) => void } - +const DEBOUNCE_TIME = 500 const Input = ({ placeholder, onSelectItem }: InputProps) => { // DO NOT remove this log console.log('input re-render') - // Your code start here - return - // Your code end here -}; + // Your code starts here + const [state, dispatch] = useReducer(reducer, initialState) + const { searchQuery, searchResults, isLoading, errorMessage } = state + + useEffect(() => { + if (!searchQuery) { + return + } + + const startFetching = async () => { + try { + const results = await fetchData(searchQuery) + if (!ignore) { + dispatch({ type: "SET_SEARCH_RESULTS", payload: results }) + } + } catch (error: any) { + if (!ignore) { + dispatch({ type: "SET_ERROR", payload: error.message || "An error occurred" }) + } + } + } + let ignore = false + startFetching() -export default Input; + return () => { + ignore = true + } + }, [searchQuery]) + + const handleSearch = useCallback( + debounce((value: string) => { + dispatch({ type: "SET_SEARCH_QUERY", payload: value }) + }, DEBOUNCE_TIME), + [] + ) + + return ( +
+ handleSearch(e.target.value)} + className="form__field" + /> + + {searchQuery && ( + + )} + + ) + // Your code ends here +} +export default Input diff --git a/src/components/Input/input.scss b/src/components/Input/input.scss index 1dafbe7..c591652 100644 --- a/src/components/Input/input.scss +++ b/src/components/Input/input.scss @@ -1,6 +1,15 @@ -* { - box-sizing: border-box; -} -html{ - font-size: 16px; +//** variables +$input-text-color: #a3a3a3; + +.form { + position: relative; + &__field { + width: 360px; + background: #fff; + font: inherit; + border: 1px solid $input-text-color; + box-shadow: 0 6px 10px 0 rgba(0, 0, 0, .1); + outline: 0; + padding: 22px 18px; + } } diff --git a/src/components/Input/inputReducer.tsx b/src/components/Input/inputReducer.tsx new file mode 100644 index 0000000..17e3eff --- /dev/null +++ b/src/components/Input/inputReducer.tsx @@ -0,0 +1,34 @@ +type Action = + | { type: "SET_SEARCH_QUERY"; payload: string } + | { type: "SET_SEARCH_RESULTS"; payload: string[] } + | { type: "SET_LOADING"; payload: boolean } + | { type: "SET_ERROR"; payload: string | null } + +export interface State { + searchQuery: string + searchResults: string[] + isLoading: boolean + errorMessage: string | null +} + +export const initialState: State = { + searchQuery: "", + searchResults: [], + isLoading: false, + errorMessage: null, +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "SET_SEARCH_QUERY": + return { ...state, searchQuery: action.payload, searchResults: [], errorMessage: null, isLoading: true } + case "SET_SEARCH_RESULTS": + return { ...state, searchResults: action.payload, isLoading: false } + case "SET_LOADING": + return { ...state, isLoading: action.payload } + case "SET_ERROR": + return { ...state, errorMessage: action.payload, isLoading: false } + default: + return state + } +} \ No newline at end of file