From 45ed7a1ec32f7fcdecebf7f96b666eb9370903db Mon Sep 17 00:00:00 2001 From: Vasyl Levitskyi Date: Tue, 9 Jun 2026 10:24:57 +0300 Subject: [PATCH 1/2] add solution --- src/App.tsx | 161 ++++-------------------------------- src/components/Footer.tsx | 69 ++++++++++++++++ src/components/Header.tsx | 72 ++++++++++++++++ src/components/TodoItem.tsx | 108 ++++++++++++++++++++++++ src/components/TodoList.tsx | 31 +++++++ src/context/TodoContext.tsx | 46 +++++++++++ src/enums/Filter.ts | 5 ++ src/hooks/useTodoContext.ts | 7 ++ src/index.tsx | 7 +- src/store/reducer.ts | 79 ++++++++++++++++++ src/types/Todo.ts | 5 ++ 11 files changed, 445 insertions(+), 145 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/enums/Filter.ts create mode 100644 src/hooks/useTodoContext.ts create mode 100644 src/store/reducer.ts create mode 100644 src/types/Todo.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..2f0e68e32 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,29 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { useTodoContext } from './hooks/useTodoContext'; + export const App: React.FC = () => { + const { + state: { todos }, + } = useTodoContext(); + 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..14cbe3334 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,69 @@ +import React from 'react'; + +import { Filter } from '../enums/Filter'; +import { useTodoContext } from '../hooks/useTodoContext'; + +export const Footer: React.FC = () => { + const { + state: { todos, filter }, + dispatch, + } = useTodoContext(); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const completedTodosCount = todos.filter(todo => todo.completed).length; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..b6d95841d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,72 @@ +import React, { + FormEvent, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +import { TodoContext } from '../context/TodoContext'; + +export const Header: React.FC = () => { + const { + state: { todos }, + dispatch, + } = useContext(TodoContext); + + const [title, setTitle] = useState(''); + + const newTodoFieldRef = useRef(null); + + const allCompleted = todos.length > 0 && todos.every(todo => todo.completed); + + useEffect(() => { + newTodoFieldRef.current?.focus(); + }, [todos]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + dispatch({ + type: 'addTodo', + payload: { + id: +new Date(), + title: trimmedTitle, + completed: false, + }, + }); + + setTitle(''); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..5cd0566fc --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react'; + +import { useTodoContext } from '../hooks/useTodoContext'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + + const { dispatch } = useTodoContext(); + + const saveTodo = () => { + const preparedTitle = title.trim(); + + if (!preparedTitle) { + dispatch({ type: 'deleteTodo', payload: todo.id }); + + return; + } + + dispatch({ + type: 'updateTodo', + payload: { + id: todo.id, + title: preparedTitle, + }, + }); + + setIsEditing(false); + }; + + const cancelEditing = () => { + setTitle(todo.title); + setIsEditing(false); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + saveTodo(); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + cancelEditing(); + } + }; + + return ( +
+ + + + dispatch({ + type: 'toggleTodo', + payload: todo.id, + }) + } + /> + + {isEditing ? ( +
+ setTitle(event.target.value)} + onBlur={saveTodo} + onKeyUp={handleKeyUp} + autoFocus + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..922849230 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { Filter } from '../enums/Filter'; +import { useTodoContext } from '../hooks/useTodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { + state: { todos, filter }, + } = useTodoContext(); + + const visibleTodos = todos.filter(todo => { + if (filter === Filter.Active) { + return !todo.completed; + } + + if (filter === Filter.Completed) { + return todo.completed; + } + + return true; + }); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..11dc51a01 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useEffect, useReducer } from 'react'; + +import { Filter } from '../enums/Filter'; +import { Action, reducer, RootState } from '../store/reducer'; + +const STORAGE_KEY = 'todos'; + +type ContextType = { + state: RootState; + dispatch: React.Dispatch; +}; + +export const TodoContext = createContext({} as ContextType); + +type ProviderProps = { + children: React.ReactNode; +}; + +const getInitialTodos = () => { + const todosFromStorage = localStorage.getItem(STORAGE_KEY); + + if (!todosFromStorage) { + return []; + } + + return JSON.parse(todosFromStorage); +}; + +const initialState: RootState = { + todos: getInitialTodos(), + filter: Filter.All, +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; diff --git a/src/enums/Filter.ts b/src/enums/Filter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/enums/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/hooks/useTodoContext.ts b/src/hooks/useTodoContext.ts new file mode 100644 index 000000000..ded89fb91 --- /dev/null +++ b/src/hooks/useTodoContext.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import { TodoContext } from '../context/TodoContext'; + +export const useTodoContext = () => { + return useContext(TodoContext); +}; 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/store/reducer.ts b/src/store/reducer.ts new file mode 100644 index 000000000..359c1cd7d --- /dev/null +++ b/src/store/reducer.ts @@ -0,0 +1,79 @@ +import { Filter } from '../enums/Filter'; +import { Todo } from '../types/Todo'; + +export type RootState = { + todos: Todo[]; + filter: Filter; +}; + +export type Action = + | { type: 'addTodo'; payload: Todo } + | { type: 'deleteTodo'; payload: number } + | { type: 'toggleTodo'; payload: number } + | { type: 'toggleAll' } + | { type: 'clearCompleted' } + | { type: 'updateTodo'; payload: { id: number; title: string } } + | { type: 'setFilter'; payload: Filter }; + +export const reducer = (state: RootState, action: Action): RootState => { + switch (action.type) { + case 'addTodo': + return { + ...state, + todos: [...state.todos, action.payload], + }; + + case 'deleteTodo': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + + case 'toggleTodo': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload + ? { ...todo, completed: !todo.completed } + : todo, + ), + }; + + case 'toggleAll': { + const allCompleted = state.todos.every(todo => todo.completed); + + return { + ...state, + todos: state.todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })), + }; + } + + case 'clearCompleted': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + + case 'updateTodo': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo, + ), + }; + + case 'setFilter': + return { + ...state, + filter: action.payload, + }; + + default: + return state; + } +}; 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; +}; From 45b0ecc6724bd5c9351f8dedac69b088b03c92c8 Mon Sep 17 00:00:00 2001 From: Vasyl Levitskyi Date: Tue, 9 Jun 2026 10:39:18 +0300 Subject: [PATCH 2/2] add solution --- src/components/TodoItem.tsx | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx index 5cd0566fc..bf91141fe 100644 --- a/src/components/TodoItem.tsx +++ b/src/components/TodoItem.tsx @@ -51,24 +51,21 @@ export const TodoItem: React.FC = ({ todo }) => { return (
-