diff --git a/src/App.tsx b/src/App.tsx index a399287bd..4ac424509 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,20 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; +import { TodoList } from './components/TodoList'; +import { TodoHeader } from './components/TodoHeader'; +import { TodoFooter } from './components/TodoFooter'; +import { TodosContext } from './context'; export const App: React.FC = () => { + const { todos } = useContext(TodosContext); + 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/TodoFooter.tsx b/src/components/TodoFooter/TodoFooter.tsx new file mode 100644 index 000000000..461abd951 --- /dev/null +++ b/src/components/TodoFooter/TodoFooter.tsx @@ -0,0 +1,57 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../../context'; +import { Filter } from '../../enums/Enums'; +import classNames from 'classnames'; + +export const TodoFooter: React.FC = () => { + const { todos, activeTodos, filter, setFilter, deleteHandler } = + useContext(TodosContext); + + const deleteCompleted = () => { + const idsToDelete: number[] = []; + + todos.forEach(todo => { + const { completed, id } = todo; + + if (completed) { + idsToDelete.push(id); + } + }); + + deleteHandler(idsToDelete); + }; + + return ( + + ); +}; diff --git a/src/components/TodoFooter/index.ts b/src/components/TodoFooter/index.ts new file mode 100644 index 000000000..544d07114 --- /dev/null +++ b/src/components/TodoFooter/index.ts @@ -0,0 +1 @@ +export * from './TodoFooter'; diff --git a/src/components/TodoHeader/TodoHeader.tsx b/src/components/TodoHeader/TodoHeader.tsx new file mode 100644 index 000000000..cedb26993 --- /dev/null +++ b/src/components/TodoHeader/TodoHeader.tsx @@ -0,0 +1,59 @@ +import React, { useContext } from 'react'; +import { TodosContext } from '../../context'; +import classNames from 'classnames'; +import { myLocalStorage } from '../../localStorage'; +import { Names } from '../../enums/Enums'; + +export const TodoHeader: React.FC = () => { + const { + todos, + activeTodos, + value, + headerInputRef, + setTodos, + setValue, + onSubmit, + } = useContext(TodosContext); + + const toogleAll = () => { + const updatedTodos = todos.map(todo => { + const { completed } = todo; + + if (activeTodos === 0 && completed) { + return { ...todo, completed: !completed }; + } + + return { ...todo, completed: true }; + }); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoHeader/index.ts b/src/components/TodoHeader/index.ts new file mode 100644 index 000000000..c4db4bc40 --- /dev/null +++ b/src/components/TodoHeader/index.ts @@ -0,0 +1 @@ +export * from './TodoHeader'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..146647958 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,86 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import React from 'react'; + +interface Props { + id: number; + title: string; + editableTitle: string; + isTodoChecked: boolean; + editableTodoById: number; + todoInputRef: React.MutableRefObject; + setEditableTitle: (value: string) => void; + editHandler: (id: number, title: string) => void; + deleteHandler: (ids: number[]) => void; + toogleHandler: (id: number) => void; + onSubmit: (event: React.FormEvent, id: number) => void; +} + +export const TodoItem: React.FC = React.memo( + ({ + id, + title, + editableTitle, + editableTodoById, + isTodoChecked, + todoInputRef, + setEditableTitle, + editHandler, + deleteHandler, + toogleHandler, + onSubmit, + }) => { + return ( +
+ + + {editableTodoById === id ? ( +
onSubmit(event, id)}> + onSubmit(event, id)} + data-cy="TodoTitleField" + type="text" + className="todo__title-field" + placeholder="Empty todo will be deleted" + value={editableTitle} + onChange={event => setEditableTitle(event.target.value)} + /> +
+ ) : ( + editHandler(id, title)} + > + {title || 'Todo is being saved now'} + + )} + + {editableTodoById !== id && ( + + )} +
+ ); + }, +); + +TodoItem.displayName = 'TodoItem'; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..d32b09927 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,95 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { TodosContext } from '../../context'; +import { Filter, Names } from '../../enums/Enums'; +import { myLocalStorage } from '../../localStorage'; +import { TodoItem } from '../TodoItem/TodoItem'; + +export const TodoList: React.FC = () => { + const { todos, filter, setTodos, toogleHandler, deleteHandler } = + useContext(TodosContext); + + const [editableTodoById, setEditableTodoById] = useState(0); + const [editableTitle, setEditableTitle] = useState(''); + const todoInputRef = useRef(null); + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case Filter.active: + return !todo.completed; + case Filter.completed: + return todo.completed; + default: + return todo; + } + }); + + const editHandler = (id: number, value: string) => { + setEditableTodoById(id); + setEditableTitle(value); + }; + + const onSubmit = (event: React.FormEvent, id: number) => { + event.preventDefault(); + const trimmedTitle = editableTitle.trim(); + + if (trimmedTitle.length > 0) { + const updatedTodos = todos.map(todo => { + if (todo.id === id) { + return { ...todo, title: trimmedTitle }; + } + + return todo; + }); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); + } else { + deleteHandler([id]); + } + + setEditableTodoById(0); + setEditableTitle(''); + }; + + const onKeyUpHandler = (key: React.KeyboardEvent) => { + if (key.code === 'Escape') { + setEditableTodoById(0); + setEditableTitle(''); + } + }; + + useEffect(() => { + todoInputRef.current?.focus(); + }, [editableTodoById]); + + return ( +
onKeyUpHandler(event)} + > + {filteredTodos.map(todo => { + const { id, title } = todo; + const isTodoChecked = todo.completed; + + return ( + + ); + })} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/context/TodosContext.tsx b/src/context/TodosContext.tsx new file mode 100644 index 000000000..9abfed70c --- /dev/null +++ b/src/context/TodosContext.tsx @@ -0,0 +1,130 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { + createContext, + memo, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Todo } from '../types/Todo'; +import { myLocalStorage } from '../localStorage'; +import { Names } from '../enums/Enums'; + +interface TodosContextType { + todos: Todo[]; + activeTodos: number; + value: string; + filter: string; + headerInputRef: React.MutableRefObject; + setTodos: (todos: Todo[]) => void; + setValue: (value: string) => void; + onSubmit: (event: React.FormEvent) => void; + setFilter: (value: string) => void; + toogleHandler: (id: number) => void; + deleteHandler: (ids: number[]) => void; +} + +export const TodosContext = createContext({ + todos: [], + activeTodos: 0, + value: '', + filter: '', + headerInputRef: { + current: null, + }, + setTodos: () => {}, + setValue: () => {}, + onSubmit: () => {}, + setFilter: () => {}, + toogleHandler: () => {}, + deleteHandler: () => {}, +}); + +interface Props { + children: React.ReactNode; +} + +export const TodosProvider: React.FC = memo(({ children }) => { + const [todos, setTodos] = useState([]); + const [value, setValue] = useState(''); + const [filter, setFilter] = useState('All'); + const headerInputRef = useRef(null); + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const todoId = +new Date(); + const todo = { + id: todoId, + title: value.trim(), + completed: false, + }; + + myLocalStorage.setItem(Names.todos, JSON.stringify([...todos, todo])); + setTodos(currentTodos => [...currentTodos, todo]); + + setValue(''); + }; + + const toogleHandler = (id: number) => { + const updatedTodos = todos.map(todo => { + const { completed } = todo; + + if (todo.id === id) { + return { ...todo, completed: !completed }; + } + + return todo; + }); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); + }; + + const deleteHandler = (ids: number[]) => { + const updatedTodos = todos.filter(todo => !ids.includes(todo.id)); + + setTodos(updatedTodos); + myLocalStorage.setItem(Names.todos, JSON.stringify(updatedTodos)); + headerInputRef.current?.focus(); + }; + + const activeTodos = todos.filter(_todo => !_todo.completed).length; + + useEffect(() => { + const getTodos = myLocalStorage.getItem(Names.todos); + + if (!getTodos) { + myLocalStorage.setItem(Names.todos, JSON.stringify([])); + } else { + setTodos(JSON.parse(getTodos as string)); + } + + headerInputRef.current?.focus(); + }, []); + + const contextValue = useMemo( + () => ({ + todos, + activeTodos, + value, + filter, + headerInputRef, + setTodos, + setValue, + onSubmit, + setFilter, + toogleHandler, + deleteHandler, + }), + [todos, value, filter], + ); + + return ( + + {children} + + ); +}); + +TodosProvider.displayName = 'TodosProvider'; diff --git a/src/context/index.ts b/src/context/index.ts new file mode 100644 index 000000000..b9ab89200 --- /dev/null +++ b/src/context/index.ts @@ -0,0 +1 @@ +export * from './TodosContext'; diff --git a/src/enums/Enums.ts b/src/enums/Enums.ts new file mode 100644 index 000000000..eef71c709 --- /dev/null +++ b/src/enums/Enums.ts @@ -0,0 +1,9 @@ +export enum Filter { + all = 'All', + active = 'Active', + completed = 'Completed', +} + +export enum Names { + todos = 'todos', +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..a9b8be212 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,17 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import './styles/filter.scss'; +import './styles/index.scss'; +import './styles/todo.scss'; +import './styles/todoapp.scss'; import { App } from './App'; +import { TodosProvider } from './context'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/localStorage/index.ts b/src/localStorage/index.ts new file mode 100644 index 000000000..3fcbe130e --- /dev/null +++ b/src/localStorage/index.ts @@ -0,0 +1 @@ +export * from './localStorage'; diff --git a/src/localStorage/localStorage.ts b/src/localStorage/localStorage.ts new file mode 100644 index 000000000..92aeed0d3 --- /dev/null +++ b/src/localStorage/localStorage.ts @@ -0,0 +1 @@ +export const myLocalStorage = window.localStorage; diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..fd5f6aa10 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.scss @@ -73,6 +73,7 @@ &__title-field { width: 100%; padding: 11px 14px; + box-sizing: border-box; font-size: inherit; line-height: inherit; 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/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +}