diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index b51149cf5..26bf31468 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -73,9 +73,7 @@ module.exports = {
'react/jsx-props-no-spreading': 0,
'react/state-in-constructor': [2, 'never'],
'react-hooks/rules-of-hooks': 2,
- 'jsx-a11y/label-has-associated-control': ["error", {
- assert: "either",
- }],
+ 'jsx-a11y/label-has-associated-control': ['off'],
'jsx-a11y/label-has-for': [2, {
components: ['Label'],
required: {
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/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..08139dec4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,156 +1,42 @@
/* eslint-disable jsx-a11y/control-has-associated-label */
import React from 'react';
+import { Footer } from './components/footer';
+import { Header } from './components/header';
+import { StateContext } from './context/global-context';
+import { ToDo } from './components/todo';
export const App: React.FC = () => {
+ const state = React.useContext(StateContext);
+ const visibleTodos = state.todos.filter(todo => {
+ if (state.filter === 'active') {
+ return !todo.completed;
+ }
+
+ if (state.filter === 'completed') {
+ return todo.completed;
+ }
+
+ return true;
+ });
+
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
-
-
+
+
+ {state.todos.length > 0 && (
+ <>
+
+ {visibleTodos.map(todo => (
+
+ ))}
+
+
+
+ >
+ )}
);
diff --git a/src/components/footer.tsx b/src/components/footer.tsx
new file mode 100644
index 000000000..5c953892d
--- /dev/null
+++ b/src/components/footer.tsx
@@ -0,0 +1,48 @@
+import { useContext } from 'react';
+import {
+ StateContext,
+ FILTER_NAMES,
+ DispatchContext,
+} from '../context/global-context';
+
+export const Footer = () => {
+ const state = useContext(StateContext);
+ const dispatch = useContext(DispatchContext);
+
+ const activeTodosCount = state.todos.filter(todo => !todo.completed).length;
+ const hasCompletedTodos = state.todos.some(todo => todo.completed);
+
+ return (
+
+ );
+};
diff --git a/src/components/header.tsx b/src/components/header.tsx
new file mode 100644
index 000000000..6221e6bc1
--- /dev/null
+++ b/src/components/header.tsx
@@ -0,0 +1,66 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { DispatchContext, StateContext } from '../context/global-context';
+
+export const Header = () => {
+ const dispatch = useContext(DispatchContext);
+ const state = useContext(StateContext);
+ const [title, setTitle] = useState('');
+
+ const inputRef = React.useRef(null);
+ const isAllCompleted = state.todos.every(todo => todo.completed);
+
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, [state.todos.length]);
+
+ const addTodo = () => {
+ const trimmedTitle = title.trim();
+
+ if (!trimmedTitle) {
+ return;
+ }
+
+ dispatch({
+ type: 'add',
+ payload: {
+ id: +new Date(),
+ title: trimmedTitle,
+ completed: false,
+ },
+ });
+ };
+
+ return (
+
+ {/* this button should have `active` class only if all todos are completed */}
+ {!!state.todos.length && (
+ dispatch({ type: 'toggle_all' })}
+ type="button"
+ className={`todoapp__toggle-all ${isAllCompleted ? 'active' : ''}`}
+ data-cy="ToggleAllButton"
+ />
+ )}
+
+ {/* Add a todo on form submit */}
+
+
+ );
+};
diff --git a/src/components/todo.tsx b/src/components/todo.tsx
new file mode 100644
index 000000000..04cdfa89a
--- /dev/null
+++ b/src/components/todo.tsx
@@ -0,0 +1,91 @@
+import { useContext, useEffect, useRef, useState } from 'react';
+import { DispatchContext, Todo } from '../context/global-context';
+
+export const ToDo = ({ todo }: { todo: Todo }) => {
+ const dispatch = useContext(DispatchContext);
+ const [isEditing, setIsEditing] = useState(null);
+ const [title, setTitle] = useState(todo.title);
+
+ const editFieldRef = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ editFieldRef.current?.focus();
+ }
+ }, [isEditing]);
+
+ const submit = (e?: React.FormEvent) => {
+ e?.preventDefault();
+
+ const trimmedTitle = title.trim();
+
+ if (!trimmedTitle) {
+ dispatch({ type: 'remove', payload: { id: todo.id } });
+ } else if (trimmedTitle !== todo.title) {
+ dispatch({
+ type: 'edit',
+ payload: { id: todo.id, title: trimmedTitle },
+ });
+ }
+
+ setIsEditing(null);
+ };
+
+ return (
+
+
+
+ dispatch({ type: 'toggle', payload: { id: todo.id } })
+ }
+ />
+
+
+ {!isEditing ? (
+ {
+ setIsEditing(todo);
+ setTitle(todo.title);
+ }}
+ >
+ {todo.title}
+
+ ) : (
+
+ )}
+ {/* Remove button appears only on hover */}
+ {!isEditing && (
+ dispatch({ type: 'remove', payload: { id: todo.id } })}
+ >
+ ×
+
+ )}
+
+ );
+};
diff --git a/src/context/global-context.tsx b/src/context/global-context.tsx
new file mode 100644
index 000000000..fc0393b9f
--- /dev/null
+++ b/src/context/global-context.tsx
@@ -0,0 +1,99 @@
+import React from 'react';
+import { State } from '../types/state';
+import { FilterType } from '../types/filters';
+import { Action } from '../types/action';
+
+const getInitialState = (): State => {
+ const saved = localStorage.getItem('todos');
+
+ try {
+ return {
+ todos: saved ? JSON.parse(saved) : [],
+ filter: 'all',
+ };
+ } catch (e) {
+ return { todos: [], filter: 'all' };
+ }
+};
+
+export const FILTER_NAMES: FilterType[] = ['all', 'active', 'completed'];
+
+const initialState = getInitialState();
+
+const reducer = (state: State, action: Action): State => {
+ switch (action.type) {
+ case 'add':
+ return {
+ ...state,
+ todos: [...state.todos, action.payload],
+ };
+ case 'remove':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => todo.id !== action.payload.id),
+ };
+ case 'toggle':
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload.id
+ ? { ...todo, completed: !todo.completed }
+ : todo,
+ ),
+ };
+ case 'edit':
+ 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,
+ };
+ case 'clearCompleted':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => !todo.completed),
+ };
+ case 'toggle_all':
+ return {
+ ...state,
+ todos: state.todos.map(todo => {
+ return {
+ ...todo,
+ completed: !state.todos.every(toDo => toDo.completed),
+ };
+ }),
+ };
+ default:
+ return state;
+ }
+};
+
+export const StateContext = React.createContext(initialState);
+export const DispatchContext = React.createContext>(
+ () => {},
+);
+
+export const GlobalStateProvider = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => {
+ const [state, dispatch] = React.useReducer(reducer, initialState);
+
+ React.useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(state.todos));
+ }, [state.todos]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index b2c38a17a..bdc069b7f 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 { GlobalStateProvider } from './context/global-context';
const container = document.getElementById('root') as HTMLDivElement;
-createRoot(container).render( );
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss
index 4576af434..cfb34ec2f 100644
--- a/src/styles/todo-list.scss
+++ b/src/styles/todo-list.scss
@@ -71,6 +71,7 @@
}
&__title-field {
+ box-sizing: border-box;
width: 100%;
padding: 11px 14px;
diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss
index e289a9458..29383a1e2 100644
--- a/src/styles/todoapp.scss
+++ b/src/styles/todoapp.scss
@@ -56,6 +56,7 @@
}
&__new-todo {
+ box-sizing: border-box;
width: 100%;
padding: 16px 16px 16px 60px;
diff --git a/src/types/action.ts b/src/types/action.ts
new file mode 100644
index 000000000..f5498226a
--- /dev/null
+++ b/src/types/action.ts
@@ -0,0 +1,11 @@
+import { FilterType } from './filters';
+import { Todo } from './todo';
+
+export type Action =
+ | { type: 'add'; payload: Todo }
+ | { type: 'remove'; payload: { id: number } }
+ | { type: 'toggle'; payload: { id: number } }
+ | { type: 'edit'; payload: { id: number; title: string } }
+ | { type: 'setFilter'; payload: FilterType }
+ | { type: 'clearCompleted' }
+ | { type: 'toggle_all' };
diff --git a/src/types/filters.ts b/src/types/filters.ts
new file mode 100644
index 000000000..30fddb0a8
--- /dev/null
+++ b/src/types/filters.ts
@@ -0,0 +1 @@
+export type FilterType = 'all' | 'active' | 'completed';
diff --git a/src/types/state.ts b/src/types/state.ts
new file mode 100644
index 000000000..feb418b9b
--- /dev/null
+++ b/src/types/state.ts
@@ -0,0 +1,7 @@
+import { FilterType } from './filters';
+import { Todo } from './todo';
+
+export type State = {
+ todos: Todo[];
+ filter: FilterType;
+};
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;
+};