From 9e459203c5787035035c746ddca8fdec39455a0f Mon Sep 17 00:00:00 2001 From: Maksym Davydiuk Date: Sat, 9 May 2026 21:25:25 +0300 Subject: [PATCH] Implement TodoApp with React Context, localStorage, filtering and inline editing --- package-lock.json | 9 +- src/App.tsx | 183 +++++++++--------------------------- src/TodoContext.tsx | 173 ++++++++++++++++++++++++++++++++++ src/components/Filter.tsx | 42 +++++++++ src/components/TodoForm.tsx | 36 +++++++ src/components/TodoItem.tsx | 105 +++++++++++++++++++++ src/components/TodoList.tsx | 15 +++ src/constants/filter.ts | 7 ++ src/types/Todo.ts | 5 + 9 files changed, 435 insertions(+), 140 deletions(-) create mode 100644 src/TodoContext.tsx create mode 100644 src/components/Filter.tsx create mode 100644 src/components/TodoForm.tsx create mode 100644 src/components/TodoItem.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/constants/filter.ts create mode 100644 src/types/Todo.ts diff --git a/package-lock.json b/package-lock.json index 1f19b4743..a7f06fc95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1170,10 +1170,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", - "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/src/App.tsx b/src/App.tsx index a399287bd..7bd2591f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,68 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import classNames from 'classnames'; +import { TodoProvider, useTodos } from './TodoContext'; +import { TodoForm } from './components/TodoForm'; +import { TodoList } from './components/TodoList'; +import { Filter } from './components/Filter'; + +const TodoApp: React.FC = () => { + const { todos, clearCompleted, toggleAll } = useTodos(); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = todos.some(todo => todo.completed); + const allCompleted = todos.length > 0 && todos.every(t => t.completed); + const hasTodos = todos.length > 0; + const itemWord = activeTodosCount === 1 ? 'item' : 'items'; -export const App: React.FC = () => { 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 */} -
- + {hasTodos && } - - Todo is being saved now + {hasTodos && ( +
+ + {activeTodosCount} {itemWord} left - -
-
- - {/* 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 */} - -
+ Clear completed + + + )}
); }; + +export const App: React.FC = () => ( + + + +); diff --git a/src/TodoContext.tsx b/src/TodoContext.tsx new file mode 100644 index 000000000..d2cca88dd --- /dev/null +++ b/src/TodoContext.tsx @@ -0,0 +1,173 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { Todo } from './types/Todo'; +import { FILTER, FilterType } from './constants/filter'; + +type TodoContextType = { + todos: Todo[]; + filter: FilterType; + filteredTodos: Todo[]; + inputRef: React.RefObject; + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + updateTodo: (id: number, title: string) => void; + clearCompleted: () => void; + toggleAll: () => void; +}; + +const TodoContext = createContext(undefined); + +function getFilterFromHash(): FilterType { + const hash = window.location.hash; + + if (hash === '#/active') { + return FILTER.active; + } + + if (hash === '#/completed') { + return FILTER.completed; + } + + return FILTER.all; +} + +function loadTodos(): Todo[] { + try { + const saved = localStorage.getItem('todos'); + + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } +} + +export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [todos, setTodos] = useState(loadTodos); + const [filter, setFilter] = useState(getFilterFromHash); + const inputRef = useRef(null); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + useEffect(() => { + const handleHashChange = () => setFilter(getFilterFromHash()); + + window.addEventListener('hashchange', handleHashChange); + + return () => window.removeEventListener('hashchange', handleHashChange); + }, []); + + const focusInput = useCallback(() => { + inputRef.current?.focus(); + }, []); + + const addTodo = useCallback((title: string) => { + const trimmed = title.trim(); + + if (!trimmed) { + return; + } + + setTodos(prev => [ + ...prev, + { id: +new Date(), title: trimmed, completed: false }, + ]); + }, []); + + const deleteTodo = useCallback( + (id: number) => { + setTodos(prev => prev.filter(todo => todo.id !== id)); + focusInput(); + }, + [focusInput], + ); + + const toggleTodo = useCallback((id: number) => { + setTodos(prev => + prev.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }, []); + + const updateTodo = useCallback( + (id: number, title: string) => { + const trimmed = title.trim(); + + if (!trimmed) { + deleteTodo(id); + + return; + } + + setTodos(prev => + prev.map(todo => + todo.id === id ? { ...todo, title: trimmed } : todo, + ), + ); + }, + [deleteTodo], + ); + + const clearCompleted = useCallback(() => { + setTodos(prev => prev.filter(todo => !todo.completed)); + focusInput(); + }, [focusInput]); + + const toggleAll = useCallback(() => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(prev => prev.map(todo => ({ ...todo, completed: !allCompleted }))); + }, [todos]); + + const filteredTodos = todos.filter(todo => { + if (filter === FILTER.active) { + return !todo.completed; + } + + if (filter === FILTER.completed) { + return todo.completed; + } + + return true; + }); + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('useTodos must be used within TodoProvider'); + } + + return context; +}; diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx new file mode 100644 index 000000000..9493ca22a --- /dev/null +++ b/src/components/Filter.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import classNames from 'classnames'; +import { FILTER } from '../constants/filter'; +import { useTodos } from '../TodoContext'; + +export const Filter: React.FC = () => { + const { filter } = useTodos(); + + return ( + + ); +}; diff --git a/src/components/TodoForm.tsx b/src/components/TodoForm.tsx new file mode 100644 index 000000000..b4b619112 --- /dev/null +++ b/src/components/TodoForm.tsx @@ -0,0 +1,36 @@ +import React, { useEffect, useState } from 'react'; +import { useTodos } from '../TodoContext'; + +export const TodoForm: React.FC = () => { + const { addTodo, inputRef } = useTodos(); + const [title, setTitle] = useState(''); + + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addTodo(title); + + if (title.trim()) { + setTitle(''); + } + + inputRef.current?.focus(); + }; + + return ( +
+ setTitle(e.target.value)} + /> +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..b0b8d12d2 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useTodos } from '../TodoContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { toggleTodo, deleteTodo, updateTodo } = useTodos(); + const [editing, setEditing] = useState(false); + const [editTitle, setEditTitle] = useState(''); + const editRef = useRef(null); + const isHandled = useRef(false); + + useEffect(() => { + if (editing) { + editRef.current?.focus(); + } + }, [editing]); + + const handleDoubleClick = () => { + setEditTitle(todo.title); + setEditing(true); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + isHandled.current = true; + updateTodo(todo.id, editTitle); + setEditing(false); + }; + + const handleBlur = () => { + if (isHandled.current) { + isHandled.current = false; + + return; + } + + updateTodo(todo.id, editTitle); + setEditing(false); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + isHandled.current = true; + setEditing(false); + } + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {editing ? ( +
+ setEditTitle(e.target.value)} + onBlur={handleBlur} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..6c2585f91 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { useTodos } from '../TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { filteredTodos } = useTodos(); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/constants/filter.ts b/src/constants/filter.ts new file mode 100644 index 000000000..07d268e41 --- /dev/null +++ b/src/constants/filter.ts @@ -0,0 +1,7 @@ +export const FILTER = { + all: 'all', + active: 'active', + completed: 'completed', +} as const; + +export type FilterType = (typeof FILTER)[keyof typeof FILTER]; 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; +};