From 3829cad48deff164a43f4b3faf56beb9084b1587 Mon Sep 17 00:00:00 2001 From: Yevhen Serdiuk Date: Sat, 9 May 2026 15:57:30 +0300 Subject: [PATCH] add solution --- README.md | 2 +- src/App.tsx | 275 +++++++++++++-------------- src/components/Footer/Footer.tsx | 64 +++++++ src/components/Header/Header.tsx | 57 ++++++ src/components/Loader/Loader.tsx | 21 ++ src/components/TodoItem/TodoItem.tsx | 89 +++++++++ src/components/TodoList/TodoList.tsx | 22 +++ src/contexts/TodosContext.tsx | 17 ++ src/styles/todo-list.scss | 1 + src/styles/todoapp.scss | 1 + src/types/FilterTodosEnum.ts | 5 + src/types/Todo.ts | 5 + src/types/TodosContextType.ts | 16 ++ 13 files changed, 427 insertions(+), 148 deletions(-) create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Loader/Loader.tsx create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/contexts/TodosContext.tsx create mode 100644 src/types/FilterTodosEnum.ts create mode 100644 src/types/Todo.ts create mode 100644 src/types/TodosContextType.ts diff --git a/README.md b/README.md index 903c876f9..0958a8ae8 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://Yevhen-Srdk.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..fe4c10b2d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,138 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { TodosContext } from './contexts/TodosContext'; +import { Todo } from './types/Todo'; +import { Footer } from './components/Footer/Footer'; +import { FilterTodos } from './types/FilterTodosEnum'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList/TodoList'; export const App: React.FC = () => { + const [todos, setTodos] = useState(() => { + try { + const savedTodos = localStorage.getItem('todos'); + + return savedTodos ? JSON.parse(savedTodos) : []; + } catch { + return []; + } + }); + const [filter, setFilter] = useState(FilterTodos.All); + const focusRef = useRef(null); + + useEffect(() => { + focusRef.current?.focus(); + + if (!localStorage.getItem('todos')) { + localStorage.setItem('todos', JSON.stringify([])); + } + }, []); + + const areAllTodosCompleted = () => { + return todos.every(todo => todo.completed); + }; + + const saveTodo = (todoTitle: string) => { + const todoId = Date.now(); + const newTodo = { id: todoId, title: todoTitle, completed: false }; + const updatedTodos = [...todos, newTodo]; + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + }; + + const toggleTodo = (todoId: number) => { + const toggledTodos = todos.map(todo => + todo.id === todoId ? { ...todo, completed: !todo.completed } : todo, + ); + + setTodos(toggledTodos); + localStorage.setItem('todos', JSON.stringify(toggledTodos)); + }; + + const deleteTodo = (todoId: number) => { + const newTodos = todos.filter(todo => todo.id !== todoId); + + setTodos(newTodos); + localStorage.setItem('todos', JSON.stringify(newTodos)); + focusRef.current?.focus(); + }; + + const clearCompletedTodos = () => { + const activeTodos = todos.filter(todo => !todo.completed); + + setTodos(activeTodos); + localStorage.setItem('todos', JSON.stringify(activeTodos)); + focusRef.current?.focus(); + }; + + const updateTodo = (todoId: number, newTitle: string) => { + const trimmedNewTitle = newTitle.trim(); + + if (trimmedNewTitle.length === 0) { + deleteTodo(todoId); + + return; + } + + const updatedTodos = todos.map(todo => + todo.id === todoId ? { ...todo, title: trimmedNewTitle } : todo, + ); + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + focusRef.current?.focus(); + }; + + const filteredTodos = todos.filter(todo => { + if (filter === FilterTodos.Active) { + return !todo.completed; + } + + if (filter === FilterTodos.Completed) { + return todo.completed; + } + + return true; + }); + + const toggleAll = () => { + const isAllCompleted = areAllTodosCompleted(); + const updatedTodos = todos.map(todo => { + return { ...todo, completed: !isAllCompleted }; + }); + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + }; + 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 && } + + {todos.length > 0 &&
} +
+
); }; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..5ad72ab6a --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,64 @@ +import { useContext } from 'react'; +import { TodosContext } from '../../contexts/TodosContext'; +import { FilterTodos } from '../../types/FilterTodosEnum'; +import classNames from 'classnames'; + +export const Footer = () => { + const { todos, filter, setFilter, clearCompletedTodos } = + useContext(TodosContext); + + return ( + + ); +}; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..9fb94140c --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,57 @@ +import { ChangeEvent, FormEvent, RefObject, useContext, useState } from 'react'; +import { TodosContext } from '../../contexts/TodosContext'; +import classNames from 'classnames'; + +type Props = { + focusRef: RefObject; +}; + +export const Header = ({ focusRef }: Props) => { + const { todos, toggleAll, areAllTodosCompleted, saveTodo } = + useContext(TodosContext); + const [title, setTitle] = useState(''); + + const handleChangeInput = (event: ChangeEvent) => { + setTitle(event.target.value); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (trimmedTitle.length === 0) { + return; + } + + setTitle(''); + saveTodo(trimmedTitle); + focusRef?.current?.focus(); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 000000000..bdc07b3fc --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,21 @@ +import classNames from 'classnames'; + +type Props = { + isLoading: boolean; +}; + +export const Loader = ({ isLoading }: Props) => { + if (!isLoading) { + return null; + } + + return ( +
+
+
+
+ ); +}; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..fc794142a --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,89 @@ +import classNames from 'classnames'; +import { Todo } from '../../types/Todo'; +import { Loader } from '../Loader/Loader'; +import { useState } from 'react'; + +type Props = { + todo: Todo; + toggleTodo: (todoId: number) => void; + updateTodo: (todoId: number, newTitle: string) => void; + deleteTodo: (id: number) => void; +}; + +export const TodoItem = ({ + todo, + toggleTodo, + updateTodo, + deleteTodo, +}: Props) => { + const [isEditing, setIsEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(''); + + const handleUpdate = () => { + updateTodo(todo.id, editingTitle); + setIsEditing(false); + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {isEditing ? ( +
{ + e.preventDefault(); + handleUpdate(); + }} + > + setEditingTitle(e.target.value)} + onBlur={handleUpdate} + onKeyUp={e => e.key === 'Escape' && setIsEditing(false)} + /> +
+ ) : ( + <> + { + setIsEditing(true); + setEditingTitle(todo.title); + }} + > + {todo.title} + + + + + )} + + +
+ ); +}; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..bbb4decc0 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,22 @@ +import { useContext } from 'react'; +import { TodosContext } from '../../contexts/TodosContext'; +import { TodoItem } from '../TodoItem/TodoItem'; + +export const TodoList = () => { + const { filteredTodos, toggleTodo, updateTodo, deleteTodo } = + useContext(TodosContext); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/contexts/TodosContext.tsx b/src/contexts/TodosContext.tsx new file mode 100644 index 000000000..3f339be88 --- /dev/null +++ b/src/contexts/TodosContext.tsx @@ -0,0 +1,17 @@ +import { createContext } from 'react'; +import { TodosContextType } from '../types/TodosContextType'; +import { FilterTodos } from '../types/FilterTodosEnum'; + +export const TodosContext = createContext({ + todos: [], + filteredTodos: [], + filter: FilterTodos.All, + setFilter: () => {}, + clearCompletedTodos: () => {}, + toggleAll: () => {}, + toggleTodo: () => {}, + areAllTodosCompleted: () => false, + updateTodo: () => {}, + deleteTodo: () => {}, + saveTodo: () => {}, +}); diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..fd5f6aa10 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.scss @@ -73,6 +73,7 @@ &__title-field { width: 100%; padding: 11px 14px; + box-sizing: border-box; font-size: inherit; line-height: inherit; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..cf79aa03b 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -58,6 +58,7 @@ &__new-todo { width: 100%; padding: 16px 16px 16px 60px; + box-sizing: border-box; font-size: 24px; line-height: 1.4em; diff --git a/src/types/FilterTodosEnum.ts b/src/types/FilterTodosEnum.ts new file mode 100644 index 000000000..4cfd272e5 --- /dev/null +++ b/src/types/FilterTodosEnum.ts @@ -0,0 +1,5 @@ +export const enum FilterTodos { + All = 'all', + Active = 'active', + Completed = 'completed', +} 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; +}; diff --git a/src/types/TodosContextType.ts b/src/types/TodosContextType.ts new file mode 100644 index 000000000..b4d2ae794 --- /dev/null +++ b/src/types/TodosContextType.ts @@ -0,0 +1,16 @@ +import { FilterTodos } from './FilterTodosEnum'; +import { Todo } from './Todo'; + +export type TodosContextType = { + todos: Todo[]; + filteredTodos: Todo[]; + filter: FilterTodos; + setFilter: (filterBy: FilterTodos) => void; + clearCompletedTodos: () => void; + toggleAll: () => void; + toggleTodo: (id: number) => void; + areAllTodosCompleted: () => boolean; + updateTodo: (todoId: number, newTitle: string) => void; + deleteTodo: (id: number) => void; + saveTodo: (todoTitle: string) => void; +};