diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..7edee84ff 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,156 +1,23 @@
/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+import React, { useContext } from 'react';
+import { StateContext } from './components/Store';
+import { Header } from './components/Header';
+import { TodoList } from './components/TodoList';
+import { Footer } from './components/Footer';
export const App: React.FC = () => {
+ const { todos } = useContext(StateContext);
+
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 &&
}
);
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 000000000..897dd70ab
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,51 @@
+import { useContext } from 'react';
+import cn from 'classnames';
+import { DispatchContext, StateContext } from './Store';
+import { getActiveTodosArray, getCompletedTodosArray } from '../services';
+import { FilterTodos } from '../types/FilterTodos';
+
+export const Footer = () => {
+ const { todos, filter } = useContext(StateContext);
+ const dispatch = useContext(DispatchContext);
+
+ return (
+
+ );
+};
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 000000000..616ea0846
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,74 @@
+import { FormEvent, useContext, useEffect, useRef } from 'react';
+import classNames from 'classnames';
+import { DispatchContext, StateContext } from './Store';
+import { Todo } from '../types/Todo';
+import { getCompletedTodosArray } from '../services';
+
+export const Header = () => {
+ const { todos, newTodoTitle } = useContext(StateContext);
+ const dispatch = useContext(DispatchContext);
+
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
+ });
+
+ const handleSetNewTodoTitle = (
+ event: React.ChangeEvent,
+ ) => {
+ dispatch({ type: 'setNewTodoTitle', payload: event.target.value });
+ };
+
+ const handleAddTodo = (event: FormEvent) => {
+ event.preventDefault();
+
+ if (!newTodoTitle.trim()) {
+ return;
+ }
+
+ const newTodo: Todo = {
+ id: +new Date(),
+ title: newTodoTitle.trim(),
+ completed: false,
+ };
+
+ dispatch({ type: 'addTodo', payload: newTodo });
+ dispatch({ type: 'setNewTodoTitle', payload: '' });
+ };
+
+ const validation = todos.every(todo => todo.completed === true);
+
+ const handleToggleAll = () => {
+ dispatch({ type: 'setAllCompleted', payload: !validation });
+ };
+
+ return (
+
+ {!!todos.length && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Store.tsx b/src/components/Store.tsx
new file mode 100644
index 000000000..aeb680371
--- /dev/null
+++ b/src/components/Store.tsx
@@ -0,0 +1,123 @@
+import React, { useEffect, useReducer } from 'react';
+import { Todo } from '../types/Todo';
+import { TodoStatus } from '../types/TodoStatus';
+import { FilterTodos } from '../types/FilterTodos';
+
+type State = {
+ todos: Todo[];
+ newTodoTitle: string;
+ status: TodoStatus;
+ filter: FilterTodos;
+};
+
+const getTodosFromLocalStorage = (): Todo[] => {
+ const todos = localStorage.getItem('todos');
+
+ return todos ? JSON.parse(todos) : [];
+};
+
+const initialTodos: Todo[] = getTodosFromLocalStorage();
+
+const initialState: State = {
+ todos: initialTodos,
+ newTodoTitle: '',
+ status: TodoStatus.all,
+ filter: FilterTodos.All,
+};
+
+type Action =
+ | { type: 'addTodo'; payload: Todo }
+ | { type: 'deleteTodo'; payload: number }
+ | { type: 'updateTodo'; payload: Todo }
+ | { type: 'setNewTodoTitle'; payload: string }
+ | { type: 'setAllCompleted'; payload: boolean }
+ | { type: 'setStatus'; payload: TodoStatus }
+ | { type: 'setNewStatus'; payload: Todo }
+ | { type: 'setFilterByStatus'; payload: FilterTodos }
+ | { type: 'clearAllCompleted' };
+
+function reducer(state: State, action: Action): State {
+ switch (action.type) {
+ case 'addTodo':
+ return {
+ ...state,
+ todos: [...state.todos, action.payload],
+ };
+ case 'deleteTodo':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => todo.id !== action.payload),
+ };
+ case 'updateTodo':
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload.id
+ ? { ...todo, title: action.payload.title }
+ : todo,
+ ),
+ };
+ case 'setNewTodoTitle':
+ return {
+ ...state,
+ newTodoTitle: action.payload,
+ };
+ case 'setAllCompleted':
+ return {
+ ...state,
+ todos: state.todos.map(todo => ({
+ ...todo,
+ completed: action.payload,
+ })),
+ };
+ case 'setStatus':
+ return {
+ ...state,
+ status: action.payload,
+ };
+ case 'setNewStatus':
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload.id
+ ? { ...todo, completed: !todo.completed }
+ : todo,
+ ),
+ };
+ case 'setFilterByStatus':
+ return {
+ ...state,
+ filter: action.payload,
+ };
+ case 'clearAllCompleted':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => !todo.completed),
+ };
+ default:
+ return state;
+ }
+}
+
+export const StateContext = React.createContext(initialState);
+export const DispatchContext = React.createContext>(
+ () => {},
+);
+
+type Props = {
+ children: React.ReactNode;
+};
+
+export const GlobalStateProvider: React.FC = ({ children }) => {
+ const [state, dispatch] = useReducer(reducer, initialState);
+
+ useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(state.todos));
+ }, [state.todos]);
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx
new file mode 100644
index 000000000..019dda54f
--- /dev/null
+++ b/src/components/TodoItem.tsx
@@ -0,0 +1,143 @@
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import classNames from 'classnames';
+import React, { useContext, useEffect, useRef, useState } from 'react';
+import { Todo } from '../types/Todo';
+import { DispatchContext } from './Store';
+
+type Props = {
+ todo: Todo;
+};
+
+export const TodoItem: React.FC = ({ todo }) => {
+ const dispatch = useContext(DispatchContext);
+
+ const handleChangeStatusTodo = (
+ event: React.ChangeEvent,
+ ) => {
+ dispatch({
+ type: 'setNewStatus',
+ payload: { ...todo, completed: event.target.checked },
+ });
+ };
+
+ const [isEditingTodo, setIsEditingTodo] = useState(false);
+ const [updatedTitleTodo, setUpdatedTitleTodo] = useState(todo.title);
+
+ const updatedInput = useRef(null);
+
+ useEffect(() => {
+ if (isEditingTodo && updatedInput.current) {
+ updatedInput.current?.focus();
+ }
+ }, [isEditingTodo]);
+
+ useEffect(() => setIsEditingTodo(false), [todo]);
+
+ const { id, title, completed } = todo;
+
+ const handleDeleteTodo = (todoId: number) => {
+ dispatch({ type: 'deleteTodo', payload: todoId });
+ };
+
+ const handleSubmit = () => {
+ const correctTitle = updatedTitleTodo.trim();
+
+ if (correctTitle === title) {
+ setIsEditingTodo(false);
+
+ return;
+ }
+
+ if (!correctTitle) {
+ dispatch({ type: 'deleteTodo', payload: todo.id });
+
+ return;
+ }
+
+ setUpdatedTitleTodo(correctTitle);
+
+ dispatch({ type: 'updateTodo', payload: { ...todo, title: correctTitle } });
+ };
+
+ const handleBlur = () => {
+ handleSubmit();
+ };
+
+ const handleKeyEvent = (event: React.KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setUpdatedTitleTodo(todo.title);
+ setIsEditingTodo(false);
+ }
+ };
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setUpdatedTitleTodo(event.target.value);
+ };
+
+ const handleDoubleClick = () => {
+ if (!isEditingTodo) {
+ setIsEditingTodo(true);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {isEditingTodo ? (
+
+ ) : (
+ <>
+
+ {updatedTitleTodo}
+
+
+ handleDeleteTodo(id)}
+ >
+ ×
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx
new file mode 100644
index 000000000..1931c60cc
--- /dev/null
+++ b/src/components/TodoList.tsx
@@ -0,0 +1,28 @@
+import { useContext, useMemo } from 'react';
+import { TodoItem } from './TodoItem';
+import { StateContext } from './Store';
+import { FilterTodos } from '../types/FilterTodos';
+
+export const TodoList = () => {
+ const { todos, filter } = useContext(StateContext);
+
+ const preparedTodos = useMemo(() => {
+ if (filter === FilterTodos.Active) {
+ return todos.filter(todo => !todo.completed);
+ }
+
+ if (filter === FilterTodos.Completed) {
+ return todos.filter(todo => todo.completed);
+ }
+
+ return todos;
+ }, [todos, filter]);
+
+ return (
+
+ {preparedTodos.map(todo => (
+
+ ))}
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index a9689cb38..d06ee7fb2 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 { GlobalStateProvider } from './components/Store';
const container = document.getElementById('root') as HTMLDivElement;
-createRoot(container).render( );
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/src/services.ts b/src/services.ts
new file mode 100644
index 000000000..72f8cfdfe
--- /dev/null
+++ b/src/services.ts
@@ -0,0 +1,9 @@
+import { Todo } from './types/Todo';
+
+export const getActiveTodosArray = (todos: Todo[]) => {
+ return todos.filter((todo: Todo) => !todo.completed);
+};
+
+export const getCompletedTodosArray = (todos: Todo[]) => {
+ return todos.filter((todo: Todo) => todo.completed);
+};
diff --git a/src/styles/index.scss b/src/styles/index.scss
index a34eec7c6..72904c4f1 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -1,3 +1,7 @@
+* {
+ box-sizing: border-box;
+}
+
iframe {
display: none;
}
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/FilterTodos.ts b/src/types/FilterTodos.ts
new file mode 100644
index 000000000..59797b251
--- /dev/null
+++ b/src/types/FilterTodos.ts
@@ -0,0 +1,5 @@
+export enum FilterTodos {
+ All = 'All',
+ Active = 'Active',
+ Completed = 'Completed',
+}
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..bc1b08497
--- /dev/null
+++ b/src/types/TodoStatus.ts
@@ -0,0 +1,5 @@
+export enum TodoStatus {
+ all = 'All',
+ active = 'Active',
+ completed = 'Completed',
+}