diff --git a/package.json b/package.json index 0198c7b..5c1daf7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "sass": "^1.79.4", - "sass-embedded": "^1.79.4" + "sass-embedded": "^1.79.4", + "zustand": "^5.0.0" }, "devDependencies": { "@chromatic-com/storybook": "1.9.0", diff --git a/src/components/Input/index.tsx b/src/components/Input/index.tsx index 453c742..a161272 100644 --- a/src/components/Input/index.tsx +++ b/src/components/Input/index.tsx @@ -1,7 +1,8 @@ -import "./input.scss"; -import { fetchData } from "../../utils/fetch-data"; -import { debounce } from "../../utils/deboucne"; -import Loader from "../Loader"; +import { ChangeEvent, useCallback, useEffect, useState } from 'react'; +import { debounce } from '../../utils/deboucne'; +import { fetchData } from '../../utils/fetch-data'; +import Loader from '../Loader'; +import './input.scss'; export interface InputProps { /** Placeholder of the input */ @@ -12,10 +13,66 @@ export interface InputProps { const Input = ({ placeholder, onSelectItem }: InputProps) => { // DO NOT remove this log - console.log('input re-render') + console.log('input re-render'); + const [searchTerm, setSearchTerm] = useState(''); + const [listResult, setListResult] = useState([]); + const [isSearching, setIsSearch] = useState(false); + + const handleClick = useCallback((item: string) => { + onSelectItem(item) + },[onSelectItem]) + + function ListResult() { + return ( + searchTerm === '' ? null : listResult.length > 0 ?
+ {listResult.map((item, index) =>
handleClick(item)} key={index}> + {item} +
)} +
+ :
No result
); + } + + const getData = async (searchTerm: string) => { + if(searchTerm === '') return + try { + setIsSearch(true) + const res = await fetchData(searchTerm); + setListResult(res); + } catch(error) {console.log('error', error); + } finally { + setIsSearch(false) + } + } + + const handleOnChange = (e: ChangeEvent) => { + e.preventDefault(); + setSearchTerm(e.target.value); + }; + + useEffect(() => { + let ignore = false; + if(!ignore) { + getData(searchTerm) + } + return () => { + ignore = true + }; + }, [searchTerm]) // Your code start here - return + return ( +
+ ) => handleOnChange(e), + 100 + )} + > + {isSearching ? : } +
+ ); // Your code end here }; diff --git a/src/components/Input/input.scss b/src/components/Input/input.scss index 1dafbe7..6516cf9 100644 --- a/src/components/Input/input.scss +++ b/src/components/Input/input.scss @@ -4,3 +4,33 @@ html{ font-size: 16px; } + +.input { + border-radius: 4px; + line-height: 1.5em; + font-size: 16px; + padding: .5em 1em; + width: 100%; +} + +.list-result { + border: solid 1px grey; +} + +.list-result-item { + padding: 1em 2em; + + &:hover { + background-color: #eee; + cursor: pointer; + } +} + +.no-result { + border: solid 1px grey; + font-style: italic; + color: #333; + text-align: center; + padding: 1em 0; +} + diff --git a/src/components/Todo/ResultItem.tsx b/src/components/Todo/ResultItem.tsx new file mode 100644 index 0000000..6f1793b --- /dev/null +++ b/src/components/Todo/ResultItem.tsx @@ -0,0 +1,69 @@ +import { useEffect, useRef, useState } from "react"; +import { newStateType, useDataStore } from "./store"; +import { useShallow } from "zustand/shallow"; + +const ResultItem = (props: newStateType) => { + const { name, id, active } = props; + const editInputRef = useRef(null); + const [editInputValue, setEditInputValue] = useState(name); + const [isEdit, setIsEdit] = useState(false); + const handleDoubleClick = () => { + setIsEdit(!isEdit); + }; + + const updateName = useDataStore(useShallow((state) => state.updateName)); + const toggle = useDataStore(useShallow((state) => state.toggle)) ; + const remove = useDataStore(useShallow((state) => state.removeData)) ; + + const editOnChange = (e: any) => { + setEditInputValue(e.target.value); + }; + + const editOnBlur = () => { + setIsEdit(false); + setEditInputValue(name); + }; + + useEffect(() => { + if(isEdit) { + editInputRef.current.focus() + } + },[isEdit]) + + + + return ( +
+ {isEdit ? ( + editOnChange(e)} + onKeyDown={(e: any) => { + if (e.key === "Enter") { + updateName({ id, active, name: editInputValue }); + setIsEdit(false); + } + }} + className="edit-input" + /> + ) : ( + <> + toggle(props)} + /> + +
remove(id)}>
+ + )} +
+ ); +}; + +export default ResultItem; diff --git a/src/components/Todo/Todo.scss b/src/components/Todo/Todo.scss new file mode 100644 index 0000000..a2a5245 --- /dev/null +++ b/src/components/Todo/Todo.scss @@ -0,0 +1,146 @@ +.wrapper { + position: relative; + width: 550px; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.active-all-cta { + position: absolute; + left: 0; + top: 0; + &:before { + content: "❯"; + display: inline-block; + font-size: 22px; + padding: 15px 22px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + } +} + +.header { + color: #111; + font: 14px Helvetica Neue, Helvetica, Arial, sans-serif; + font-weight: normal; + line-height: normal; + background: rgba(0, 0, 0, 0.003); + border: none; + box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + height: 65px; + padding: 16px 16px 16px 60px; + box-sizing: border-box; + color: inherit; + font-family: inherit; + font-size: 24px; + font-weight: inherit; + line-height: 1.4em; + margin: 0; + width: 100%; +} + +.body { + font-size: 24px; + position: relative; +} + +.toggle { + -webkit-appearance: none; + appearance: none; + border: none; + bottom: 0; + height: 40px; + background: none; + margin: auto 0; + opacity: 0; + position: absolute; + text-align: center; + top: 0; + width: 40px; +} + +.close-cta { + bottom: 0; + color: var(--set-close-cta--color, #949494); + display: var(--set-close-cta--display, none); + font-size: 30px; + height: 40px; + margin: auto 0; + position: absolute; + right: 10px; + top: 0; + transition: color 0.2s ease-out; + width: 40px; + + &:after { + content: "×"; + display: block; + height: 100%; + line-height: 1.1; + } +} + +.item { + position: relative; + &:hover { + --set-close-cta--display: block; + --set-close-cta--color: #c18585; + } +} + +.result-input, +.result-input--checked, +.edit-input { + background-position: 0; + background-repeat: no-repeat; + color: #484848; + display: block; + font-weight: 400; + line-height: 1.2; + padding: 15px 15px 15px 60px; + transition: color 0.4s; + font-size: 24px; + word-break: break-all; + width: 100%; + box-sizing: border-box; + border-bottom: 1px solid #ededed; +} + +.edit-input { + border: solid 1px red; +} + +.result-input { + 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"); +} + +.result-input--checked { + text-decoration: line-through; + 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"); +} + +.footer { + display: flex; + justify-content: space-between; + border: 2px solid #e6e6e6; + font-size: 15px; + padding: 10px 15px; + text-align: center; + &--center { + display: flex; + justify-content: space-between; + gap: 18px; + } + + &--right { + &:hover { + text-decoration: underline; + } + } +} + +.filter { + border: solid 1px transparent; + &-active { + border: solid 1px red; + } +} diff --git a/src/components/Todo/index.tsx b/src/components/Todo/index.tsx new file mode 100644 index 0000000..bbfec15 --- /dev/null +++ b/src/components/Todo/index.tsx @@ -0,0 +1,100 @@ +import { useMemo, useState } from "react"; +import ResultItem from "./ResultItem"; +import { useDataStore } from "./store"; +import { useShallow } from "zustand/shallow"; +import "./Todo.scss"; + +const Todo = () => { + const data = useDataStore(useShallow((state) => state.data)); + const [filter, setFilter] = useState("all"); + const getActiveData = useDataStore( + useShallow((state) => state.getActiveData) + ); + const addData = useDataStore(useShallow((state) => state.addData)); + const activeAll = useDataStore(useShallow((state) => state.activeAll)); + const [initialValue, setInitialValue] = useState(""); + + const handlerEnter = (e: any) => { + setInitialValue(""); + addData({ + name: e.target.value, + id: +new Date(), + active: true, + }); + }; + + const handleGetActiveData = (status: string) => { + if (status === "active") { + setFilter("active"); + } else if (status === "completed") { + setFilter("completed"); + } else setFilter("all"); + }; + + const onBlur = (e: any) => { + setInitialValue(e.target.value); + }; + + const listResult = useMemo(() => { + if (filter === "active") return data.filter((item) => item.active); + if (filter === "completed") return data.filter((item) => !item.active); + return data; + }, [filter, data]); + + return ( +
+ + { + if (e.key === "Enter") { + handlerEnter(e); + } + }} + onChange={(e) => onBlur(e)} + value={initialValue} + type="text" + className="header" + placeholder="What needs to be done?" + /> +
+ {!!listResult?.length && + listResult.map((item) => )} +
+ + {data.length > 0 && ( +
+
+ {data.filter((item) => item.active).length}{" "} + {listResult.length > 1 ? "items left!" : "item left!"} +
+
+
handleGetActiveData("all")} + > + All +
+
handleGetActiveData("active")} + > + Active +
+
handleGetActiveData("completed")} + > + Completed +
+
+
+ Clear Completed +
+
+ )} +
+ ); +}; +export default Todo; diff --git a/src/components/Todo/store.ts b/src/components/Todo/store.ts new file mode 100644 index 0000000..b3be271 --- /dev/null +++ b/src/components/Todo/store.ts @@ -0,0 +1,60 @@ +import { create } from "zustand"; + +export type newStateType = { + name: string; + id: number; + active: boolean; +}; + +export type dataType = { + data: newStateType[]; + addData: (newState: newStateType) => void; + removeData: (id: number) => void; + updateName: (newState: newStateType) => void; + toggle: (newState: newStateType) => void; + getActiveData: () => void; + activeAll: () => void; +}; + +export const useDataStore = create((set) => ({ + data: [], + addData: (newState: newStateType) => { + set((state) => ({ data: [...state.data, newState] })); + }, + removeData: (id: number) => { + set((state) => ({ + data: state.data.filter((item) => item.id !== id), + })); + }, + updateName: (newState: newStateType) => { + set((state) => ({ + data: state.data.map((item) => + item.id === newState.id ? { ...item, name: newState.name } : item + ), + })); + }, + toggle: (newState: newStateType) => { + set((state) => { + return { + data: state.data.map((item) => + item.id === newState.id ? { ...item, active: !newState.active } : item + ), + }; + }); + }, + getActiveData: () => { + set((state) => ({ + data: state.data.filter((item) => item.active), + })); + }, + activeAll: () => { + set((state) => ({ + data: state.data.map(item => { + if(state.data.findIndex(i => i.active) > -1) { + return {...item, active: false} + } + return {...item, active: true} + }) + })); + } +})); diff --git a/src/stories/GuideLine.mdx b/src/stories/GuideLine.mdx index 2bc6338..71f111d 100644 --- a/src/stories/GuideLine.mdx +++ b/src/stories/GuideLine.mdx @@ -44,6 +44,6 @@ The name of the PR is `[Your name] - [Your work email (**without @niteco.se**)]` ## How to deploy - Deploy to a free online hosting like Vercel, Netlify, Cloudflare Page... - Build command: `yarn build-storybook` -- Folder to deploy: `storybook-static` +- Folder to deploy: `storybook-static ` ### Don't forget to submit your works by filling the information on the Excel file \ No newline at end of file diff --git a/src/stories/Todo.stories.ts b/src/stories/Todo.stories.ts new file mode 100644 index 0000000..1d1cce4 --- /dev/null +++ b/src/stories/Todo.stories.ts @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import Todo from "../components/Todo"; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = { + title: "Example/Todo", + component: Todo, + 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...", + }, +}; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index f68b513..d9c4cce 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3913,3 +3913,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zustand@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.0.tgz#71f8aaecf185592a3ba2743d7516607361899da9" + integrity sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==