-
- {/* this button should have `active` class only if all todos are completed */}
-
-
- {/* Add a todo on form submit */}
-
-
-
-
+ const handleAllTodoCompleted = () => {
+ const updatedTodos = todos.map(todo => ({
+ ...todo,
+ completed: !isAllCompleted,
+ }));
- {/* Hide the footer if there are no todos */}
-
-
- 3 items left
-
+ setTodos(updatedTodos);
+ };
- {/* Active link should have the 'selected' class */}
-
-
- All
-
+ return (
+
+
todos
-
- Active
-
+
+
-
- Completed
-
-
+ {todos.length > 0 &&
}
- {/* this button should be disabled if there are no completed todos */}
-
- Clear completed
-
-
+ {todos.length > 0 &&
}
);
diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx
new file mode 100644
index 000000000..039152fcb
--- /dev/null
+++ b/src/components/Filter.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import cn from 'classnames';
+
+import { FilterType } from '../types/FilterType';
+
+const filters = [
+ { type: FilterType.All, href: '#/', dataCy: 'FilterLinkAll' },
+ { type: FilterType.Active, href: '#/active', dataCy: 'FilterLinkActive' },
+ {
+ type: FilterType.Completed,
+ href: '#/completed',
+ dataCy: 'FilterLinkCompleted',
+ },
+];
+
+type Props = {
+ filter: FilterType;
+ setFilter: (filter: FilterType) => void;
+};
+
+export const Filter: React.FC = ({ filter, setFilter }) => (
+
+ {filters.map(({ type, href, dataCy }) => (
+ {
+ event.preventDefault();
+ setFilter(type);
+ }}
+ >
+ {type}
+
+ ))}
+
+);
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 000000000..ef54f184d
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,42 @@
+import React from 'react';
+
+import { FilterType } from '../types/FilterType';
+import { Filter } from './Filter';
+import { useTodos } from './TodosContext';
+
+type Props = {
+ filter: FilterType;
+ setFilter: (filter: FilterType) => void;
+};
+
+export const Footer: React.FC = ({ filter, setFilter }) => {
+ const { todos, setTodos } = useTodos();
+
+ const numberOfActiveTodos = todos.filter(todo => !todo.completed).length;
+
+ const handleDeleteCompletedTodos = () => {
+ const unCompletedTodos = todos.filter(todo => !todo.completed);
+
+ setTodos(unCompletedTodos);
+ };
+
+ return (
+
+
+ {numberOfActiveTodos} items left
+
+
+
+
+ todo.completed)}
+ onClick={handleDeleteCompletedTodos}
+ >
+ Clear completed
+
+
+ );
+};
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 000000000..0e492d5c2
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import cn from 'classnames';
+import { useTodos } from './TodosContext';
+
+type Props = {
+ query: string;
+ setQuery: (query: string) => void;
+ isAllCompleted: boolean;
+ handleAllTodoCompleted: () => void;
+ inputRef: React.RefObject;
+};
+
+export const Header: React.FC = ({
+ query,
+ setQuery,
+ isAllCompleted,
+ handleAllTodoCompleted,
+ inputRef,
+}) => {
+ const { todos, addTodo } = useTodos();
+
+ const handleAddTodo = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' && query.trim() !== '') {
+ addTodo(query.trim());
+ setQuery('');
+ }
+ };
+
+ return (
+
+ {todos.length > 0 && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx
new file mode 100644
index 000000000..00056f414
--- /dev/null
+++ b/src/components/TodoItem.tsx
@@ -0,0 +1,103 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import React, { useState } from 'react';
+import cn from 'classnames';
+
+import { Todo } from '../types/Todo';
+import { useTodos } from './TodosContext';
+
+type Props = {
+ todo: Todo;
+};
+
+export const TodoItem: React.FC = ({ todo }) => {
+ const { deleteTodo, updateTodo } = useTodos();
+ const [editingTodoId, setEditingTodoId] = useState(null);
+ const [titleText, setTitleText] = useState('');
+
+ const handleSaveUpdated = () => {
+ const trimmedTitleText = titleText.trim();
+
+ if (!trimmedTitleText) {
+ deleteTodo(todo.id);
+ } else if (trimmedTitleText !== todo.title) {
+ updateTodo({ ...todo, title: trimmedTitleText });
+ }
+
+ setEditingTodoId(null);
+ setTitleText('');
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleSaveUpdated();
+ }
+ };
+
+ const handleKeyUp = (
+ event: React.KeyboardEvent,
+ title: string,
+ ) => {
+ if (event.key === 'Escape') {
+ setTitleText(title);
+ setEditingTodoId(null);
+ }
+ };
+
+ return (
+
+
+ updateTodo({ ...todo, completed: !todo.completed })}
+ />
+
+
+ {editingTodoId === todo.id ? (
+
+ ) : (
+ {
+ setEditingTodoId(todo.id);
+ setTitleText(todo.title);
+ }}
+ >
+ {todo.title}
+
+ )}
+
+ {!editingTodoId && (
+ deleteTodo(todo.id)}
+ >
+ ×
+
+ )}
+
+ );
+};
diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx
new file mode 100644
index 000000000..6233a4955
--- /dev/null
+++ b/src/components/TodoList.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+import { FilterType } from '../types/FilterType';
+import { TodoItem } from './TodoItem';
+import { useTodos } from './TodosContext';
+
+type Props = {
+ filter: FilterType;
+};
+
+export const TodoList: React.FC = ({ filter }) => {
+ const { todos } = useTodos();
+
+ const filteredTodos = todos.filter(todo => {
+ switch (filter) {
+ case FilterType.Active:
+ return !todo.completed;
+ case FilterType.Completed:
+ return todo.completed;
+ default:
+ return true;
+ }
+ });
+
+ return (
+
+ {filteredTodos.map(todo => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/TodosContext.tsx b/src/components/TodosContext.tsx
new file mode 100644
index 000000000..7f9ed0a9c
--- /dev/null
+++ b/src/components/TodosContext.tsx
@@ -0,0 +1,86 @@
+import React, { useContext, useEffect, useMemo, useState } from 'react';
+
+import { Todo } from '../types/Todo';
+
+const getTodos = (): Todo[] => {
+ const data = localStorage.getItem('todos');
+
+ try {
+ return JSON.parse(data || '[]');
+ } catch {
+ localStorage.removeItem('todos');
+
+ return [];
+ }
+};
+
+interface TodosContextType {
+ todos: Todo[];
+ setTodos: React.Dispatch>;
+ addTodo: (title: string) => void;
+ deleteTodo: (id: string) => void;
+ updateTodo: (updatedTodo: Todo) => void;
+}
+
+export const TodosContext = React.createContext(
+ undefined,
+);
+
+export const TodosProvider: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const [todos, setTodos] = useState(getTodos());
+
+ useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(todos));
+ }, [todos]);
+
+ const value = useMemo(() => {
+ const addTodo = (title: string) => {
+ if (title.trim() === '') {
+ return;
+ }
+
+ const newTodo: Todo = {
+ id: crypto.randomUUID(),
+ title: title.trim(),
+ completed: false,
+ };
+
+ setTodos(prevTodos => [...prevTodos, newTodo]);
+ localStorage.setItem('todos', JSON.stringify([...todos, newTodo]));
+ };
+
+ const deleteTodo = (id: string) => {
+ const updatedTodos = todos.filter(todo => todo.id !== id);
+
+ setTodos(updatedTodos);
+ localStorage.setItem('todos', JSON.stringify(updatedTodos));
+ };
+
+ const updateTodo = (updatedTodo: Todo) => {
+ const updatedTodos = todos.map(todo =>
+ todo.id === updatedTodo.id ? updatedTodo : todo,
+ );
+
+ setTodos(updatedTodos);
+ localStorage.setItem('todos', JSON.stringify(updatedTodos));
+ };
+
+ return { todos, setTodos, addTodo, deleteTodo, updateTodo };
+ }, [todos]);
+
+ return (
+ {children}
+ );
+};
+
+export const useTodos = (): TodosContextType => {
+ const context = useContext(TodosContext);
+
+ if (!context) {
+ throw new Error('useTodos must be used within a TodosProvider');
+ }
+
+ return context;
+};
diff --git a/src/index.tsx b/src/index.tsx
index b2c38a17a..8735edb87 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 { TodosProvider } from './components/TodosContext';
const container = document.getElementById('root') as HTMLDivElement;
-createRoot(container).render( );
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/src/styles/index.scss b/src/styles/index.scss
index d8d324941..28064a37f 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -1,3 +1,7 @@
+@use './todoapp';
+@use './todo-list';
+@use './filters';
+
iframe {
display: none;
}
@@ -19,7 +23,3 @@ body {
opacity: 0;
pointer-events: none;
}
-
-@import './todoapp';
-@import './todo-list';
-@import './filters';
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/FilterType.ts b/src/types/FilterType.ts
new file mode 100644
index 000000000..579c7f50c
--- /dev/null
+++ b/src/types/FilterType.ts
@@ -0,0 +1,5 @@
+export enum FilterType {
+ All = 'All',
+ Active = 'Active',
+ Completed = 'Completed',
+}
diff --git a/src/types/Todo.ts b/src/types/Todo.ts
new file mode 100644
index 000000000..476a3f9ef
--- /dev/null
+++ b/src/types/Todo.ts
@@ -0,0 +1,5 @@
+export interface Todo {
+ id: string;
+ title: string;
+ completed: boolean;
+}