Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 119 additions & 27 deletions src/App.css
Original file line number Diff line number Diff line change
@@ -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
}
}
32 changes: 6 additions & 26 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
<Input placeholder='Type something to search...' onSelectItem={handleSelectItem} />
)
}

Expand Down
39 changes: 39 additions & 0 deletions src/components/Input/SearchResults.tsx
Original file line number Diff line number Diff line change
@@ -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<SearchResultsProps> = ({
isLoading,
errorMessage,
searchResults,
onSelectItem
}) => {
return (
<div className="search-result">
{isLoading && <Loader />}
{errorMessage && <p className="error-message">{errorMessage}</p>}
{searchResults.length === 0 && !isLoading && !errorMessage && (
<p className="no-result">No results found</p>
)}
<div className="lists">
{searchResults.map((result, index) => (
<div
className="item"
key={index}
onClick={() => onSelectItem(result)}
>
{result}
</div>
))}
</div>
</div>
)
}

export default SearchResults
79 changes: 66 additions & 13 deletions src/components/Input/index.tsx
Original file line number Diff line number Diff line change
@@ -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 <input></input>
// 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 (
<form className="form">
<input
placeholder={placeholder}
type="text"
onChange={(e) => handleSearch(e.target.value)}
className="form__field"
/>

{searchQuery && (
<SearchResults
isLoading={isLoading}
errorMessage={errorMessage}
searchResults={searchResults}
onSelectItem={onSelectItem}
/>
)}
</form>
)
// Your code ends here
}

export default Input
19 changes: 14 additions & 5 deletions src/components/Input/input.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
34 changes: 34 additions & 0 deletions src/components/Input/inputReducer.tsx
Original file line number Diff line number Diff line change
@@ -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
}
}