From 0e25baa58a11867f9c5a6852cbb6f02642a2e997 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 19:02:24 +0300 Subject: [PATCH] completed tasks --- README.md | 2 +- src/App.tsx | 194 +++++++++++------------------------- src/components/Footer.tsx | 80 +++++++++++++++ src/components/Header.tsx | 50 ++++++++++ src/components/TodoItem.tsx | 94 +++++++++++++++++ src/components/TodoList.tsx | 19 ++++ src/context/TodoContext.tsx | 123 +++++++++++++++++++++++ src/index.tsx | 7 +- src/styles/todoapp.scss | 1 + src/types/Status.ts | 5 + src/types/Todo.ts | 5 + 11 files changed, 441 insertions(+), 139 deletions(-) create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/context/TodoContext.tsx create mode 100644 src/types/Status.ts create mode 100644 src/types/Todo.ts diff --git a/README.md b/README.md index 903c876f9..6359fe89f 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://qlaudy.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..b38e7b92c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,76 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useRef, useState } from 'react'; +import { TodoContext } from './context/TodoContext'; +import { Status } from './types/Status'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { TodoList } from './components/TodoList'; export const App: React.FC = () => { - return ( -
-

todos

+ const [query, setQuery] = useState(''); + const newTodoField = useRef(null); -
-
- {/* this button should have `active` class only if all todos are completed */} -
+ const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + addTodo(query); + setQuery(''); + }; -
- {/* This is a completed todo */} -
- + const handleDeleteTodo = (id: number) => { + deleteTodo(id); + newTodoField.current?.focus(); + }; - - Completed Todo - + const visibleTodos = todos.filter(todo => { + if (filter === Status.Active) { + return !todo.completed; + } - {/* Remove button appears only on hover */} - -
+ if (filter === Status.Completed) { + return todo.completed; + } - {/* This todo is an active todo */} -
- + return true; + }); - - Not Completed Todo - + const handleClearCompleted = () => { + clearCompleted(); + newTodoField.current?.focus(); + }; - -
+ const activeTodosCount = todos.filter(todo => !todo.completed).length; - {/* 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 - + return ( +
+

todos

- -
-
+
+
+ + {todos.length > 0 && ( + + )} {/* 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 && ( +
+ )}
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..d1d701b23 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,80 @@ +import classNames from 'classnames'; +import { TodoContext } from '../context/TodoContext'; +import React, { useContext } from 'react'; +import { Status } from '../types/Status'; + +interface Props { + activeTodosCount: number; + handleClearCompleted: () => void; +} + +export const Footer: React.FC = ({ + activeTodosCount, + handleClearCompleted, +}) => { + const { todos, filter, setFilter } = useContext(TodoContext); + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..fac6003f9 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,50 @@ +import classNames from 'classnames'; +import { TodoContext } from '../context/TodoContext'; +import React, { useContext } from 'react'; + +interface Props { + handleSubmit: (event: React.FormEvent) => void; + query: string; + setQuery: (value: string) => void; + inputRef: React.RefObject; +} + +export const Header: React.FC = ({ + handleSubmit, + query, + setQuery, + inputRef, +}) => { + const { todos, toggleAll } = useContext(TodoContext); + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..32896e2d7 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,94 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext, useState } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { TodoContext } from '../context/TodoContext'; + +interface Props { + todo: Todo; + onDeleteTodo: (id: number) => void; +} + +export const TodoItem: React.FC = ({ todo, onDeleteTodo }) => { + const { toggleTodo, updateTodo } = useContext(TodoContext); + + const [isEditing, setIsEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(todo.title); + + const handleSubmit = () => { + updateTodo(todo.id, editingTitle); + setIsEditing(false); + }; + + const handleCancel = () => { + setEditingTitle(todo.title); + setIsEditing(false); + }; + + return ( +
+ + + {isEditing ? ( +
{ + event.preventDefault(); + handleSubmit(); + }} + > + setEditingTitle(event.target.value)} + onKeyUp={event => { + if (event.key === 'Escape') { + handleCancel(); + } + }} + autoFocus + /> +
+ ) : ( + { + setIsEditing(true); + setEditingTitle(todo.title); + }} + > + {todo.title} + + )} + + {!isEditing && ( + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..71f2c8242 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,19 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { Todo } from '../types/Todo'; +import React from 'react'; +import { TodoItem } from './TodoItem'; + +interface Props { + visibleTodos: Todo[]; + onDeleteTodo: (id: number) => void; +} + +export const TodoList: React.FC = ({ visibleTodos, onDeleteTodo }) => { + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..d8ff11173 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,123 @@ +import React, { createContext, useEffect, useState } from 'react'; +import { Status } from '../types/Status'; +import { Todo } from '../types/Todo'; + +interface TodoContextType { + todos: Todo[]; + filter: Status; + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + updateTodo: (id: number, newTitle: string) => void; + clearCompleted: () => void; + toggleAll: () => void; + setFilter: (value: Status) => void; +} + +export const TodoContext = createContext({ + todos: [], + filter: Status.All, + addTodo: () => {}, + deleteTodo: () => {}, + toggleTodo: () => {}, + updateTodo: () => {}, + clearCompleted: () => {}, + toggleAll: () => {}, + setFilter: () => {}, +}); + +export const TodoProvider = ({ children }: { children: React.ReactNode }) => { + const [todos, setTodos] = useState(() => { + try { + const savedTodos = localStorage.getItem('todos'); + + return savedTodos ? JSON.parse(savedTodos) : []; + } catch { + localStorage.removeItem('todos'); + + return []; + } + }); + const [filter, setFilter] = useState(Status.All); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (title: string) => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: trimmedTitle, + completed: false, + }; + + setTodos(currentTodos => [...currentTodos, newTodo]); + }; + + const deleteTodo = (id: number) => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)); + }; + + const toggleTodo = (id: number) => { + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const updateTodo = (id: number, newTitle: string) => { + const trimmedNewTitle = newTitle.trim(); + + if (!trimmedNewTitle) { + deleteTodo(id); + + return; + } + + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === id ? { ...todo, title: trimmedNewTitle } : todo, + ), + ); + }; + + const clearCompleted = () => { + setTodos(currentTodos => + currentTodos.filter(todo => todo.completed === false), + ); + }; + + const toggleAll = () => { + setTodos(currentTodos => { + const areAllCompleted = currentTodos.every(todo => todo.completed); + const newStatus = !areAllCompleted; + + return currentTodos.map(todo => ({ ...todo, completed: newStatus })); + }); + }; + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..a4207b1fc 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 { TodoProvider } from './context/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..29383a1e2 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -56,6 +56,7 @@ } &__new-todo { + box-sizing: border-box; width: 100%; padding: 16px 16px 16px 60px; diff --git a/src/types/Status.ts b/src/types/Status.ts new file mode 100644 index 000000000..de66acb67 --- /dev/null +++ b/src/types/Status.ts @@ -0,0 +1,5 @@ +export enum Status { + 'All', + 'Active', + '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; +};