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 */}
-
-
- {/* Add a todo on form submit */}
-
-
-
-
-
- {/* Hide the footer if there are no todos */}
-
-
- 3 items left
-
-
- {/* Active link should have the 'selected' class */}
-
-
- All
-
-
-
- Active
-
+
-
- Completed
-
-
-
- {/* this button should be disabled if there are no completed todos */}
-
- Clear completed
-
-
+ 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 (
+
+
+ All
+
+
+
+ Active
+
+
+
+ Completed
+
+
+ );
+};
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 (
+
+ );
+};
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 */}
+
+ toggleTodo(todo.id)}
+ />
+
+
+ {editing ? (
+
+ ) : (
+ <>
+
+ {todo.title}
+
+
+ deleteTodo(todo.id)}
+ >
+ ×
+
+ >
+ )}
+
+ );
+};
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;
+};