diff --git a/README.md b/README.md index 903c876f9..8c95e754c 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th - Implement a solution following the [React task guidelines](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). - Open another terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your GitHub username in the [DEMO LINK](https://.github.io/react_todo-app/) and add it to the PR description. +- Replace `` with your GitHub username in the [DEMO LINK](https://RuslanV23.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..0bacc89e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,21 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useTodos } from './Context/TodoContext'; +import { TodoHeader } from './Components/TodoHeader'; +import { TodoList } from './Components/TodoList'; +import { TodoFooter } from './Components/TodoFooter'; export const App: React.FC = () => { + const { todos } = useTodos(); + return (

todos

-
- {/* this button should have `active` class only if all todos are completed */} -
- -
- {/* This is a completed todo */} -
- - - - Completed Todo - - - {/* Remove button appears only on hover */} - -
- - {/* This todo is an active todo */} -
- - - - Not Completed Todo - - - -
- - {/* This todo is being edited */} -
- - - {/* This form is shown instead of the title and remove button */} -
- -
-
- - {/* This todo is in loadind state */} -
- - - - 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/TodoFooter.tsx b/src/Components/TodoFooter.tsx new file mode 100644 index 000000000..6b2371368 --- /dev/null +++ b/src/Components/TodoFooter.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { useTodos } from '../Context/TodoContext'; +import { QueryFilter } from '../Types/QueryFilter'; +import classNames from 'classnames'; + +export const TodoFooter: React.FC = () => { + const { + todoLeft, + query, + setQuery, + isSomeTodosCompleted, + removeAllCompletedTodos, + } = useTodos(); + + return ( +
+ + {todoLeft} items left + + + {/* Active link should have the 'selected' class */} + + + +
+ ); +}; diff --git a/src/Components/TodoHeader.tsx b/src/Components/TodoHeader.tsx new file mode 100644 index 000000000..b3f4052c1 --- /dev/null +++ b/src/Components/TodoHeader.tsx @@ -0,0 +1,64 @@ +import React, { useState } from 'react'; +import { useTodos } from '../Context/TodoContext'; +import { Todo } from '../Types/Todo'; +import classNames from 'classnames'; + +export const TodoHeader: React.FC = () => { + const [title, setTitle] = useState(''); + const { + todos, + addTodo, + headerInputRef, + isAllTodosCompleted, + toggleAllTodos, + } = useTodos(); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const titleTrim = title.trim(); + + if (titleTrim.length === 0) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: titleTrim, + completed: false, + }; + + if (addTodo) { + addTodo(newTodo); + } + + setTitle(''); + }; + + return ( +
+ {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/Components/TodoItem.tsx b/src/Components/TodoItem.tsx new file mode 100644 index 000000000..71c70320c --- /dev/null +++ b/src/Components/TodoItem.tsx @@ -0,0 +1,102 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +/* eslint-disable jsx-a11y/label-has-associated-control */ + +import classNames from 'classnames'; +import React from 'react'; +import { Todo } from '../Types/Todo'; +import { useTodos } from '../Context/TodoContext'; + +export const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => { + const { + updateTodo, + deleteTodo, + toggleTodo, + setEditTodo, + editTodo, + editingInputRef, + } = useTodos(); + + return ( +
+ + {editTodo?.id === todo.id ? ( +
{ + updateTodo({ + title: editTodo.title, + id: editTodo.id, + completed: editTodo.completed, + } as Todo); + setEditTodo(null); + }} + > + { + if (event.key === 'Escape') { + setEditTodo(null); + } + }} + ref={editingInputRef} + onChange={event => + setEditTodo(currentEdit => + currentEdit + ? { ...currentEdit, title: event.target.value } + : currentEdit, + ) + } + onBlur={() => { + updateTodo({ + title: editTodo.title, + id: editTodo.id, + completed: editTodo.completed, + } as Todo); + setEditTodo(null); + }} + /> +
+ ) : ( + <> + + setEditTodo({ + id: todo.id, + title: todo.title, + completed: todo.completed, + }) + } + > + {todo.title} + + + {/* Remove button appears only on hover */} + + + )} +
+ ); +}; diff --git a/src/Components/TodoList.tsx b/src/Components/TodoList.tsx new file mode 100644 index 000000000..56798a46e --- /dev/null +++ b/src/Components/TodoList.tsx @@ -0,0 +1,14 @@ +import { useTodos } from '../Context/TodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { filteredTodos } = useTodos(); + + return ( +
+ {filteredTodos.map(todo => { + return ; + })} +
+ ); +}; diff --git a/src/Context/TodoContext.tsx b/src/Context/TodoContext.tsx new file mode 100644 index 000000000..5b5507f14 --- /dev/null +++ b/src/Context/TodoContext.tsx @@ -0,0 +1,205 @@ +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { Todo } from '../Types/Todo'; +import { QueryFilter } from '../Types/QueryFilter'; + +type TodoContextType = { + todos: Todo[]; + filteredTodos: Todo[]; + addTodo: (todos: Todo) => void; + + query: QueryFilter; + setQuery: (query: QueryFilter) => void; + + deleteTodo: (todoId: number) => void; + updateTodo: (newTodo: Todo) => void; + toggleTodo: (todoId: number) => void; + toggleAllTodos: () => void; + setEditTodo: React.Dispatch>; + removeAllCompletedTodos: () => void; + + todoLeft: number; + headerInputRef: React.RefObject; + editingInputRef: React.RefObject; + editTodo: Todo | null; + isAllTodosCompleted: boolean; + isSomeTodosCompleted: boolean; +}; + +const STORAGE_KEY = 'todos'; + +type Props = { + children: React.ReactNode; +}; + +const TodoContext = React.createContext(undefined); + +export const TodoProvider: React.FC = ({ children }) => { + const [editTodo, setEditTodo] = useState(null); + const [todos, setTodos] = useState([]); + + const [query, setQuery] = useState(QueryFilter.All); + + const headerInputRef = useRef(null); + const editingInputRef = useRef(null); + + const filteredTodos = useMemo(() => { + return todos.filter(todo => { + switch (query) { + case QueryFilter.Active: + return !todo.completed; + + case QueryFilter.Completed: + return todo.completed; + + default: + return todo; + } + }); + }, [todos, query]); + + // #region Manage focus + useEffect(() => { + if (editTodo) { + editingInputRef.current?.focus(); + } + }, [editTodo]); + + const headerFocus = useCallback(() => { + headerInputRef.current?.focus(); + }, []); + + useEffect(() => { + headerInputRef.current?.focus(); + }, []); + // #endregion + + // #region LocalStorage Store Todos + useEffect(() => { + const todoStorage = localStorage.getItem(STORAGE_KEY); + + headerInputRef.current?.focus(); + + if (todoStorage) { + setTodos(JSON.parse(todoStorage)); + } + }, []); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); + }, [todos]); + + // #endregion + + // #region variable of Todos + const isAllTodosCompleted = useMemo(() => { + return todos.every(todo => todo.completed); + }, [todos]); + + const isSomeTodosCompleted = useMemo(() => { + return todos.some(todo => todo.completed); + }, [todos]); + + const todoLeft = useMemo(() => { + return todos.filter(todo => !todo.completed).length; + }, [todos]); + + const removeAllCompletedTodos = useCallback(() => { + headerFocus(); + setTodos(currentTodos => currentTodos.filter(todo => !todo.completed)); + }, []); + + // #endregion + + // #region useCallback Todos + const addTodo = useCallback((todo: Todo) => { + setTodos(currentTodos => [...currentTodos, todo]); + headerFocus(); + }, []); + + const toggleAllTodos = useCallback(() => { + setTodos(currentTodos => { + return currentTodos.map(todo => { + return { ...todo, completed: !isAllTodosCompleted }; + }); + }); + }, [isAllTodosCompleted]); + + const deleteTodo = useCallback((todoId: number) => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== todoId)); + headerFocus(); + }, []); + + const toggleTodo = useCallback((todoId: number) => { + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === todoId ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }, []); + + const updateTodo = useCallback((newTodo: Todo) => { + const trimTitle = newTodo.title.trim(); + + if (trimTitle === '') { + deleteTodo(newTodo.id); + headerFocus(); + + return; + } + + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === newTodo.id ? { ...newTodo, title: trimTitle } : todo, + ), + ); + headerFocus(); + }, []); + + // #endregion + + return ( + + {children} + + ); +}; + +export function useTodos(): TodoContextType { + const context = useContext(TodoContext); + + if (!context) { + throw new Error('useTodos must be used within a TodoProvider'); + } + + return context; +} diff --git a/src/Types/QueryFilter.ts b/src/Types/QueryFilter.ts new file mode 100644 index 000000000..c41b83290 --- /dev/null +++ b/src/Types/QueryFilter.ts @@ -0,0 +1,5 @@ +export enum QueryFilter { + All = '', + Active = 'All', + 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/index.tsx b/src/index.tsx index b2c38a17a..87c6c6786 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 { TodoProvider } from './Context/TodoContext'; 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..fa8cb9dc0 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; @@ -127,6 +128,10 @@ text-decoration: underline; } + &:disabled { + visibility: hidden; + } + &:active { text-decoration: none; }