diff --git a/README.md b/README.md index 903c876f9..6fc5843b5 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th - Implement a solution following the [React task guidelines](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). - Open another terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your GitHub username in the [DEMO LINK](https://.github.io/react_todo-app/) and add it to the PR description. +- Replace `` with your GitHub username in the [DEMO LINK](https://shymdima.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..d44ecaf0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,23 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { TodoList } from './components/TodoList'; +import { useLocalStorageContext } from './context/context'; export const App: React.FC = () => { + const { todos } = useLocalStorageContext(); + + localStorage.removeItem('todoss'); + return (

todos

- +
-
- {/* this button should have `active` class only if all todos are completed */} -
- -
- {/* This is a completed todo */} -
- - - - Completed Todo - - - {/* Remove button appears only on hover */} - -
- - {/* This todo is an active todo */} -
- - - - Not Completed Todo - - - -
- - {/* This todo is being edited */} -
- - - {/* This form is shown instead of the title and remove button */} -
- -
-
- - {/* This todo is in loadind state */} -
- - - - Todo is being saved now - - - -
-
- - {/* Hide the footer if there are no todos */} -
- - 3 items left - - - {/* Active link should have the 'selected' class */} - + - {/* this button should be disabled if there are no completed todos */} - -
+ {todos.length > 0 ?
: null}
); diff --git a/src/api/localStorage.ts b/src/api/localStorage.ts new file mode 100644 index 000000000..3dcc00f2c --- /dev/null +++ b/src/api/localStorage.ts @@ -0,0 +1,35 @@ +import { useState } from 'react'; +import { Todo } from '../types/Todo'; + +export const useLocalStorage = (key: string, initialState: Todo[]) => { + const [todos, setTodos] = useState(() => { + const savedValue = localStorage.getItem(key); + + if (savedValue === null) { + localStorage.setItem(key, JSON.stringify(initialState)); + + return initialState; + } + + return JSON.parse(savedValue); + }); + + function save(newValue: Todo[] | ((value: Todo[]) => Todo[])) { + setTodos((prevValue: Todo[]) => { + if (typeof newValue === 'function') { + const updateFn = newValue as (prevValue: Todo[]) => Todo[]; + const nextValue = updateFn(prevValue); + + localStorage.setItem(key, JSON.stringify(nextValue)); + + return nextValue; + } else { + localStorage.setItem(key, JSON.stringify(newValue)); + + return newValue; + } + }); + } + + return [todos, save] as const; +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..78818c065 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import { useLocalStorageContext } from '../context/context'; +import { FilterEnum } from '../types/Filter'; + +export const Footer = () => { + const { todos, save, filter, setFilter } = useLocalStorageContext(); + const itemsLeft = todos.filter(todo => !todo.completed).length; + + const onClear = () => { + const newTodos = todos.filter(todo => !todo.completed); + + save(newTodos); + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..a83fdf115 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,70 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useLocalStorageContext } from '../context/context'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; + +export const Header = () => { + const { todos, save } = useLocalStorageContext(); + const [title, setTitle] = useState(''); + const input = useRef(null); + const areAllCompleted = todos.every(todo => todo.completed); + + useEffect(() => { + input.current?.focus(); + }, [todos]); + + const onToggle = () => { + const newTodos = todos.map(todo => ({ + ...todo, + completed: !areAllCompleted, + })); + + save(newTodos); + }; + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const normalizedTitle = title.trim(); + + if (!normalizedTitle) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: normalizedTitle, + completed: false, + }; + + save((previous: Todo[]) => [...previous, newTodo]); + + setTitle(''); + }; + + return ( +
+ {todos.length > 0 ? ( +
+ ); +}; diff --git a/src/components/TodoInfo.tsx b/src/components/TodoInfo.tsx new file mode 100644 index 000000000..b4835b079 --- /dev/null +++ b/src/components/TodoInfo.tsx @@ -0,0 +1,126 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useLocalStorageContext } from '../context/context'; +import React, { useEffect, useRef, useState } from 'react'; + +type Props = { + todo: Todo; +}; +export const TodoInfo: React.FC = ({ todo }) => { + const { todos, save } = useLocalStorageContext(); + const { title, completed } = todo; + const inputNewTitle = useRef(null); + const [newTitle, setNewTitle] = useState(todo.title); + const [isEditing, setIsEditing] = useState(false); + + const handleDoubleClick = () => { + setIsEditing(true); + }; + + const onDelete = () => { + const newTodos = todos.filter(todoOnServer => todoOnServer.id !== todo.id); + + save(newTodos); + }; + + const onClickChange = () => { + const updatedTodos = todos.map(todoOnServer => + todoOnServer.id === todo.id + ? { ...todoOnServer, completed: !todoOnServer.completed } + : todoOnServer, + ); + + save(updatedTodos); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setNewTitle(todo.title); + setIsEditing(false); + } + }; + + useEffect(() => { + if (isEditing) { + inputNewTitle.current?.focus(); + } + }, [isEditing]); + + const titleChange = (event: React.FormEvent) => { + event.preventDefault(); + const normalizedTitle = newTitle.trim(); + + if (!normalizedTitle) { + onDelete(); + setIsEditing(false); + + return; + } + + if (normalizedTitle === title) { + setIsEditing(false); + + return; + } + + const updatedTodos = todos.map(todoOnServer => + todoOnServer.id === todo.id + ? { ...todoOnServer, title: normalizedTitle } + : todoOnServer, + ); + + save(updatedTodos); + setIsEditing(false); + }; + + return ( +
+ + + {isEditing ? ( +
+ setNewTitle(e.target.value)} + onKeyUp={handleKeyUp} + ref={inputNewTitle} + value={newTitle} + /> +
+ ) : ( + <> + + {title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..f1417c619 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { useLocalStorageContext } from '../context/context'; +import { TodoInfo } from './TodoInfo'; +import { FilterEnum } from '../types/Filter'; + +export const TodoList = () => { + const { todos, filter } = useLocalStorageContext(); + + const [todosToShow, setTodosToShow] = useState(todos); + + useEffect(() => { + const filterActions = { + [FilterEnum.All]: () => todos, + [FilterEnum.Active]: () => todos.filter((todo) => !todo.completed), + [FilterEnum.Completed]: () => todos.filter((todo) => todo.completed), + }; + + setTodosToShow(filterActions[filter] ? filterActions[filter]() : todos); + }, [filter, todos]); + + return ( +
+ {todosToShow.map(todo => { + return ; + })} +
+ ); +}; diff --git a/src/context/context.tsx b/src/context/context.tsx new file mode 100644 index 000000000..66326fdb7 --- /dev/null +++ b/src/context/context.tsx @@ -0,0 +1,41 @@ +import React, { createContext, useContext, useState } from 'react'; +import { useLocalStorage } from '../api/localStorage'; +import { Todo } from '../types/Todo'; +import { FilterEnum } from '../types/Filter'; + +interface LocalStorageContextValue { + todos: Todo[]; + save: (newValue: Todo[] | ((value: Todo[]) => Todo[])) => void; + filter: FilterEnum; + setFilter: (newFilter: FilterEnum) => void; +} + +export const localStorageContext = + createContext(null); + + export const LocalStorageProvider = ({ + children, + }: { + children: React.ReactNode; + }) => { + const [todos, save] = useLocalStorage("todos", []); + const [filter, setFilter] = useState(FilterEnum.All); + + return ( + + {children} + + ); + }; + +export const useLocalStorageContext = () => { + const context = useContext(localStorageContext); + + if (!context) { + throw new Error( + 'useLocalStorageContext must be used within a LocalStorageProvider', + ); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..a0b368fd4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,12 @@ import { createRoot } from 'react-dom/client'; import './styles/index.scss'; import { App } from './App'; +import { LocalStorageProvider } from './context/context'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/filters.scss b/src/styles/filters.scss index 75b5804e5..7fcffb8c6 100644 --- a/src/styles/filters.scss +++ b/src/styles/filters.scss @@ -19,4 +19,4 @@ border-color: rgba(175, 47, 47, 0.2); } } -} +} \ No newline at end of file diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..32fe4cdcd 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -20,6 +20,6 @@ body { pointer-events: none; } -@import './todoapp'; -@import './todo-list'; -@import './filters'; +@import "./todoapp"; +@import "./todo-list"; +@import "./filters"; \ No newline at end of file diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..9e359f1bf 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.scss @@ -15,13 +15,13 @@ &__status-label { cursor: pointer; - 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%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + 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%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center left; } &.completed &__status-label { - 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%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); + 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%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E"); } &__status { @@ -92,8 +92,58 @@ .overlay { position: absolute; - inset: 0; + top: 0; + left: 0; + right: 0; + height: 58px; opacity: 0.5; } } + +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} \ No newline at end of file diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..62a3184e9 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,5 +1,6 @@ + .todoapp { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 300; color: #4d4d4d; @@ -8,8 +9,7 @@ &__content { margin-bottom: 20px; background: #fff; - box-shadow: - 0 2px 4px 0 rgba(0, 0, 0, 0.2), + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); } @@ -49,14 +49,31 @@ } &::before { - content: '❯'; + content: "❯"; transform: translateY(2px) rotate(90deg); line-height: 0; } } + &__edited-todo { + padding: 12px 15px; + width: 93.6%; + + font-size: 24px; + line-height: 1.4em; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + border: none; + background: rgba(0, 0, 0, 0.01); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); + } + &__new-todo { - width: 100%; + width: 85%; padding: 16px 16px 16px 60px; font-size: 24px; @@ -69,7 +86,7 @@ border: none; background: rgba(0, 0, 0, 0.01); - box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); &::placeholder { font-style: italic; @@ -97,8 +114,7 @@ text-align: center; border-top: 1px solid #e6e6e6; - box-shadow: - 0 1px 1px rgba(0, 0, 0, 0.2), + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, @@ -122,6 +138,7 @@ appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + transition: opacity 0.3s; &:hover { text-decoration: underline; @@ -130,5 +147,9 @@ &:active { text-decoration: none; } + + &:disabled { + visibility: hidden; + } } -} +} \ No newline at end of file diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..6dcf69763 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum FilterEnum { + All = "All", + Active = "Active", + Completed = "Completed", + } \ No newline at end of file diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..d94ea1bff --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: number; + title: string; + completed: boolean; +};