diff --git a/README.md b/README.md index 903c876f9..db2dbf2b9 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://Alex-redman.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..87e7e7e4b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,78 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState } from 'react'; +import { useTodos } from './components/TodosContext'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { TodoFilter } from './types/TodosContexType'; export const App: React.FC = () => { + const { + todos, + filter, + setFilter, + addTodo, + toggleTodo, + removeTodo, + clearCompleted, + updateTodoTitle, + toggleAllTodos, + } = useTodos(); + + const [newTitle, setNewTitle] = useState(''); + const [editingId, setEditingId] = useState(null); + + const filteredTodos = todos.filter(todo => { + if (filter === TodoFilter.Active) { + return !todo.completed; + } + + if (filter === TodoFilter.Completed) { + return todo.completed; + } + + return true; + }); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + + const handleEditStart = (id: number) => { + setEditingId(id); + }; + + const cancelEditing = () => { + setEditingId(null); + }; + 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 */} - -
+ )}
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..c36565a48 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import classNames from 'classnames'; +import { TodoFilter } from '../types/TodosContexType'; +import { Todo } from '../types/Todo'; + +interface FooterProps { + todos: Todo[]; + filter: TodoFilter; + setFilter: (filter: TodoFilter) => void; + clearCompleted: () => void; + activeTodosCount: number; +} + +export const Footer: React.FC = ({ + todos, + filter, + setFilter, + clearCompleted, + activeTodosCount, +}) => { + const filterLinks = [ + { filter: TodoFilter.All, label: 'All' }, + { filter: TodoFilter.Active, label: 'Active' }, + { filter: TodoFilter.Completed, label: 'Completed' }, + ]; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..1bfad6a5c --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,60 @@ +import classNames from 'classnames'; +import React, { useRef, useEffect } from 'react'; + +interface HeaderProps { + newTitle: string; + setNewTitle: (value: string) => void; + addTodo: (title: string) => void; + toggleAllTodos: () => void; + todosLength: number; + activeTodosCount: number; +} + +export const Header: React.FC = ({ + newTitle, + setNewTitle, + addTodo, + toggleAllTodos, + todosLength, + activeTodosCount, +}) => { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, [todosLength]); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (newTitle.trim()) { + addTodo(newTitle); + setNewTitle(''); + } + }; + + return ( +
+ {todosLength > 0 && ( + + )} +
+ setNewTitle(e.target.value)} + /> +
+
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..6c7a4a5e2 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,104 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useEffect, useRef } from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +interface TodoItemProps { + todo: Todo; + editing: boolean; + toggleTodo: (id: number) => void; + removeTodo: (id: number) => void; + handleEditStart: (id: number) => void; + updateTodoTitle: (id: number, newTitle: string) => void; + cancelEditing: () => void; +} + +export const TodoItem: React.FC = ({ + todo, + editing, + toggleTodo, + removeTodo, + handleEditStart, + updateTodoTitle, + cancelEditing, +}) => { + const fieldRef = useRef(null); + + useEffect(() => { + if (editing && fieldRef.current) { + fieldRef.current.focus(); + fieldRef.current.select(); + } + }, [editing]); + + const save = () => { + if (fieldRef.current) { + updateTodoTitle(todo.id, fieldRef.current.value); + cancelEditing(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + save(); + } else if (e.key === 'Escape') { + cancelEditing(); + } + }; + + return ( +
+ + + {editing ? ( +
{ + event.preventDefault(); + save(); + }} + > + +
+ ) : ( + <> + handleEditStart(todo.id)} + > + {todo.title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..5ae1e5e85 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +interface TodoListProps { + todos: Todo[]; + editingId: number | null; + toggleTodo: (id: number) => void; + removeTodo: (id: number) => void; + updateTodoTitle: (id: number, newTitle: string) => void; + handleEditStart: (id: number) => void; + cancelEditing: () => void; +} + +export const TodoList: React.FC = ({ + todos, + editingId, + toggleTodo, + removeTodo, + updateTodoTitle, + handleEditStart, + cancelEditing, +}) => { + return ( +
+ {todos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodosContext.tsx b/src/components/TodosContext.tsx new file mode 100644 index 000000000..c219a6f18 --- /dev/null +++ b/src/components/TodosContext.tsx @@ -0,0 +1,107 @@ +import { createContext, useContext, useState, useEffect } from 'react'; +import { Todo } from '../types/Todo'; +import { TodosContextType, TodoFilter } from '../types/TodosContexType'; + +const TodosContext = createContext(undefined); + +export const TodosProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [todos, setTodos] = useState(() => { + const storedTodos = localStorage.getItem('todos'); + + return storedTodos ? JSON.parse(storedTodos) : []; + }); + + const [filter, setFilter] = useState(TodoFilter.All); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (title: string) => { + const newTodo = { id: +new Date(), title: title.trim(), completed: false }; + + setTodos(prev => [...prev, newTodo]); + }; + + const toggleTodo = (id: number) => { + setTodos(prevTodos => { + const updatedTodos = prevTodos.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ); + + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + + return updatedTodos; + }); + }; + + const removeTodo = (id: number) => { + setTodos(prevTodos => { + const updatedTodos = prevTodos.filter(todo => todo.id !== id); + + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + + return updatedTodos; + }); + }; + + const clearCompleted = () => { + setTodos(prevTodos => { + const updatedTodos = prevTodos.filter(todo => !todo.completed); + + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + + return updatedTodos; + }); + }; + + const updateTodoTitle = (id: number, newTitle: string) => { + setTodos(prevTodos => { + if (!newTitle.trim()) { + return prevTodos.filter(todo => todo.id !== id); + } + + return prevTodos.map(todo => + todo.id === id ? { ...todo, title: newTitle.trim() } : todo, + ); + }); + }; + + const toggleAllTodos = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(prevTodos => + prevTodos.map(todo => ({ ...todo, completed: !allCompleted })), + ); + }; + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const context = useContext(TodosContext); + + if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + return context; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..d0503ebf4 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,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/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..fd5f6aa10 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.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..e52b0462b 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -25,6 +25,7 @@ &__header { position: relative; + box-sizing: border-box; } &__toggle-all { @@ -58,6 +59,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..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/types/TodosContexType.ts b/src/types/TodosContexType.ts new file mode 100644 index 000000000..75e05ce15 --- /dev/null +++ b/src/types/TodosContexType.ts @@ -0,0 +1,19 @@ +import { Todo } from './Todo'; + +export enum TodoFilter { + All = 'all', + Active = 'active', + Completed = 'completed', +} + +export type TodosContextType = { + todos: Todo[]; + filter: TodoFilter; + setFilter: (filter: TodoFilter) => void; + addTodo: (title: string) => void; + toggleTodo: (id: number) => void; + removeTodo: (id: number) => void; + clearCompleted: () => void; + updateTodoTitle: (id: number, newTitle: string) => void; + toggleAllTodos: () => void; +};