diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 453c742..575a21e 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -2,6 +2,7 @@ import "./input.scss"; import { fetchData } from "../../utils/fetch-data"; import { debounce } from "../../utils/deboucne"; import Loader from "../Loader"; +import { ChangeEvent, useCallback, useState } from "react"; export interface InputProps { /** Placeholder of the input */ @@ -12,12 +13,78 @@ export interface InputProps { const Input = ({ placeholder, onSelectItem }: InputProps) => { // DO NOT remove this log - console.log('input re-render') + console.log("input re-render"); + + const [tempValue, setTempValue] = useState(""); + const [isSearching, setIsSearching] = useState(false); + const [searchData, setSearchData] = useState([]); + + const handleCallAPI = useCallback(async (searchTerm: string) => { + if (!searchTerm) return; + setIsSearching(true); + try { + const response = await fetchData(searchTerm); + console.log(response); + setSearchData(response); + } catch (error) { + console.log("error when searching", error); + } finally { + setIsSearching(false); + } + }, []); + + const renderResultBlock = (searchData: string[]) => { + return ( + <> + {searchData?.length ? ( +
+ {searchData.map((item, index) => { + return ( +
+ ) => { + e.stopPropagation(); + onSelectItem(item); + }} + > + {item} +
+ ); + })} +
+ ) : ( +
+ No data matching your search. Pleas try again! +
+ )} + + ); + }; // Your code start here - return + return ( +
+ ) => { + e.preventDefault(); + const searchTerm = e.target?.value; + setTempValue(searchTerm); + handleCallAPI(searchTerm || ""); + }, 300)} + className={"input-search-component__input-search"} + /> + {!!tempValue && ( +
+ {isSearching ? : renderResultBlock(searchData)} +
+ )} +
+ ); // Your code end here }; export default Input; - diff --git a/src/components/Input/input.scss b/src/components/Input/input.scss index 1dafbe7..edbe97b 100644 --- a/src/components/Input/input.scss +++ b/src/components/Input/input.scss @@ -4,3 +4,45 @@ html{ font-size: 16px; } + +.input-search-component { + position: relative; + + &__input-search { + height: 48px; + border: 1px solid #333; + border-radius: 5px; + font-size: 16px; + font-weight: 500; + padding-inline: 8px; + width: 300px; + } + + &__result-block { + position: absolute; + top: 56px; + left: 0; + border-radius: 5px; + width: 100%; + min-height: 80px; + max-height: 300px; + overflow: auto; + + &--no-data { + text-align: center; + } + + &--has-data { + border: 1px solid #999; + } + } + + &__result-item { + padding: 12px 16px; + cursor: pointer; + + &:hover { + background-color: #ddd; + } + } +} diff --git a/src/components/TodoMVC/TodoFilterOption.tsx b/src/components/TodoMVC/TodoFilterOption.tsx new file mode 100644 index 0000000..9f7ed1d --- /dev/null +++ b/src/components/TodoMVC/TodoFilterOption.tsx @@ -0,0 +1,30 @@ +import "./todoMVC.scss"; + +interface ITodoFilterOptionProps { + onFilteringTodoTask: (id: string) => void; + todoListToShow: string; + isActive: boolean; +} + +const TodoFilterOption = ({ + onFilteringTodoTask, + todoListToShow = '', + isActive = false, +}: ITodoFilterOptionProps) => { + console.log(todoListToShow); + + return ( +
{ + onFilteringTodoTask(todoListToShow); + }} + > + {todoListToShow} +
+ ); +}; + +export default TodoFilterOption; diff --git a/src/components/TodoMVC/TodoItem.tsx b/src/components/TodoMVC/TodoItem.tsx new file mode 100644 index 0000000..c632e86 --- /dev/null +++ b/src/components/TodoMVC/TodoItem.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { ITodoListProps } from "."; +import "./todoMVC.scss"; + +interface ITodoItemProps { + todoItem: ITodoListProps; + onChangingTodoTaskStatus: (id: number) => void; + onRemovingTodoTask: (id: number) => void; + onChangingTodoTaskValue: (id: number, value: string) => void; +} + +const TodoItem = ({ + todoItem, + onChangingTodoTaskStatus, + onRemovingTodoTask, + onChangingTodoTaskValue, +}: ITodoItemProps) => { + const [isDoubleClicked, setIsDoubleClicked] = useState(false); + + const handleOnDoubleClickTodoItem = (e: React.UIEvent) => { + if (e.detail === 2) { + console.log("double click"); + setIsDoubleClicked(true); + setTimeout(() => { + document.getElementById(`todo-label-${todoItem.id}`)?.focus(); + }, 300); + } + }; + + const handleOnBlurTodoItem = () => { + setIsDoubleClicked(false); + }; + + const handleChangeTodoItem = ( + e: + | React.ChangeEvent + | React.KeyboardEvent + | React.BaseSyntheticEvent + ) => { + if (e.type === "keydown" && (e as React.KeyboardEvent).key === "Enter") { + if (!e?.target?.value) return; + if (!onChangingTodoTaskValue) return; + + onChangingTodoTaskValue(todoItem.id, e?.target?.value); + setIsDoubleClicked(false); + } + }; + + return ( +
+ {!isDoubleClicked && ( + <> + + + + + )} + {isDoubleClicked && ( +
+ +
+ )} +
+ ); +}; + +export default TodoItem; diff --git a/src/components/TodoMVC/index.tsx b/src/components/TodoMVC/index.tsx new file mode 100644 index 0000000..11ed58b --- /dev/null +++ b/src/components/TodoMVC/index.tsx @@ -0,0 +1,211 @@ +import TodoFilterOption from "./TodoFilterOption"; +import TodoItem from "./TodoItem"; +import "./todoMVC.scss"; +import { useEffect, useState } from "react"; + +export interface InputProps { + /** Placeholder of the input */ + placeholder?: string; +} + +export interface ITodoListProps { + value: string; + isCompleted: boolean; + id: number; +} + +let isTheFirstHit = true; + +const TodoMVC = () => { + const [todoList, setTodoList] = useState([]); + const [todoCompletedList, setTodoCompletedList] = useState( + [] + ); + const [todoInCompleteList, setTodoInCompleteList] = useState< + ITodoListProps[] + >([]); + const [inputValue, setInputValue] = useState(""); + const [todoListToShow, setTodoListToShow] = useState("All"); + const [isCheckedAll, setIsCheckedAll] = useState( + isTheFirstHit && !todoList.some((todo) => !todo.isCompleted) + ); + const listStatus = ["All", "Active", "Completed"]; + + const handleInput = ( + e: + | React.ChangeEvent + | React.KeyboardEvent + | React.BaseSyntheticEvent + ) => { + if (e.type === "change") { + setInputValue(e.target.value); + } + + if (e.type === "keydown" && (e as React.KeyboardEvent).key === "Enter") { + if (!e?.target?.value) return; + const preparedTodoTask = { + isCompleted: false, + value: e?.target?.value, + id: Math.floor(Math.random() * 1000) + 1, + }; + setTodoList((prevState) => [...prevState, preparedTodoTask]); + setInputValue(""); + } + }; + + const onChangingTodoTaskStatus = (id: number) => { + if (!todoList?.length) return; + setTodoList((prevState) => + prevState.map((todo) => + todo.id === id ? { ...todo, isCompleted: !todo.isCompleted } : todo + ) + ); + }; + + const onRemovingTodoTask = (id: number) => { + if (!todoList?.length) return; + setTodoList((prevState) => prevState.filter((todo) => todo.id !== id)); + }; + + const onClearingCompleteTask = () => { + setTodoList((prevState) => prevState.filter((todo) => !todo.isCompleted)); + }; + + const onFilteringTodoTask = (type = "All") => { + console.log(type); + + switch (type) { + case "Active": + setTodoListToShow("Active"); + break; + case "Completed": + setTodoListToShow("Completed"); + break; + + default: + setTodoListToShow("All"); + break; + } + }; + + const onMarkDoneAllTasks = () => { + if (isTheFirstHit) { + setIsCheckedAll(true); + setTodoList((prevState) => + prevState.map((todo) => { + return { ...todo, isCompleted: true }; + }) + ); + isTheFirstHit = false; + } else { + setIsCheckedAll(!isCheckedAll); + setTodoList((prevState) => + prevState.map((todo) => { + return { ...todo, isCompleted: !isCheckedAll }; + }) + ); + } + }; + + const onChangingTodoTaskValue = (id: number, value: string) => { + setTodoList((prevState) => + prevState.map((todo) => + todo.id === id ? { ...todo, value: value } : todo + ) + ); + }; + + useEffect(() => { + const itemTodoList = JSON.parse( + localStorage.getItem("todoList") as string + ) as ITodoListProps[]; + if (itemTodoList) { + setTodoList(itemTodoList); + } + }, []); + + useEffect(() => { + localStorage?.setItem("todoList", JSON.stringify(todoList)); + if (!todoList?.length) return; + setTodoCompletedList(todoList.filter((todo) => todo.isCompleted)); + setTodoInCompleteList(todoList.filter((todo) => !todo.isCompleted)); + }, [todoList]); + + // Your code start here + return ( +
+
+
{ + onMarkDoneAllTasks(); + }} + >
+ +
+ {!!todoList?.length && ( +
+ {!!listStatus?.length && + listStatus.map((status) => { + const isShow = todoListToShow === status; + const newTodoList = + todoListToShow === "All" + ? todoList + : todoListToShow === "Active" + ? todoInCompleteList + : todoCompletedList; + return ( + isShow && + !!newTodoList?.length && + newTodoList.map((item) => { + return ( + + ); + }) + ); + })} +
+
+ {todoInCompleteList?.length || 0} item + {todoInCompleteList.length > 1 ? "s" : ""} left! +
+
+ {!!listStatus?.length && + listStatus.map((status) => { + return ( + + ); + })} +
+
+ Clear completed +
+
+
+ )} +
+ ); + // Your code end here +}; + +export default TodoMVC; diff --git a/src/components/TodoMVC/todoMVC.scss b/src/components/TodoMVC/todoMVC.scss new file mode 100644 index 0000000..30d3f3d --- /dev/null +++ b/src/components/TodoMVC/todoMVC.scss @@ -0,0 +1,162 @@ +* { + box-sizing: border-box; +} +html { + font: 14px Helvetica Neue, Helvetica, Arial, sans-serif; +} + +@mixin flexbox($direction: row, $justify: center, $align: center) { + display: flex; + direction: $direction; + justify-content: $justify; + align-items: $align; +} + +.todo-mvc-component { + position: relative; + + &__input { + height: 48px; + border: 1px solid #333; + border-radius: 5px; + font-size: 16px; + font-weight: 500; + padding-left: 40px; + padding-right: 8px; + width: 400px; + + &--wrapper { + position: relative; + } + } + + &__tick-all-btn { + position: absolute; + width: 40px; + height: 40px; + left: 0; + top: 3px; + cursor: pointer; + @include flexbox(); + + &:before { + color: #949494; + content: "❯"; + display: inline-block; + font-size: 18px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + } + } + + &__todo-list { + position: absolute; + top: 56px; + left: 0; + border-radius: 5px; + width: 100%; + min-height: 40px; + max-height: 400px; + border: 1px solid #999; + } + + &__todo-item { + @include flexbox($align: center); + padding: 12px 16px; + gap: 8px; + border-bottom: 1px solid #ededed; + position: relative; + min-height: 66px; + &:hover { + --set-todo-mvc-component__remove-btn--opacity: 1; + } + } + + &__checkbox-btn { + @include flexbox(); + background: none; + border: none; + padding: 0; + cursor: pointer; + + &__icon { + width: 40px; + height: 40px; + background-position: 0; + background-repeat: no-repeat; + + &--completed { + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E"); + } + + &--not-yet { + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E"); + } + } + } + + &__remove-btn { + @include flexbox(); + background: none; + border: none; + padding: 0; + margin-left: auto; + cursor: pointer; + opacity: var(--set-todo-mvc-component__remove-btn--opacity, 0); + } + + &__footer { + @include flexbox($justify: space-between); + padding: 12px 16px; + font-size: 12px; + } + + &__item-count { + padding-inline: 2px; + } + + &__filters { + @include flexbox(); + gap: 4px; + } + + &__filter { + padding: 2px 3px; + border-radius: 3px; + cursor: pointer; + border: 1px solid transparent; + + &:hover, + &.active { + border-color: #cb9295; + } + } + + &__clear-complete-btn { + cursor: pointer; + } + + &__todo-item-input { + border: none; + padding-inline: 20px; + font-size: 16px; + width: 100%; + height: 100%; + min-height: 65px; + + &:focus { + border: none; + outline: none; + } + + &--wrapper { + @include flexbox(); + position: absolute; + inset: 0 0 0 0; + } + } + + &__todo-label { + width: 100%; + } +} diff --git a/src/stories/TodoMVC.stories.ts b/src/stories/TodoMVC.stories.ts new file mode 100644 index 0000000..d92259a --- /dev/null +++ b/src/stories/TodoMVC.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import TodoMVC from "../components/TodoMVC"; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = { + title: "Example/TodoMVC", + component: TodoMVC, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: "centered", + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ["autodocs"], + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args + // args: { onClick: fn() }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const Primary: Story = { + args: { + placeholder: "Type something to search...", + }, +};