diff --git a/src/App.tsx b/src/App.tsx index a399287bd..03573b25b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,52 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useEffect, useRef, useState } 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 - +import { FilterType } from './types/FilterType'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { useTodos } from './components/TodosContext'; - {/* Remove button appears only on hover */} - -
- - {/* This todo is an active todo */} -
- - - - Not Completed Todo - - - -
- - {/* This todo is being edited */} -
- +export const App: React.FC = () => { + const { todos, setTodos } = useTodos(); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState(FilterType.All); - {/* This form is shown instead of the title and remove button */} -
- -
-
+ const inputRef = useRef(null); - {/* This todo is in loadind state */} -
- + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todos]); - - Todo is being saved now - + const isAllCompleted = todos.every(todo => todo.completed); - -
-
+ const handleAllTodoCompleted = () => { + const updatedTodos = todos.map(todo => ({ + ...todo, + completed: !isAllCompleted, + })); - {/* Hide the footer if there are no todos */} -
- - 3 items left - + setTodos(updatedTodos); + }; - {/* Active link should have the 'selected' class */} - + {todos.length > 0 && } - {/* this button should be disabled if there are no completed todos */} - -
+ {todos.length > 0 &&
); diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx new file mode 100644 index 000000000..039152fcb --- /dev/null +++ b/src/components/Filter.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import cn from 'classnames'; + +import { FilterType } from '../types/FilterType'; + +const filters = [ + { type: FilterType.All, href: '#/', dataCy: 'FilterLinkAll' }, + { type: FilterType.Active, href: '#/active', dataCy: 'FilterLinkActive' }, + { + type: FilterType.Completed, + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }, +]; + +type Props = { + filter: FilterType; + setFilter: (filter: FilterType) => void; +}; + +export const Filter: React.FC = ({ filter, setFilter }) => ( + +); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..ef54f184d --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +import { FilterType } from '../types/FilterType'; +import { Filter } from './Filter'; +import { useTodos } from './TodosContext'; + +type Props = { + filter: FilterType; + setFilter: (filter: FilterType) => void; +}; + +export const Footer: React.FC = ({ filter, setFilter }) => { + const { todos, setTodos } = useTodos(); + + const numberOfActiveTodos = todos.filter(todo => !todo.completed).length; + + const handleDeleteCompletedTodos = () => { + const unCompletedTodos = todos.filter(todo => !todo.completed); + + setTodos(unCompletedTodos); + }; + + return ( +
+ + {numberOfActiveTodos} items left + + + + + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..0e492d5c2 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import cn from 'classnames'; +import { useTodos } from './TodosContext'; + +type Props = { + query: string; + setQuery: (query: string) => void; + isAllCompleted: boolean; + handleAllTodoCompleted: () => void; + inputRef: React.RefObject; +}; + +export const Header: React.FC = ({ + query, + setQuery, + isAllCompleted, + handleAllTodoCompleted, + inputRef, +}) => { + const { todos, addTodo } = useTodos(); + + const handleAddTodo = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && query.trim() !== '') { + addTodo(query.trim()); + setQuery(''); + } + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..00056f414 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,103 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import React, { useState } from 'react'; +import cn from 'classnames'; + +import { Todo } from '../types/Todo'; +import { useTodos } from './TodosContext'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { deleteTodo, updateTodo } = useTodos(); + const [editingTodoId, setEditingTodoId] = useState(null); + const [titleText, setTitleText] = useState(''); + + const handleSaveUpdated = () => { + const trimmedTitleText = titleText.trim(); + + if (!trimmedTitleText) { + deleteTodo(todo.id); + } else if (trimmedTitleText !== todo.title) { + updateTodo({ ...todo, title: trimmedTitleText }); + } + + setEditingTodoId(null); + setTitleText(''); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSaveUpdated(); + } + }; + + const handleKeyUp = ( + event: React.KeyboardEvent, + title: string, + ) => { + if (event.key === 'Escape') { + setTitleText(title); + setEditingTodoId(null); + } + }; + + return ( +
+ + + {editingTodoId === todo.id ? ( +
+ handleKeyDown(event)} + onKeyUp={event => handleKeyUp(event, todo.title)} + onChange={event => setTitleText(event.target.value)} + autoFocus + /> +
+ ) : ( + { + setEditingTodoId(todo.id); + setTitleText(todo.title); + }} + > + {todo.title} + + )} + + {!editingTodoId && ( + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..6233a4955 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,32 @@ +import React from 'react'; + +import { FilterType } from '../types/FilterType'; +import { TodoItem } from './TodoItem'; +import { useTodos } from './TodosContext'; + +type Props = { + filter: FilterType; +}; + +export const TodoList: React.FC = ({ filter }) => { + const { todos } = useTodos(); + + const filteredTodos = todos.filter(todo => { + switch (filter) { + case FilterType.Active: + return !todo.completed; + case FilterType.Completed: + return todo.completed; + default: + return true; + } + }); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodosContext.tsx b/src/components/TodosContext.tsx new file mode 100644 index 000000000..7f9ed0a9c --- /dev/null +++ b/src/components/TodosContext.tsx @@ -0,0 +1,86 @@ +import React, { useContext, useEffect, useMemo, useState } from 'react'; + +import { Todo } from '../types/Todo'; + +const getTodos = (): Todo[] => { + const data = localStorage.getItem('todos'); + + try { + return JSON.parse(data || '[]'); + } catch { + localStorage.removeItem('todos'); + + return []; + } +}; + +interface TodosContextType { + todos: Todo[]; + setTodos: React.Dispatch>; + addTodo: (title: string) => void; + deleteTodo: (id: string) => void; + updateTodo: (updatedTodo: Todo) => void; +} + +export const TodosContext = React.createContext( + undefined, +); + +export const TodosProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [todos, setTodos] = useState(getTodos()); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const value = useMemo(() => { + const addTodo = (title: string) => { + if (title.trim() === '') { + return; + } + + const newTodo: Todo = { + id: crypto.randomUUID(), + title: title.trim(), + completed: false, + }; + + setTodos(prevTodos => [...prevTodos, newTodo]); + localStorage.setItem('todos', JSON.stringify([...todos, newTodo])); + }; + + const deleteTodo = (id: string) => { + const updatedTodos = todos.filter(todo => todo.id !== id); + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + }; + + const updateTodo = (updatedTodo: Todo) => { + const updatedTodos = todos.map(todo => + todo.id === updatedTodo.id ? updatedTodo : todo, + ); + + setTodos(updatedTodos); + localStorage.setItem('todos', JSON.stringify(updatedTodos)); + }; + + return { todos, setTodos, addTodo, deleteTodo, updateTodo }; + }, [todos]); + + return ( + {children} + ); +}; + +export const useTodos = (): TodosContextType => { + 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..8735edb87 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 { TodosProvider } from './components/TodosContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..28064a37f 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,3 +1,7 @@ +@use './todoapp'; +@use './todo-list'; +@use './filters'; + iframe { display: none; } @@ -19,7 +23,3 @@ body { opacity: 0; pointer-events: none; } - -@import './todoapp'; -@import './todo-list'; -@import './filters'; 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..29383a1e2 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; diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 000000000..579c7f50c --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..476a3f9ef --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: string; + title: string; + completed: boolean; +}