diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index a399287bd..000000000 --- a/src/App.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; - -export const App: React.FC = () => { - 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 */} - -
-
- ); -}; diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx new file mode 100644 index 000000000..ba38e62c8 --- /dev/null +++ b/src/components/App/App.tsx @@ -0,0 +1,33 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useEffect } from 'react'; +import { Header } from '../Header/Header'; +import { TodoList } from '../TodoList/TodoList'; +import { Footer } from '../Footer/Footer'; +import { useTodoContext } from '../../context/TodoContext' +import { getTodos } from '../../storage/localStorage'; + + +export const App: React.FC = () => { + const { todos, setTodos } = useTodoContext(); + + useEffect(() => { + setTodos(getTodos()); + }, [setTodos]); + + return ( +
+

todos

+ +
+
+ {todos.length > 0 && ( + <> + +
+
+ ); +}; diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..988b8e24c --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,60 @@ +import { useTodoContext } from '../../context/TodoContext'; +import { FilterType } from '../../types/FilterTypes'; +import { FilterTypes } from '../../types/FilterTypes'; +import classNames from 'classnames'; + +export const Footer: React.FC = () => { + const { todos, setTodos, filter, setFilter } = useTodoContext(); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + + const handleFilterChange = (newFilter: FilterType) => { + setFilter(newFilter); + }; + + const clearCompleted = () => { + const todosCompleted = todos.filter(todo => !todo.completed); + + setTodos(todosCompleted); + localStorage.setItem('todos', JSON.stringify(todosCompleted)); + + const inputField = document.querySelector(".todoapp__new-todo") as HTMLInputElement; + inputField!.focus(); + }; + + return ( + + ) +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..77c064cb4 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { useTodoContext } from '../../context/TodoContext'; +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; + + +export const Header: React.FC = () => { + const { todos, setTodos, title, setTitle } = useTodoContext(); + + const addTodo = (text: string) => { + const newTodo: Todo = { id: Date.now(), title: text, completed: false}; + + setTodos([...todos, newTodo]); + localStorage.setItem('todos', JSON.stringify([...todos, newTodo])); + } + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + return; + } + + addTodo(title.trim()); + setTitle(''); + }; + + const toogleAllTodos = () => { + const allCompleted = todos.every(todo => todo.completed); + + const updatedTodos = todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })); + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + } + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ) +} diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..f89b07000 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,132 @@ +import React, { useState, useRef } from 'react'; +import { Todo } from '../../types/Todo'; +import classNames from 'classnames'; +import { useTodoContext } from '../../context/TodoContext'; + +interface Props { + todo: Todo; +} + +export const TodoItem: React.FC = ({ todo }) => { + const { todos, setTodos, newTitle, setNewTitle } = useTodoContext(); + const [isEditForm, setIsEditForm] = useState(false); + const editInputRef = useRef(null); + + const handleDoubleClick = () => { + setIsEditForm(true); + setNewTitle(todo.title); + setTimeout(() => { + editInputRef.current?.focus(); + }, 0); + } + + const deleteTodo = (id: number) => { + const filteredTodos = todos.filter(t => t.id !== id); + + setTodos(filteredTodos); + localStorage.setItem('todos', JSON.stringify(filteredTodos)); + + const inputField = document.querySelector(".todoapp__new-todo") as HTMLInputElement; + inputField!.focus(); + } + + const editTodo = (title: string, id: number) => { + const updatedTodos = todos.map(t => t.id === id ? { ...t, title } : t); + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditForm(false); + } + }; + + const editTitle = (e: React.FormEvent) => { + e.preventDefault(); + + const form = new FormData(e.currentTarget); + const newEditTitle = form.get('TodoTitleField') as string; + + if (!newEditTitle.trim()) { + deleteTodo(todo.id); + } else if (newEditTitle !== todo.title) { + editTodo(newEditTitle.trim(), todo.id); + } + + setTimeout(() => { + setIsEditForm(false); + }, 100); + }; + + const handleTitleBlur = (e: React.ChangeEvent) => { + const newTitleBlur = e.target.value.trim(); + + if (!newTitleBlur) { + deleteTodo(todo.id); + return; + } + + if (newTitleBlur !== todo.title) { + editTodo(newTitleBlur, todo.id); + } + + setIsEditForm(false); + }; + + const toggleTodoStatus = (id: number) => { + const updatedTodos = todos.map(t => + t.id === id ? { ...t, completed: !t.completed } : t + ); + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + } + + return ( +
+ + + {isEditForm ? ( +
+ setNewTitle(e.target.value)} + onBlur={handleTitleBlur} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + + {todo.title} + + )} + + + {/* Remove button appears only on hover */} + {!isEditForm && ( + + )} +
+ ) +} diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..43d32956e --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,33 @@ +import { useTodoContext } from "../../context/TodoContext" +import { TodoItem } from "../TodoItem/TodoItem" + + +enum FilterT { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +export const TodoList: React.FC = () => { + const { todos, filter } = useTodoContext(); + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case FilterT.Active: + return !todo.completed; + case FilterT.Completed: + return todo.completed; + case FilterT.All: + default: + return true; + } + }); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ) +} diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..0500c4aa3 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,57 @@ +import React, { useContext, useState } from 'react'; +import { Todo } from '../types/Todo'; +import { FilterType } from '../types/FilterTypes'; + +interface TodoContextType { + todos: Todo[]; + setTodos: (todos: Todo[]) => void; + title: string; + setTitle: (title: string) => void; + filter: 'all' | 'active' | 'completed'; + setFilter: (filter: 'all' | 'active' | 'completed') => void; + newTitle: string; + setNewTitle: (editTitle: string) => void; +} + +export const TodoContext = React.createContext({ + todos: [], + setTodos: () => {}, + title: '', + setTitle: () => {}, + filter: 'all', + setFilter: () => {}, + newTitle: '', + setNewTitle: () => {}, +}); + +type Props = { + children: React.ReactNode; +} + +export const TodoProvider: React.FC = ({ children }) => { + const [ todos, setTodos ] = useState([]); + const [ title, setTitle ] = useState(''); + const [ filter, setFilter ] = useState('all'); + const [ newTitle, setNewTitle ] = useState(''); + + const value: TodoContextType = { + todos, + setTodos, + title, + setTitle, + filter, + setFilter, + newTitle, + setNewTitle, + } + + return ( + + {children} + + ) +} + +export const useTodoContext = (): TodoContextType => { + return useContext(TodoContext) +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..3652baf3c 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/todo-list.scss'; +import './styles/filters.scss'; +import './styles/todoapp.scss'; -import { App } from './App'; +import { App } from './components/App/App'; +import { TodoProvider } from './context/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + +); diff --git a/src/storage/localStorage.ts b/src/storage/localStorage.ts new file mode 100644 index 000000000..ede2f037d --- /dev/null +++ b/src/storage/localStorage.ts @@ -0,0 +1,15 @@ +const STORAGE_KEY = 'todos'; + +export const getTodos = () => { + const value = localStorage.getItem(STORAGE_KEY); + + if (!value) { + localStorage.setItem('todos', JSON.stringify([])); + + return []; + } + + const parsedValue = JSON.parse(value); + + return Array.isArray(parsedValue) ? parsedValue : []; +} diff --git a/src/styles/filters.css b/src/styles/filters.scss similarity index 100% rename from src/styles/filters.css rename to src/styles/filters.scss diff --git a/src/styles/todo-list.css b/src/styles/todo-list.scss similarity index 99% rename from src/styles/todo-list.css rename to src/styles/todo-list.scss index 4576af434..542362d66 100644 --- a/src/styles/todo-list.css +++ b/src/styles/todo-list.scss @@ -71,7 +71,7 @@ } &__title-field { - width: 100%; + width: 94%; padding: 11px 14px; font-size: inherit; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..e3983c143 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -56,7 +56,7 @@ } &__new-todo { - width: 100%; + width: 85%; padding: 16px 16px 16px 60px; font-size: 24px; diff --git a/src/types/FilterTypes.ts b/src/types/FilterTypes.ts new file mode 100644 index 000000000..0985e7081 --- /dev/null +++ b/src/types/FilterTypes.ts @@ -0,0 +1,9 @@ +export const FilterTypes = { + ALL: 'all', + ACTIVE: 'active', + COMPLETED: 'completed', +} as const; + +export type FilterType = typeof FilterTypes[keyof typeof FilterTypes]; + + 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; +}