diff --git a/package-lock.json b/package-lock.json
index 0adcc869f..1f19b4743 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.8.5",
+ "@mate-academy/scripts": "^1.9.12",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
@@ -1170,9 +1170,9 @@
}
},
"node_modules/@mate-academy/scripts": {
- "version": "1.8.5",
- "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz",
- "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==",
+ "version": "1.9.12",
+ "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz",
+ "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==",
"dev": true,
"dependencies": {
"@octokit/rest": "^17.11.2",
diff --git a/package.json b/package.json
index e6134ce84..91d7489b9 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.8.5",
+ "@mate-academy/scripts": "^1.9.12",
"@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..6481b2da2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,156 +1,25 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import React, { useContext } from 'react';
+import { Header } from './components/Header/Header';
+import { Footer } from './components/Footer/Footer';
+import { TodoList } from './components/TodoList/TodoList';
+import { TodosContext } from './components/TodoAppContext/TodoAppContext';
export const App: React.FC = () => {
+ const { todos } = useContext(TodosContext);
+
+ // console.log(todos.length);
+
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
-
-
+ {todos.length !== 0 &&
}
);
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 000000000..7ae0ac7dd
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,74 @@
+import { useContext } from 'react';
+import {
+ TodosContext,
+ TodosDispatchContext,
+} from '../TodoAppContext/TodoAppContext';
+import classNames from 'classnames';
+import { TodoStatus } from '../../types/TodoStatus';
+
+export const Footer: React.FC = () => {
+ const { todos, filterStatus } = useContext(TodosContext);
+ const dispatch = useContext(TodosDispatchContext);
+ const uncomletedTodos = todos.filter(todo => !todo.completed);
+
+ return (
+
+ );
+};
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 000000000..11c03e3fc
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,73 @@
+import { useContext, useEffect, useRef, useState } from 'react';
+import {
+ TodosContext,
+ TodosDispatchContext,
+} from '../TodoAppContext/TodoAppContext';
+import classNames from 'classnames';
+import { Todo } from '../../types/Todo';
+
+export const Header: React.FC = () => {
+ const [title, setTitle] = useState('');
+ const dispatch = useContext(TodosDispatchContext);
+ const { todos } = useContext(TodosContext);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const completedTodosLength = todos.filter(el => el.completed).length;
+ const field = useRef(null);
+
+ useEffect(() => {
+ field.current?.focus();
+ }, [isSubmitting, todos]);
+
+ const handleAdd = (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+
+ if (!title.trim()) {
+ return;
+ }
+
+ const newTodo: Todo = {
+ id: +new Date(),
+ title: title.trim(),
+ completed: false,
+ };
+
+ dispatch({
+ type: 'addTodo',
+ payload: newTodo,
+ });
+ setTitle('');
+ setIsSubmitting(false);
+ };
+
+ return (
+
+ {/* this button should have `active` class only if all todos are completed */}
+ {todos.length !== 0 && (
+ {
+ dispatch({ type: 'toggleAllStatuses' });
+ }}
+ />
+ )}
+
+ {/* Add a todo on form submit */}
+
+
+ );
+};
diff --git a/src/components/TodoAppContext/TodoAppContext.tsx b/src/components/TodoAppContext/TodoAppContext.tsx
new file mode 100644
index 000000000..d85d4b660
--- /dev/null
+++ b/src/components/TodoAppContext/TodoAppContext.tsx
@@ -0,0 +1,103 @@
+import { createContext, useEffect, useReducer } from 'react';
+import { TodoStatus } from '../../types/TodoStatus';
+import { Todo } from '../../types/Todo';
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export type Action =
+ | { type: 'addTodo'; payload: Todo }
+ | { type: 'deleteTodo'; payload: number }
+ | { type: 'deleteCompletedTodos' }
+ | { type: 'filterBy'; payload: TodoStatus }
+ | { type: 'updateStatusTodo'; payload: Todo }
+ | { type: 'toggleAllStatuses' }
+ | { type: 'editTitle'; payload: number; title: string };
+
+const data = localStorage.getItem('todos');
+const initialTodos: Todo[] = JSON.parse(data as string) || [];
+
+const initialState = {
+ todos: initialTodos,
+ filterStatus: TodoStatus.All,
+};
+
+type RootState = typeof initialState;
+
+export const TodosContext = createContext(initialState);
+
+export const TodosDispatchContext = createContext>(
+ () => {},
+);
+
+const reducer = (state: RootState, action: Action) => {
+ switch (action.type) {
+ case 'addTodo':
+ return { ...state, todos: [...state.todos, action.payload] };
+ case 'filterBy':
+ return { ...state, filterStatus: action.payload };
+ case 'deleteTodo':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => todo.id !== action.payload) || [],
+ };
+ case 'deleteCompletedTodos':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => !todo.completed) || [],
+ };
+ case 'updateStatusTodo':
+ return {
+ ...state,
+ todos:
+ state.todos.map(todo =>
+ todo.id === action.payload.id
+ ? { ...todo, completed: !action.payload.completed }
+ : todo,
+ ) || [],
+ };
+ case 'toggleAllStatuses':
+ const complTodosLength =
+ state.todos.filter(el => el.completed).length || 0;
+ const completedToValue =
+ state.todos.length === complTodosLength ? false : true;
+
+ return {
+ ...state,
+ todos:
+ state.todos.map(todo => {
+ return { ...todo, completed: completedToValue };
+ }) || [],
+ };
+ case 'editTitle':
+ return {
+ ...state,
+ todos:
+ state.todos.map(todo =>
+ todo.id === action.payload
+ ? { ...todo, title: action.title }
+ : todo,
+ ) || [],
+ };
+ default: {
+ throw Error('Unknown action');
+ }
+ }
+};
+
+export const TodosProvider: React.FC = ({ children }) => {
+ const [todoState, dispatch] = useReducer(reducer, initialState);
+
+ useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(todoState.todos));
+ }, [todoState]);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx
new file mode 100644
index 000000000..f623c48c8
--- /dev/null
+++ b/src/components/TodoItem/TodoItem.tsx
@@ -0,0 +1,100 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import classNames from 'classnames';
+import { useContext, useEffect, useRef, useState } from 'react';
+import { TodosDispatchContext } from '../TodoAppContext/TodoAppContext';
+import { Todo } from '../../types/Todo';
+
+type Props = {
+ todo: Todo;
+};
+
+export const TodoItem: React.FC = ({ todo }) => {
+ const dispatch = useContext(TodosDispatchContext);
+ const [isEditing, setIsEditing] = useState(false);
+ const [title, setTitle] = useState(todo.title);
+ const field = useRef(null);
+
+ useEffect(() => {
+ field.current?.focus();
+ }, [isEditing]);
+
+ const handleEditClose = (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!title.trim()) {
+ dispatch({ type: 'deleteTodo', payload: todo.id });
+ setIsEditing(false);
+
+ return;
+ }
+
+ if (todo.title === title) {
+ setIsEditing(false);
+
+ return;
+ }
+
+ dispatch({ type: 'editTitle', payload: todo.id, title: title.trim() });
+ setTitle(title.trim());
+ setIsEditing(false);
+ };
+
+ const handleKeyChange = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setIsEditing(false);
+ }
+ };
+
+ return (
+
+
+ {
+ dispatch({ type: 'updateStatusTodo', payload: todo });
+ }}
+ />
+
+
+ {!isEditing ? (
+ <>
+ setIsEditing(true)}
+ >
+ {todo.title}
+
+ dispatch({ type: 'deleteTodo', payload: todo.id })}
+ >
+ ×
+
+ >
+ ) : (
+
+ )}
+
+ );
+};
diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx
new file mode 100644
index 000000000..21179236f
--- /dev/null
+++ b/src/components/TodoList/TodoList.tsx
@@ -0,0 +1,32 @@
+import { useContext } from 'react';
+import { TodosContext } from '../TodoAppContext/TodoAppContext';
+import { TodoStatus } from '../../types/TodoStatus';
+import { TodoItem } from '../TodoItem/TodoItem';
+import { Todo } from '../../types/Todo';
+
+function getVisibleTodos(todos: Todo[], status: TodoStatus) {
+ const copyTodos = [...todos];
+
+ if (status === TodoStatus.Active) {
+ return copyTodos.filter(todo => !todo.completed);
+ }
+
+ if (status === TodoStatus.Completed) {
+ return copyTodos.filter(todo => todo.completed);
+ }
+
+ return copyTodos;
+}
+
+export const TodoList: React.FC = () => {
+ const { todos, filterStatus } = useContext(TodosContext);
+
+ const visibleTodos = getVisibleTodos(todos, filterStatus);
+
+ return (
+
+ {visibleTodos.length !== 0 &&
+ visibleTodos.map(todo => )}
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index a9689cb38..d305e3c1e 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,11 +1,14 @@
import { createRoot } from 'react-dom/client';
-import './styles/index.css';
-import './styles/todo-list.css';
-import './styles/filters.css';
+import './styles/index.scss';
import { App } from './App';
+import { TodosProvider } from './components/TodoAppContext/TodoAppContext';
const container = document.getElementById('root') as HTMLDivElement;
-createRoot(container).render( );
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/src/styles/todo.scss b/src/styles/todo.scss
index 4576af434..fd5f6aa10 100644
--- a/src/styles/todo.scss
+++ b/src/styles/todo.scss
@@ -73,6 +73,7 @@
&__title-field {
width: 100%;
padding: 11px 14px;
+ box-sizing: border-box;
font-size: inherit;
line-height: inherit;
diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss
index e289a9458..cf79aa03b 100644
--- a/src/styles/todoapp.scss
+++ b/src/styles/todoapp.scss
@@ -58,6 +58,7 @@
&__new-todo {
width: 100%;
padding: 16px 16px 16px 60px;
+ box-sizing: border-box;
font-size: 24px;
line-height: 1.4em;
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/TodoStatus.ts b/src/types/TodoStatus.ts
new file mode 100644
index 000000000..207c27648
--- /dev/null
+++ b/src/types/TodoStatus.ts
@@ -0,0 +1,5 @@
+export enum TodoStatus {
+ All = 'All',
+ Active = 'Active',
+ Completed = 'Completed',
+}