From 3b0581ecd48976bafd5aeb22906940647233d71b Mon Sep 17 00:00:00 2001 From: MacBook Pro Date: Sat, 16 May 2026 19:40:05 +0300 Subject: [PATCH] add task solution --- src/App.tsx | 160 ++++------------------------------ src/components/Footer.tsx | 60 +++++++++++++ src/components/TodoHeader.tsx | 46 ++++++++++ src/components/TodoItem.tsx | 95 ++++++++++++++++++++ src/components/TodoList.tsx | 29 ++++++ src/context/TodoContext.tsx | 134 ++++++++++++++++++++++++++++ src/index.tsx | 10 ++- src/types/Todo.ts | 5 ++ 8 files changed, 393 insertions(+), 146 deletions(-) create mode 100644 src/components/Footer.tsx create mode 100644 src/components/TodoHeader.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/Todo.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..57cf4fd0b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,28 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ + import React from 'react'; +import { useTodo } from './context/TodoContext'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; + export const App: React.FC = () => { + const { todos } = useTodo(); + 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 && ( + <> + +
+ + )}
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..b0df7373f --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,60 @@ +import React from 'react'; + +import { useTodo } from '../context/TodoContext'; + +export const Footer: React.FC = () => { + const { todos, filter, setFilter, clearCompleted, focusNewTodo } = useTodo(); + + const activeTodos = todos.filter(todo => !todo.completed); + const completedTodos = todos.filter(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/TodoHeader.tsx b/src/components/TodoHeader.tsx new file mode 100644 index 000000000..a70de9de0 --- /dev/null +++ b/src/components/TodoHeader.tsx @@ -0,0 +1,46 @@ +import React, { useState, useRef } from 'react'; + +import { useTodo } from '../context/TodoContext'; + +export const TodoHeader: React.FC = () => { + const [title, setTitle] = useState(''); + + const { addTodo, todos, toggleAll } = useTodo(); + + const inputRef = useRef(null); + + const allCompleted = todos.length > 0 && todos.every(todo => todo.completed); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + addTodo(title); + setTitle(''); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..9030b7f0d --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,95 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef, useState } from 'react'; + +import { Todo } from '../types/Todo'; +import { useTodo } from '../context/TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { deleteTodo, toggleTodo, updateTodo, focusNewTodo } = useTodo(); + + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(todo.title); + + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + } + }, [isEditing]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + updateTodo(todo.id, value); + setIsEditing(false); + }; + + const handleBlur = () => { + updateTodo(todo.id, value); + setIsEditing(false); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setValue(todo.title); + setIsEditing(false); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setValue(e.target.value)} + onBlur={handleBlur} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..39b2eaeb5 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { useTodo } from '../context/TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodo(); + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; + + case 'completed': + return todo.completed; + + default: + return true; + } + }); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..9a011fecc --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,134 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +import { Todo } from '../types/Todo'; + +type Filter = 'all' | 'active' | 'completed'; + +type TodoContextType = { + todos: Todo[]; + filter: Filter; + + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + clearCompleted: () => void; + toggleAll: () => void; + updateTodo: (id: number, title: string) => void; + + setFilter: (value: Filter) => void; + focusNewTodo: () => void; +}; + +const TodoContext = createContext(null); + +const storedTodos = localStorage.getItem('todos'); + +export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [todos, setTodos] = useState( + storedTodos ? JSON.parse(storedTodos) : [], + ); + + const [filter, setFilter] = useState('all'); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (title: string) => { + const trimmed = title.trim(); + + if (!trimmed) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: trimmed, + completed: false, + }; + + setTodos(current => [...current, newTodo]); + }; + + const deleteTodo = (id: number) => { + setTodos(current => current.filter(todo => todo.id !== id)); + }; + + const toggleTodo = (id: number) => { + setTodos(current => + current.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const clearCompleted = () => { + setTodos(current => current.filter(todo => !todo.completed)); + }; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(current => + current.map(todo => ({ + ...todo, + completed: !allCompleted, + })), + ); + }; + + const updateTodo = (id: number, title: string) => { + const trimmed = title.trim(); + + if (!trimmed) { + deleteTodo(id); + + return; + } + + setTodos(current => + current.map(todo => + todo.id === id ? { ...todo, title: trimmed } : todo, + ), + ); + }; + + const focusNewTodo = () => { + const field = document.querySelector( + '[data-cy="NewTodoField"]', + ) as HTMLInputElement | null; + + field?.focus(); + }; + + return ( + + {children} + + ); +}; + +export const useTodo = () => { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('No TodoProvider'); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..2c0f47214 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,15 @@ -import { createRoot } from 'react-dom/client'; +import React from 'react'; +import ReactDOM 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(); +ReactDOM.createRoot(container).render( + + + , +); diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +}