diff --git a/README.md b/README.md index 903c876f9..cef9418ab 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://maxmodrr.github.io/react_todo-app/) and add it to the PR description. diff --git a/src/App.tsx b/src/App.tsx index a399287bd..8fc9e9497 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,7 @@ -/* 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 */} -
- +import { TodoApp } from './components/TodoApp'; - {/* 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 */} - -
-
-
- ); +export const App: React.FC = () => { + return ; }; diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx new file mode 100644 index 000000000..5504a2e11 --- /dev/null +++ b/src/components/Filter.tsx @@ -0,0 +1,30 @@ +import { FILTER_BY } from '../utils/constants/Filter'; +import cl from 'classnames'; +import { useFilter, useSetFilter } from '../context/FilterContext'; + +export const Filter = () => { + const currentFilter = useFilter(); + const setCurrentFilter = useSetFilter(); + + const getNameFilter = (str: string) => { + return str.slice(0, 1).toUpperCase() + str.slice(1); + }; + + return ( + + ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..2e67ed1e8 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,40 @@ +import { useSetTodos, useTodos } from '../context/TodosContext'; +import { getCompletedTodos } from '../utils/methods'; +import { Filter } from './Filter'; + +export const Footer = () => { + const todos = useTodos(); + const setTodos = useSetTodos(); + + if (todos.length === 0) { + return null; + } + + const handleClearCompleted = () => { + setTodos([...todos].filter(e => !e.completed)); + }; + + const getItemsLeft = todos.length - getCompletedTodos(todos).length; + + return ( +
+ + {getItemsLeft} items left + + + + + {/* this button should be disabled if there are no completed todos */} + + +
+ ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..3f4b4d7ec --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,41 @@ +import cl from 'classnames'; +import { useSetTodos, useTodos } from '../context/TodosContext'; +import { getCompletedTodos } from '../utils/methods'; +import { NewTodoField } from './NewTodoField'; + +export const Header = () => { + const todos = useTodos(); + const setTodos = useSetTodos(); + + const isEqual = getCompletedTodos(todos).length === todos.length; + + const handleToggleAll = () => { + const shouldChangeAll = isEqual || getCompletedTodos(todos).length === 0; + + setTodos( + todos.map(e => ({ + ...e, + completed: shouldChangeAll ? !e.completed : true, + })), + ); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/NewTodoField.tsx b/src/components/NewTodoField.tsx new file mode 100644 index 000000000..f492318e5 --- /dev/null +++ b/src/components/NewTodoField.tsx @@ -0,0 +1,49 @@ +import { FormEvent, useEffect, useRef, useState } from 'react'; +import { getMaxId } from '../utils/methods'; +import { Todo } from '../types/Todo'; +import { useSetTodos, useTodos } from '../context/TodosContext'; + +export const NewTodoField: React.FC = () => { + const todos = useTodos(); + const setTodos = useSetTodos(); + const [title, setTitle] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + const input = inputRef.current; + + if (input) { + input.focus(); + } + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (!title) { + return; + } + + const newTodo: Todo = { + id: getMaxId(todos) + 1, + title: title.trim(), + completed: false, + }; + + setTodos([...todos, newTodo]); + setTitle(''); + }; + + return ( +
+ setTitle(e.target.value)} + ref={inputRef} + /> +
+ ); +}; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..62e54022a --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,33 @@ +/* eslint-disable import/extensions */ +import { useEffect } from 'react'; +import { FilterProvider } from '../context/FilterContext'; +import { TodosProvider } from '../context/TodosContext'; +import { Footer } from './Footer'; +import { Header } from './Header'; +import { TodoList } from './TodoList'; + +export const TodoApp = () => { + useEffect(() => { + const data = localStorage.getItem('todos'); + + if (!data) { + localStorage.setItem('todos', JSON.stringify([])); + } + }, []); + + return ( +
+

todos

+ + +
+
+ + +
+ +
+
+
+ ); +}; diff --git a/src/components/TodoElem.tsx b/src/components/TodoElem.tsx new file mode 100644 index 000000000..2a9a254b6 --- /dev/null +++ b/src/components/TodoElem.tsx @@ -0,0 +1,88 @@ +import { Todo } from '../types/Todo'; +import cl from 'classnames'; +import { useSetTodos, useTodos } from '../context/TodosContext'; +import { useEffect, useState } from 'react'; +import { TodoTitleField } from './TodoTitleField'; + +/* eslint-disable jsx-a11y/label-has-associated-control */ +interface Props { + todo: Todo; +} + +export const TodoElem: React.FC = ({ todo }) => { + const { title, completed } = todo; + const todos = useTodos(); + const setTodos = useSetTodos(); + const [isEditing, setIsEditing] = useState(false); + + useEffect(() => { + const handleKeyUpEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + } + }; + + if (isEditing) { + document.addEventListener('keyup', handleKeyUpEscape); + } + + return () => { + document.removeEventListener('keyup', handleKeyUpEscape); + }; + }, [isEditing]); + + const handleDelete = () => { + setTodos([...todos.filter(e => e.id !== todo.id)]); + }; + + const handleToggleCompleted = () => { + setTodos([ + ...todos.map(e => + e.id === todo.id ? { ...e, completed: !e.completed } : e, + ), + ]); + }; + + return ( +
+ + + {isEditing ? ( + + ) : ( + <> + setIsEditing(true)} + > + {title} + + + {/* Remove button appears only on hover */} + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..d6cd36d30 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,33 @@ +import { useFilter } from '../context/FilterContext'; +import { useTodos } from '../context/TodosContext'; +import { Filter, FILTER_BY } from '../utils/constants/Filter'; +// eslint-disable-next-line import/extensions +import { TodoElem } from './TodoElem'; + +export const TodoList = () => { + const todos = useTodos(); + const currentFilter: Filter = useFilter(); + + if (!todos) { + return null; + } + + const filteredTodos = todos.filter(todo => { + switch (currentFilter) { + case FILTER_BY.active: + return !todo.completed; + case FILTER_BY.completed: + return todo.completed; + default: + return todo; + } + }); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoTitleField.tsx b/src/components/TodoTitleField.tsx new file mode 100644 index 000000000..c4f6c06bf --- /dev/null +++ b/src/components/TodoTitleField.tsx @@ -0,0 +1,46 @@ +import { FormEvent, useState } from 'react'; +import { useSetTodos, useTodos } from '../context/TodosContext'; +import { Todo } from '../types/Todo'; + +interface Props { + todo: Todo; + onEditing: (v: boolean) => void; +} + +export const TodoTitleField: React.FC = ({ todo, onEditing }) => { + const { title, id } = todo; + const todos = useTodos(); + const setTodos = useSetTodos(); + + const [newTitle, setNewTitle] = useState(title); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + const trimmedTitle = newTitle.trim(); + + if (!trimmedTitle) { + setTodos(todos.filter(v => v.id !== id)); + } else { + setTodos([ + ...todos.map(v => (v.id === id ? { ...v, title: trimmedTitle } : v)), + ]); + } + + onEditing(false); + }; + + return ( +
+ setNewTitle(e.target.value)} + /> +
+ ); +}; diff --git a/src/context/FilterContext.tsx b/src/context/FilterContext.tsx new file mode 100644 index 000000000..508c2bfa0 --- /dev/null +++ b/src/context/FilterContext.tsx @@ -0,0 +1,23 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useContext, useState } from 'react'; +import { Filter, FILTER_BY } from '../utils/constants/Filter'; + +const FilterContext = React.createContext(FILTER_BY.all); +const SetFilterContext = React.createContext((v: Filter) => {}); + +export const useFilter = () => useContext(FilterContext); +export const useSetFilter = () => useContext(SetFilterContext); + +interface Props { + children: React.ReactNode; +} + +export const FilterProvider: React.FC = ({ children }) => { + const [filter, setFilter] = useState(FILTER_BY.all); + + return ( + + {children} + + ); +}; diff --git a/src/context/TodosContext.tsx b/src/context/TodosContext.tsx new file mode 100644 index 000000000..e2da52955 --- /dev/null +++ b/src/context/TodosContext.tsx @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import React, { useContext } from 'react'; +import { Todo } from '../types/Todo'; +import { useLocalStorage } from '../hooks/useLocalStorage'; + +const TodosContext = React.createContext([] as Todo[]); +const SetTodosContext = React.createContext((v: Todo[]) => {}); + +export const useTodos = () => useContext(TodosContext); +export const useSetTodos = () => useContext(SetTodosContext); + +interface Props { + children: React.ReactNode; +} + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + + return ( + + {children} + + ); +}; diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..740603d89 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,19 @@ +import { useState } from 'react'; + +export function useLocalStorage( + key: string, + startValue: T, +): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + const data = localStorage.getItem(key); + + return data ? JSON.parse(data) : startValue; + }); + + const save = (newValue: T) => { + setValue(newValue); + localStorage.setItem(key, JSON.stringify(newValue)); + }; + + return [value, save]; +} diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..b2c38a17a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,6 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import './styles/index.scss'; import { App } from './App'; 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/index.css b/src/styles/index.scss similarity index 87% rename from src/styles/index.css rename to src/styles/index.scss index a34eec7c6..d8d324941 100644 --- a/src/styles/index.css +++ b/src/styles/index.scss @@ -21,5 +21,5 @@ body { } @import './todoapp'; -@import './todo'; -@import './filter'; +@import './todo-list'; +@import './filters'; diff --git a/src/styles/todo-list.css b/src/styles/todo-list.scss similarity index 98% rename from src/styles/todo-list.css rename to src/styles/todo-list.scss index 4576af434..cfb34ec2f 100644 --- a/src/styles/todo-list.css +++ 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/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/constants/Filter.ts b/src/utils/constants/Filter.ts new file mode 100644 index 000000000..5a9cf4993 --- /dev/null +++ b/src/utils/constants/Filter.ts @@ -0,0 +1,7 @@ +export const FILTER_BY = { + all: 'all', + completed: 'completed', + active: 'active', +} as const; + +export type Filter = keyof typeof FILTER_BY; diff --git a/src/utils/methods.ts b/src/utils/methods.ts new file mode 100644 index 000000000..c4f0ef7c2 --- /dev/null +++ b/src/utils/methods.ts @@ -0,0 +1,9 @@ +import { Todo } from '../types/Todo'; + +export const getMaxId = (todos: Todo[]): number => { + return todos.length > 0 ? Math.max(...todos.map(todo => todo.id)) : 0; +}; + +export const getCompletedTodos = (todos: Todo[]): Todo[] => { + return todos.filter(todo => todo.completed); +};