diff --git a/.hintrc b/.hintrc
new file mode 100644
index 000000000..d72048f55
--- /dev/null
+++ b/.hintrc
@@ -0,0 +1,9 @@
+{
+ "extends": [
+ "development"
+ ],
+ "hints": {
+ "axe/name-role-value": "off",
+ "axe/forms": "off"
+ }
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 1f19b4743..00c8a82f6 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",
@@ -3234,7 +3235,8 @@
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
- "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
+ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
+ "license": "MIT"
},
"node_modules/clean-stack": {
"version": "2.2.0",
diff --git a/package.json b/package.json
index 91d7489b9..446974833 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,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",
diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..125dba6d3 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,157 +1,6 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react';
+import { TodoApp } from './components/TodoApp';
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
-
-
-
-
- );
+ return ;
};
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 000000000..356d9df71
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,76 @@
+/* eslint-disable prettier/prettier */
+import React, { useContext } from 'react';
+import classNames from 'classnames';
+import { TodoContext } from '../../context/TodoContext';
+import { FILTERS } from '../../types/Filter';
+
+export const Footer: React.FC = () => {
+ const { todos, setTodos, filter, setFilter } = useContext(TodoContext);
+
+ const activeTodos = todos.filter(todo => !todo.completed).length;
+
+ const handleClearCompleted = () => {
+ setTodos(todos.filter(todo => !todo.completed));
+
+ setTimeout(() => {
+ (
+ document.querySelector(
+ '[data-cy="NewTodoField"]',
+ ) as HTMLInputElement | null
+ )?.focus();
+ }, 0);
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts
new file mode 100644
index 000000000..ddcc5a9cd
--- /dev/null
+++ b/src/components/Footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/src/components/TodoApp/TodoApp.tsx b/src/components/TodoApp/TodoApp.tsx
new file mode 100644
index 000000000..c09c7e52c
--- /dev/null
+++ b/src/components/TodoApp/TodoApp.tsx
@@ -0,0 +1,24 @@
+/* eslint-disable prettier/prettier */
+import React, { useContext } from 'react';
+import { TodoContext } from '../../context/TodoContext';
+import { TodoHeader } from '../TodoHeader';
+import { TodoList } from '../TodoList';
+import { Footer } from '../Footer';
+
+export const TodoApp: React.FC = () => {
+ const { todos } = useContext(TodoContext);
+
+ return (
+
+
todos
+
+
+
+
+ {todos.length > 0 && }
+
+ {todos.length > 0 && }
+
+
+ );
+};
diff --git a/src/components/TodoApp/index.ts b/src/components/TodoApp/index.ts
new file mode 100644
index 000000000..4ff3f14ce
--- /dev/null
+++ b/src/components/TodoApp/index.ts
@@ -0,0 +1 @@
+export * from './TodoApp';
diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx
new file mode 100644
index 000000000..0eee632c9
--- /dev/null
+++ b/src/components/TodoHeader/TodoHeader.tsx
@@ -0,0 +1,76 @@
+/* eslint-disable prettier/prettier */
+import React, { useContext, useRef, useState } from 'react';
+import classNames from 'classnames';
+import { TodoContext } from '../../context/TodoContext';
+
+export const TodoHeader: React.FC = () => {
+ const { todos, setTodos } = useContext(TodoContext);
+
+ const [title, setTitle] = useState('');
+ const inputRef = useRef(null);
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+
+ const trimmedTitle = title.trim();
+
+ if (!trimmedTitle) {
+ inputRef.current?.focus();
+
+ return;
+ }
+
+ setTodos([
+ ...todos,
+ {
+ id: +new Date(),
+ title: trimmedTitle,
+ completed: false,
+ },
+ ]);
+
+ setTitle('');
+ inputRef.current?.focus();
+ };
+
+ const handleToggleAll = () => {
+ const allCompleted = todos.every(todo => todo.completed);
+
+ setTodos(
+ todos.map(todo => ({
+ ...todo,
+ completed: !allCompleted,
+ })),
+ );
+
+ inputRef.current?.focus();
+ };
+
+ return (
+
+ {todos.length > 0 && (
+ todo.completed),
+ })}
+ onClick={handleToggleAll}
+ />
+ )}
+
+
+
+ );
+};
diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts
new file mode 100644
index 000000000..c4db4bc40
--- /dev/null
+++ b/src/components/TodoHeader/index.ts
@@ -0,0 +1 @@
+export * from './TodoHeader';
diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx
new file mode 100644
index 000000000..08b5cc199
--- /dev/null
+++ b/src/components/TodoItem/TodoItem.tsx
@@ -0,0 +1,145 @@
+/* eslint-disable prettier/prettier */
+/* eslint-disable @typescript-eslint/indent */
+/* eslint-disable jsx-a11y/label-has-associated-control */
+
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import classNames from 'classnames';
+import { TodoContext } from '../../context/TodoContext';
+import { Todo } from '../../types/Todo';
+
+type Props = {
+ todo: Todo;
+};
+
+export const TodoItem: React.FC = ({ todo }) => {
+ const { todos, setTodos } = useContext(TodoContext);
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [editedTitle, setEditedTitle] = useState(todo.title);
+
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ inputRef.current?.focus();
+ inputRef.current?.select();
+ }
+ }, [isEditing]);
+
+ const focusNewTodoField = () => {
+ (
+ document.querySelector(
+ '[data-cy="NewTodoField"]',
+ ) as HTMLInputElement | null
+ )?.focus();
+ };
+
+ const handleDelete = () => {
+ setTodos(todos.filter(item => item.id !== todo.id));
+
+ setTimeout(() => {
+ focusNewTodoField();
+ });
+ };
+
+ const handleToggle = () => {
+ setTodos(
+ todos.map(item => (
+ item.id === todo.id
+ ? {
+ ...item,
+ completed: !item.completed,
+ }
+ : item
+ )),
+ );
+ };
+
+ const handleSave = () => {
+ const trimmedTitle = editedTitle.trim();
+
+ if (!trimmedTitle) {
+ handleDelete();
+
+ return;
+ }
+
+ setTodos(
+ todos.map(item => (
+ item.id === todo.id
+ ? {
+ ...item,
+ title: trimmedTitle,
+ }
+ : item
+ )),
+ );
+
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (
+ event: React.KeyboardEvent,
+ ) => {
+ if (event.key === 'Enter') {
+ handleSave();
+ }
+
+ if (event.key === 'Escape') {
+ setEditedTitle(todo.title);
+ setIsEditing(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {isEditing ? (
+ setEditedTitle(event.target.value)}
+ onBlur={handleSave}
+ onKeyDown={handleKeyDown}
+ />
+ ) : (
+ setIsEditing(true)}
+ >
+ {todo.title}
+
+ )}
+
+ {!isEditing && (
+
+ ×
+
+ )}
+
+ );
+};
diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts
new file mode 100644
index 000000000..21f4abac3
--- /dev/null
+++ b/src/components/TodoItem/index.ts
@@ -0,0 +1 @@
+export * from './TodoItem';
diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx
new file mode 100644
index 000000000..1e8d795a6
--- /dev/null
+++ b/src/components/TodoList/TodoList.tsx
@@ -0,0 +1,30 @@
+/* eslint-disable prettier/prettier */
+import React, { useContext } from 'react';
+import { TodoContext } from '../../context/TodoContext';
+import { TodoItem } from '../TodoItem';
+import { FILTERS } from '../../types/Filter';
+
+export const TodoList: React.FC = () => {
+ const { todos, filter } = useContext(TodoContext);
+
+ const visibleTodos = todos.filter(todo => {
+ switch (filter) {
+ case FILTERS.active:
+ return !todo.completed;
+
+ case FILTERS.completed:
+ return todo.completed;
+
+ default:
+ return true;
+ }
+ });
+
+ return (
+
+ {visibleTodos.map(todo => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts
new file mode 100644
index 000000000..f239f4345
--- /dev/null
+++ b/src/components/TodoList/index.ts
@@ -0,0 +1 @@
+export * from './TodoList';
diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx
new file mode 100644
index 000000000..fea7480e4
--- /dev/null
+++ b/src/context/TodoContext.tsx
@@ -0,0 +1,17 @@
+import React, { createContext } from 'react';
+import { Filter, FILTERS } from '../types/Filter';
+import { Todo } from '../types/Todo';
+
+type TodoContextType = {
+ todos: Todo[];
+ setTodos: React.Dispatch>;
+ filter: Filter;
+ setFilter: React.Dispatch>;
+};
+
+export const TodoContext = createContext({
+ todos: [],
+ setTodos: () => {},
+ filter: FILTERS.all,
+ setFilter: () => {},
+});
diff --git a/src/context/TodoProvider.tsx b/src/context/TodoProvider.tsx
new file mode 100644
index 000000000..8b0d3a74b
--- /dev/null
+++ b/src/context/TodoProvider.tsx
@@ -0,0 +1,35 @@
+import React, { useEffect, useState } from 'react';
+import { Filter, FILTERS } from '../types/Filter';
+import { Todo } from '../types/Todo';
+import { TodoContext } from './TodoContext';
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const TodoProvider: React.FC = ({ children }) => {
+ const [todos, setTodos] = useState(() => {
+ const savedTodos = localStorage.getItem('todos');
+
+ return savedTodos ? JSON.parse(savedTodos) : [];
+ });
+
+ const [filter, setFilter] = useState(FILTERS.all);
+
+ useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(todos));
+ }, [todos]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index b2c38a17a..d882914c8 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/TodoProvider';
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..9d0249da6
--- /dev/null
+++ b/src/types/Filter.ts
@@ -0,0 +1,7 @@
+export const FILTERS = {
+ all: 'All',
+ active: 'Active',
+ completed: 'Completed',
+} as const;
+
+export type Filter = (typeof FILTERS)[keyof typeof FILTERS];
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;
+}