diff --git a/src/App.tsx b/src/App.tsx index a399287bd..b492f9140 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,59 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; -export const App: React.FC = () => { - return ( -
-

todos

+import { useContext, useState } from 'react'; +import { TodosContext } from './components/TodosContext'; +import { Footer } from './components/Footer'; +import { FilterMethods } from './types/FilterMethods'; +import { Header } from './components/Header'; +import { Main } from './components/Main'; -
-
- {/* this button should have `active` class only if all todos are completed */} -
+ if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } -
- {/* This is a completed todo */} -
- + const { todos } = context; - - Completed Todo - + const [filterMethod, setFilterMethod] = useState( + FilterMethods.all, + ); - {/* Remove button appears only on hover */} - -
+ function filterTodos(method: FilterMethods) { + let currentTodos = [...todos]; - {/* This todo is an active todo */} -
- + switch (method) { + case FilterMethods.all: + return currentTodos; - - Not Completed Todo - + case FilterMethods.active: + currentTodos = currentTodos.filter(todo => !todo.completed); + break; - -
+ case FilterMethods.completed: + currentTodos = currentTodos.filter(todo => todo.completed); + break; + } - {/* This todo is being edited */} -
- + return currentTodos; + } - {/* This form is shown instead of the title and remove button */} -
- -
-
+ const visibleTodos = filterTodos(filterMethod); - {/* This todo is in loadind state */} -
- + return ( +
+

todos

- - Todo is being saved now - +
+
- -
-
+
{/* Hide the footer if there are no todos */} -
- - 3 items left - - - {/* Active link should have the 'selected' class */} - - - {/* this button should be disabled if there are no completed todos */} - -
+ {todos.length !== 0 && ( +
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..504575975 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,81 @@ +import { useContext } from 'react'; +import { TodosContext } from './TodosContext'; +import { FilterMethods } from '../types/FilterMethods'; +import classNames from 'classnames'; + +type Props = { + onFilter: (v: FilterMethods) => void; + filterList: FilterMethods | null; +}; + +export const Footer: React.FC = ({ onFilter, filterList }) => { + const context = useContext(TodosContext); + + if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + const { todos, setTodos, inputFocus } = context; + + function clearCompleted() { + const clearedTodos = todos.filter(todo => !todo.completed); + + setTodos(clearedTodos); + inputFocus.current?.focus(); + } + + return ( + + ); +}; diff --git a/src/components/Form.tsx b/src/components/Form.tsx new file mode 100644 index 000000000..7bc496b42 --- /dev/null +++ b/src/components/Form.tsx @@ -0,0 +1,43 @@ +import { useContext, useState } from 'react'; +import { TodosContext } from './TodosContext'; + +export const Form = () => { + const [title, setTitle] = useState(''); + const context = useContext(TodosContext); + + if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + const { addTodo, inputFocus } = context; + + return ( +
{ + e.preventDefault(); + if (!title.trim()) { + return; + } + + addTodo({ + id: +new Date(), + title: title.trim(), + completed: false, + }); + + setTitle(''); + }} + > + setTitle(e.target.value)} + /> +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..9383fb7b2 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,42 @@ +import classNames from 'classnames'; +import { useContext } from 'react'; +import { TodosContext } from './TodosContext'; +import { Form } from './Form'; + +export const Header: React.FC = () => { + const context = useContext(TodosContext); + + if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + const { todos, updateTodo } = context; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + const newStatus = !allCompleted; + + todos.forEach(todo => { + updateTodo(todo.id, { completed: newStatus }); + }); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/components/Main.tsx b/src/components/Main.tsx new file mode 100644 index 000000000..ef3da5213 --- /dev/null +++ b/src/components/Main.tsx @@ -0,0 +1,24 @@ +import { useState } from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + visibleTodos: Todo[]; +}; + +export const Main: React.FC = ({ visibleTodos }) => { + const [editingTodo, setEditingTodo] = useState(null); + + return ( +
+ {visibleTodos?.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..5e2a0904a --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,100 @@ +import { useContext, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { TodosContext } from './TodosContext'; +import classNames from 'classnames'; + +type Props = { + todo: Todo; + editing: Date | number | null; + onEdit: (v: number | null) => void; +}; + +export const TodoItem: React.FC = ({ todo, editing, onEdit }) => { + const context = useContext(TodosContext); + + if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + const { updateTodo, deleteTodo } = context; + const [value, setValue] = useState(todo.title); + + function handleSumbit() { + const normalizedValue = value.trim(); + + if (normalizedValue.length === 0) { + deleteTodo(todo.id); + + return; + } + + updateTodo(todo.id, { title: normalizedValue }); + setValue(normalizedValue); + onEdit(null); + } + + return ( +
onEdit(todo.id)} + > + {/* eslint-disable-next-line */} + + + {editing !== todo.id && ( + + {todo.title} + + )} + + {editing === todo.id && ( + e.key === 'Escape' && onEdit(null)} + onBlur={() => { + onEdit(null); + handleSumbit(); + }} + onSubmit={e => { + e.preventDefault(); + + return handleSumbit(); + }} + > + setValue(e.target.value)} + /> + + )} + + {editing !== todo.id && ( + + )} +
+ ); +}; diff --git a/src/components/TodosContext.tsx b/src/components/TodosContext.tsx new file mode 100644 index 000000000..a32de47d8 --- /dev/null +++ b/src/components/TodosContext.tsx @@ -0,0 +1,27 @@ +import { createContext, ReactNode } from 'react'; +import { useLocalStorage } from '../hooks'; +import { Todo } from '../types/Todo'; + +interface TodosContextType { + todos: Todo[]; + setTodos: React.Dispatch>; + addTodo: (newTodo: Todo) => void; + updateTodo: (todoId: number, updateData: Partial) => Promise; + deleteTodo: (id: number) => void; + loadList: number[]; + inputFocus: React.MutableRefObject; +} + +export const TodosContext = createContext(null); + +export const TodosProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const todosState = useLocalStorage(); + + return ( + + {children} + + ); +}; diff --git a/src/hooks.tsx b/src/hooks.tsx new file mode 100644 index 000000000..e3a3cd658 --- /dev/null +++ b/src/hooks.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef, useState } from 'react'; +import { Todo } from './types/Todo'; + +export const useLocalStorage = () => { + const [todos, setTodos] = useState(() => { + try { + const savedValue = localStorage.getItem('todos'); + + return savedValue ? JSON.parse(savedValue) : []; + } catch (error) { + return []; + } + }); + const [loadList, setLoadList] = useState([]); + const inputFocus = useRef(null); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (newTodo: Todo) => { + setTodos(currentTodos => [...currentTodos, newTodo]); + }; + + const updateTodo = async (todoId: number, updateData: Partial) => { + try { + setLoadList(current => [...current, todoId]); + + await setTodos(currentTodos => { + const updatedTodos = currentTodos.map(todo => + todo.id === todoId ? { ...todo, ...updateData } : todo, + ); + + return updatedTodos; + }); + } catch { + setLoadList(current => current.filter(el => el !== todoId)); + + return; + } finally { + setLoadList(current => current.filter(el => el !== todoId)); + } + }; + + const deleteTodo = (id: number | Date) => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== id)); + if (inputFocus.current) { + inputFocus.current.focus(); + } + }; + + return { + todos, + setTodos, + addTodo, + updateTodo, + deleteTodo, + loadList, + inputFocus, + }; +}; 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/types/FilterMethods.ts b/src/types/FilterMethods.ts new file mode 100644 index 000000000..cbf98a3a1 --- /dev/null +++ b/src/types/FilterMethods.ts @@ -0,0 +1,5 @@ +export enum FilterMethods { + 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; +};