diff --git a/README.md b/README.md index 903c876f9..25f557f38 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://Banderos14.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..bc3ab3cf5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,63 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import classNames from 'classnames'; +import React, { useRef, useState } from 'react'; + +import { NewTodoForm } from './components/NewTodoForm'; +import { TodoFooter } from './components/TodoFooter'; +import { TodoList } from './components/TodoList'; +import { FILTERS, FilterType } from './constants/filters'; +import { TodosProvider, useTodos } from './context/TodosContext'; + +const TodoAppContent: React.FC = () => { + const [filter, setFilter] = useState(FILTERS.all); + const newTodoFieldRef = useRef(null); + + const { todos, toggleAllTodos } = useTodos(); + const hasTodos = todos.length > 0; + const areAllTodosCompleted = hasTodos && todos.every(todo => todo.completed); + + const focusNewTodoField = () => { + newTodoFieldRef.current?.focus(); + }; -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 */} -
- - 3 items left - - - {/* Active link should have the 'selected' class */} - + {hasTodos && ( + <> + - {/* this button should be disabled if there are no completed todos */} - -
+ + + )}
); }; + +export const App: React.FC = () => ( + + + +); diff --git a/src/components/NewTodoForm.tsx b/src/components/NewTodoForm.tsx new file mode 100644 index 000000000..6f60bc574 --- /dev/null +++ b/src/components/NewTodoForm.tsx @@ -0,0 +1,42 @@ +import React, { FormEvent, RefObject, useState } from 'react'; + +import { useTodos } from '../context/TodosContext'; + +interface Props { + inputRef: RefObject; +} + +export const NewTodoForm: React.FC = ({ inputRef }) => { + const [title, setTitle] = useState(''); + + const { addTodo } = useTodos(); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!title.trim()) { + inputRef.current?.focus(); + + return; + } + + addTodo(title); + setTitle(''); + inputRef.current?.focus(); + }; + + return ( +
+ setTitle(event.target.value)} + autoFocus + /> +
+ ); +}; diff --git a/src/components/TodoFilter.tsx b/src/components/TodoFilter.tsx new file mode 100644 index 000000000..951a7d683 --- /dev/null +++ b/src/components/TodoFilter.tsx @@ -0,0 +1,48 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { FILTERS, FilterType } from '../constants/filters'; + +const FILTER_LINKS = [ + { + type: FILTERS.all, + href: '#/', + title: 'All', + dataCy: 'FilterLinkAll', + }, + { + type: FILTERS.active, + href: '#/active', + title: 'Active', + dataCy: 'FilterLinkActive', + }, + { + type: FILTERS.completed, + href: '#/completed', + title: 'Completed', + dataCy: 'FilterLinkCompleted', + }, +]; + +interface Props { + filter: FilterType; + onFilterChange: (filter: FilterType) => void; +} + +export const TodoFilter: React.FC = ({ filter, onFilterChange }) => ( + +); diff --git a/src/components/TodoFooter.tsx b/src/components/TodoFooter.tsx new file mode 100644 index 000000000..b80b4ae0f --- /dev/null +++ b/src/components/TodoFooter.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { FilterType } from '../constants/filters'; +import { useTodos } from '../context/TodosContext'; +import { TodoFilter } from './TodoFilter'; + +interface Props { + filter: FilterType; + onFilterChange: (filter: FilterType) => void; + focusNewTodoField: () => void; +} + +export const TodoFooter: React.FC = ({ + filter, + onFilterChange, + focusNewTodoField, +}) => { + const { clearCompletedTodos, todos } = useTodos(); + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = todos.some(todo => todo.completed); + const itemWord = activeTodosCount === 1 ? 'item' : 'items'; + + const handleClearCompleted = () => { + clearCompletedTodos(); + focusNewTodoField(); + }; + + return ( +
+ + {`${activeTodosCount} ${itemWord} left`} + + + + + +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..2f1d3981d --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,113 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import classNames from 'classnames'; +import React, { + FormEvent, + KeyboardEvent, + useEffect, + useRef, + useState, +} from 'react'; + +import { useTodos } from '../context/TodosContext'; +import { Todo } from '../types/Todo'; + +interface Props { + todo: Todo; + focusNewTodoField: () => void; +} + +export const TodoItem: React.FC = ({ todo, focusNewTodoField }) => { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + const titleFieldRef = useRef(null); + + const { removeTodo, toggleTodo, updateTodoTitle } = useTodos(); + const statusId = `todo-status-${todo.id}`; + + useEffect(() => { + if (isEditing) { + titleFieldRef.current?.focus(); + } + }, [isEditing]); + + const finishEditing = () => { + updateTodoTitle(todo.id, title); + setIsEditing(false); + }; + + const cancelEditing = () => { + setTitle(todo.title); + setIsEditing(false); + }; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + finishEditing(); + }; + + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + cancelEditing(); + } + }; + + const handleDelete = () => { + removeTodo(todo.id); + focusNewTodoField(); + }; + + return ( +
+ + + {isEditing ? ( +
+ setTitle(event.target.value)} + onBlur={finishEditing} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..760085669 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { FILTERS, FilterType } from '../constants/filters'; +import { useTodos } from '../context/TodosContext'; +import { TodoItem } from './TodoItem'; + +interface Props { + filter: FilterType; + focusNewTodoField: () => void; +} + +export const TodoList: React.FC = ({ filter, focusNewTodoField }) => { + const { todos } = useTodos(); + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case FILTERS.active: + return !todo.completed; + + case FILTERS.completed: + return todo.completed; + + default: + return true; + } + }); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/constants/filters.ts b/src/constants/filters.ts new file mode 100644 index 000000000..a1c9fc0f2 --- /dev/null +++ b/src/constants/filters.ts @@ -0,0 +1,7 @@ +export const FILTERS = { + all: 'all', + active: 'active', + completed: 'completed', +} as const; + +export type FilterType = (typeof FILTERS)[keyof typeof FILTERS]; diff --git a/src/context/TodosContext.tsx b/src/context/TodosContext.tsx new file mode 100644 index 000000000..29a4d1d2e --- /dev/null +++ b/src/context/TodosContext.tsx @@ -0,0 +1,114 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +import { Todo } from '../types/Todo'; +import { getSavedTodos, saveTodos } from '../utils/localStorage'; + +interface TodosContextValue { + todos: Todo[]; + addTodo: (title: string) => void; + removeTodo: (todoId: number) => void; + updateTodoTitle: (todoId: number, title: string) => void; + toggleTodo: (todoId: number) => void; + toggleAllTodos: () => void; + clearCompletedTodos: () => void; +} + +const TodosContext = createContext(null); + +interface Props { + children: React.ReactNode; +} + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState(getSavedTodos); + + useEffect(() => { + saveTodos(todos); + }, [todos]); + + const addTodo = (title: string) => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + setTodos(currentTodos => { + const maxId = Math.max(0, ...currentTodos.map(todo => todo.id)); + const newTodo: Todo = { + id: Math.max(Date.now(), maxId + 1), + title: trimmedTitle, + completed: false, + }; + + return [...currentTodos, newTodo]; + }); + }; + + const removeTodo = (todoId: number) => { + setTodos(currentTodos => currentTodos.filter(todo => todo.id !== todoId)); + }; + + const updateTodoTitle = (todoId: number, title: string) => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + removeTodo(todoId); + + return; + } + + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === todoId ? { ...todo, title: trimmedTitle } : todo, + ), + ); + }; + + const toggleTodo = (todoId: number) => { + setTodos(currentTodos => + currentTodos.map(todo => + todo.id === todoId ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const toggleAllTodos = () => { + setTodos(currentTodos => { + const shouldCompleteTodos = currentTodos.some(todo => !todo.completed); + + return currentTodos.map(todo => ({ + ...todo, + completed: shouldCompleteTodos, + })); + }); + }; + + const clearCompletedTodos = () => { + setTodos(currentTodos => currentTodos.filter(todo => !todo.completed)); + }; + + const value = { + todos, + addTodo, + removeTodo, + updateTodoTitle, + toggleTodo, + toggleAllTodos, + clearCompletedTodos, + }; + + return ( + {children} + ); +}; + +export const useTodos = () => { + const value = useContext(TodosContext); + + if (!value) { + throw new Error('useTodos must be used inside TodosProvider'); + } + + return value; +}; diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..88cbbee23 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.scss @@ -81,6 +81,7 @@ color: inherit; border: 1px solid #999; + outline: none; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); &::placeholder { diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..a9a861a96 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -68,6 +68,7 @@ -moz-osx-font-smoothing: grayscale; border: none; + outline: none; background: rgba(0, 0, 0, 0.01); box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); 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; +} diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts new file mode 100644 index 000000000..cbffcb13d --- /dev/null +++ b/src/utils/localStorage.ts @@ -0,0 +1,21 @@ +import { Todo } from '../types/Todo'; + +const TODOS_KEY = 'todos'; + +export const getSavedTodos = (): Todo[] => { + const savedTodos = localStorage.getItem(TODOS_KEY); + + if (!savedTodos) { + return []; + } + + try { + return JSON.parse(savedTodos); + } catch { + return []; + } +}; + +export const saveTodos = (todos: Todo[]) => { + localStorage.setItem(TODOS_KEY, JSON.stringify(todos)); +};