diff --git a/src/App.tsx b/src/App.tsx index a399287bd..34c89378b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,40 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +// #region imports +import React, { useContext, useState } from 'react'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { TodosContext } from './components/TodosContext'; +import { getFilteredTodos } from './services/getFilteredTodos'; +import { FilterStatus } from './types/FilterStatus'; +// #endregion export const App: React.FC = () => { + const { todos } = useContext(TodosContext); + const sortedTodos = { + active: todos.filter(({ completed }) => !completed), + completed: todos.filter(({ completed }) => completed), + }; + const [filterStatus, setFilterStatus] = useState(FilterStatus.All); + + const filteredTodos = getFilteredTodos(todos, sortedTodos, filterStatus); + 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/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..891e18f7d --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,66 @@ +// #region imports +import cn from 'classnames'; +import { useContext } from 'react'; +import { FilterStatus } from '../types/FilterStatus'; +import { Todo } from '../types/Todo'; +import { TodosContext } from './TodosContext'; +// #endregion + +type Props = { + sortedTodos: { + active: Todo[]; + completed: Todo[]; + }; + filterStatus: FilterStatus; + onStatusChange: (status: FilterStatus) => void; +}; + +export const Footer: React.FC = ({ + sortedTodos, + filterStatus, + onStatusChange, +}) => { + const { changeTodos } = useContext(TodosContext); + const { active, completed } = sortedTodos; + const filterLinks: { + [key: string]: string; + } = { + All: '#/', + Active: '#/active', + Completed: '#/completed', + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..0dfd8654b --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,84 @@ +// #region imports +import classNames from 'classnames'; +import { + FormEvent, + memo, + useContext, + useEffect, + useRef, + useState, +} from 'react'; +import { TodosContext } from './TodosContext'; +// #endregion + +export const Header = memo(function Header() { + // #region hooks + const { todos, changeTodos } = useContext(TodosContext); + const [newTitle, setNewTitle] = useState(''); + const titleInput = useRef(null); + + useEffect(() => { + titleInput.current?.focus(); + }); + // #endregion + + const areTodosCompleted = todos.every(todo => todo.completed); + + // #region handlings + const handleTodosToggle = () => { + changeTodos( + todos.map(todo => ({ + ...todo, + completed: !areTodosCompleted, + })), + ); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + return; + } + + changeTodos([ + ...todos, + { + id: +new Date(), + title: trimmedTitle, + completed: false, + }, + ]); + setNewTitle(''); + }; + // #endregion + + return ( +
+ {todos.length > 0 && ( +
+ ); +}); diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..460a9cd66 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,132 @@ +// #region imports +import cn from 'classnames'; +import { memo, useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { TodosContext } from './TodosContext'; +// #endregion + +type Props = { + todo: Todo; + isEdited?: boolean; + onEditedChange?: (id: number | null) => void; +}; + +export const TodoItem: React.FC = memo(function TodoItem({ todo }) { + const { id, title, completed } = todo; + + // #region hooks + const { todos, changeTodos } = useContext(TodosContext); + const [isEdited, setIsEdited] = useState(false); + const [editedTitle, setEditedTitle] = useState(title); + const todoDivRef = useRef(null); + + useEffect(() => { + const startEditing = () => { + setIsEdited(true); + }; + + const stopEditing = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEdited(false); + setEditedTitle(title); + } + }; + + const todoDiv = todoDivRef.current; + + todoDiv?.addEventListener('dblclick', startEditing); + + document.addEventListener('keyup', stopEditing); + + return () => { + todoDiv?.removeEventListener('dblclick', startEditing); + + document.removeEventListener('keyup', stopEditing); + }; + }, [title]); + // #endregion + + // #region handlings + const handleDelete = () => { + changeTodos(todos.filter(t => t.id !== id)); + }; + + const handleEditing = (property: keyof Todo, newValue: string | boolean) => { + const index = todos.findIndex(t => t.id === id); + const newTodos = [...todos]; + + const editedValue = + typeof newValue === 'string' ? newValue.trim() : newValue; + + if (editedValue === '') { + newTodos.splice(index, 1); + } else { + const newTodo = { + ...todos[index], + [property]: editedValue, + }; + + newTodos.splice(index, 1, newTodo); + } + + changeTodos(newTodos); + }; + + const onSubmit = () => { + handleEditing('title', editedTitle); + setIsEdited(false); + }; + // #endregion + + return ( +
+ + + {isEdited ? ( +
+ { + setEditedTitle(e.target.value); + }} + onBlur={onSubmit} + /> +
+ ) : ( + <> + + {title} + + + + + )} +
+ ); +}); diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..d44734181 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,14 @@ +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + todos: Todo[]; +}; + +export const TodoList: React.FC = ({ todos }) => ( +
+ {todos.map(todo => ( + + ))} +
+); diff --git a/src/components/TodosContext.tsx b/src/components/TodosContext.tsx new file mode 100644 index 000000000..c9b6bd823 --- /dev/null +++ b/src/components/TodosContext.tsx @@ -0,0 +1,36 @@ +import React, { createContext, useMemo } from 'react'; +import { useLocalStorage } from '../hooks/useLocalStorage'; +import { Todo } from '../types/Todo'; + +interface ContextProperty { + todos: Todo[]; + changeTodos: (todos: Todo[]) => void; +} + +export const TodosContext = createContext({ + todos: new Array(), + changeTodos: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, changeTodos] = useLocalStorage( + 'todos', + new Array(), + ); + + const value = useMemo( + () => ({ + todos, + changeTodos, + }), + [todos, changeTodos], + ); + + return ( + {children} + ); +}; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..db2595431 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +export function useLocalStorage( + itemName: string, + initialValue: T, +): [T, (item: T) => void] { + const [item, setItem] = useState(() => { + const data = localStorage.getItem(itemName); + + if (data === null) { + return initialValue; + } + + try { + return JSON.parse(data); + } catch { + localStorage.removeItem(itemName); + + return initialValue; + } + }); + + const changeItem = (newItem: T) => { + localStorage.setItem(itemName, JSON.stringify(newItem)); + setItem(newItem); + }; + + return [item, changeItem]; +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..092259365 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/index.scss'; +import './styles/filter.scss'; +import './styles/todo.scss'; +import './styles/todoapp.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/services/getFilteredTodos.ts b/src/services/getFilteredTodos.ts new file mode 100644 index 000000000..c326e3b46 --- /dev/null +++ b/src/services/getFilteredTodos.ts @@ -0,0 +1,25 @@ +import { FilterStatus } from '../types/FilterStatus'; +import { Todo } from '../types/Todo'; + +export function getFilteredTodos( + todos: Todo[], + sortedTodos: { + active: Todo[]; + completed: Todo[]; + }, + filterStatus: FilterStatus, +) { + let filteredTodos = [...todos]; + + if (filterStatus === FilterStatus.All) { + return filteredTodos; + } + + if (filterStatus === FilterStatus.Completed) { + filteredTodos = sortedTodos.completed; + } else { + filteredTodos = sortedTodos.active; + } + + return filteredTodos; +} diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..cfb34ec2f 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.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..434b7f6cd 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; @@ -130,5 +131,9 @@ &:active { text-decoration: none; } + + &:disabled { + visibility: hidden; + } } } diff --git a/src/types/FilterStatus.ts b/src/types/FilterStatus.ts new file mode 100644 index 000000000..7ca17f289 --- /dev/null +++ b/src/types/FilterStatus.ts @@ -0,0 +1,5 @@ +export enum FilterStatus { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} 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; +}