From c671cac164db7c3ff4dacf11b16933733833c79e Mon Sep 17 00:00:00 2001 From: Ivanov Ihor Date: Sun, 29 Mar 2026 20:08:16 +0300 Subject: [PATCH] add task solution --- src/App.tsx | 166 ++++----------------------- src/components/Footer/Footer.tsx | 90 +++++++++++++++ src/components/Footer/index.tsx | 1 + src/components/Header/Header.tsx | 77 +++++++++++++ src/components/Header/index.ts | 1 + src/components/TodoList/TodoList.tsx | 165 ++++++++++++++++++++++++++ src/components/TodoList/index.ts | 1 + src/index.tsx | 7 +- src/types/Filter.ts | 5 + src/types/todo.ts | 5 + src/types/todoContext.tsx | 53 +++++++++ 11 files changed, 425 insertions(+), 146 deletions(-) create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.tsx create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/types/Filter.ts create mode 100644 src/types/todo.ts create mode 100644 src/types/todoContext.tsx diff --git a/src/App.tsx b/src/App.tsx index a399287bd..44cb4a963 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,32 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useRef } from 'react'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { TodosContext } from './types/todoContext'; export const App: React.FC = () => { + const { todos } = useContext(TodosContext); + const headerInputRef = useRef(null); + + const focusInput = () => { + headerInputRef.current?.focus(); + }; + 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/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..7695515af --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,90 @@ +import { useContext } from 'react'; +import { FilterContext, TodosContext } from '../../types/todoContext'; +import { Filter } from '../../types/Filter'; +import classNames from 'classnames'; + +type Props = { + onAction: () => void; +}; + +export const Footer: React.FC = ({ onAction }) => { + const { todos, setTodos } = useContext(TodosContext); + const { filter, setFilter } = useContext(FilterContext); + + const hasCompleted = todos.some(todo => todo.completed); + + const clearCompleted = () => { + setTodos(prev => + prev.filter(todo => { + return todo.completed === false; + }), + ); + onAction(); + }; + + return ( + + ); +}; diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx new file mode 100644 index 000000000..65e2506fa --- /dev/null +++ b/src/components/Footer/index.tsx @@ -0,0 +1 @@ +export { Footer } from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..30eaa700c --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,77 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { TodosContext } from '../../types/todoContext'; +import classNames from 'classnames'; + +type Props = { + inputRef: React.RefObject; +}; + +export const Header: React.FC = ({ inputRef }) => { + const { todos, setTodos } = useContext(TodosContext); + const [title, setTitle] = useState(''); + const handleTitle = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const handleAddTask = () => { + event?.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + setTodos(prev => [ + ...prev, + { id: todos.length + 1, title: title.trim(), completed: false }, + ]); + + setTitle(''); + + setTimeout(() => { + inputRef.current?.focus(); + }, 0); + }; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(prev => prev.map(todo => ({ ...todo, completed: !allCompleted }))); + }; + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..29429dc97 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..f91eb25c9 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,165 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext, useMemo, useState } from 'react'; +import { FilterContext, TodosContext } from '../../types/todoContext'; +import { Filter } from '../../types/Filter'; +import classNames from 'classnames'; +import { Todo } from '../../types/todo'; + +type Props = { + onAction: () => void; +}; + +export const TodoList: React.FC = ({ onAction }) => { + const { todos, setTodos } = useContext(TodosContext); + const { filter } = useContext(FilterContext); + const [editingTodoId, setEditingTodoId] = useState(0); + const [title, setTitle] = useState(''); + + const startEditing = (todo: Todo) => { + setEditingTodoId(todo.id); + setTitle(todo.title); + }; + + const handleTitle = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const changeStatus = (todoId: number) => { + setTodos(prev => { + return prev.map(todo => { + if (todo.id === todoId) { + return { ...todo, completed: !todo.completed }; + } + + return todo; + }); + }); + }; + + const deleteTodo = (todoId: number) => { + setTodos(prev => { + return prev.filter(todo => { + return todo.id !== todoId; + }); + }); + onAction(); + }; + + const updateTodo = (oldTodo: Todo) => { + event?.preventDefault(); + + if (editingTodoId === 0) { + return; + } + + const trimmedTitle = title.trim(); + + if (trimmedTitle === oldTodo.title) { + setEditingTodoId(0); + + return; + } + + if (trimmedTitle.length > 0) { + setTodos(prev => + prev.map(todo => + todo.id === oldTodo.id ? { ...todo, title: trimmedTitle } : todo, + ), + ); + } else { + deleteTodo(oldTodo.id); + } + + setEditingTodoId(0); + setTitle(''); + }; + + const visibleTodos = useMemo(() => { + switch (filter) { + case Filter.Active: + return todos.filter(todo => { + return todo.completed === false; + }); + case Filter.Completed: + return todos.filter(todo => { + return todo.completed === true; + }); + default: + return todos; + } + + return todos; + }, [todos, filter]); + + return ( +
+ {visibleTodos.map(todo => { + return ( +
{ + startEditing(todo); + }} + > + + + {editingTodoId !== todo.id ? ( + <> + + {todo.title} + + + + ) : ( +
{ + updateTodo(todo); + }} + > + { + if (event.key === 'Escape') { + setEditingTodoId(0); + setTitle(''); + } + }} + onBlur={() => { + updateTodo(todo); + }} + autoFocus + /> +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..d0c1712ba --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export { TodoList } from './TodoList'; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..ad7b7e891 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 { TodosContextProvider } from './types/todoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..66887875b --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + 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/todoContext.tsx b/src/types/todoContext.tsx new file mode 100644 index 000000000..e74eeefef --- /dev/null +++ b/src/types/todoContext.tsx @@ -0,0 +1,53 @@ +import React, { + createContext, + Dispatch, + SetStateAction, + useEffect, + useState, +} from 'react'; +import { Todo } from './todo'; +import { Filter } from './Filter'; + +interface TodosContextType { + todos: Todo[]; + setTodos: Dispatch>; +} + +type FilterContextType = { + filter: Filter; + setFilter: (filter: Filter) => void; +}; + +export const TodosContext = createContext({ + todos: [], + setTodos: () => {}, +}); + +export const FilterContext = createContext({ + filter: Filter.All, + setFilter: () => {}, +}); + +export const TodosContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [todos, setTodos] = useState( + JSON.parse(localStorage.getItem('todos') || '[]'), + ); + + const [filter, setFilter] = useState(Filter.All); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + return ( + + + {children} + + + ); +};