diff --git a/src/App.tsx b/src/App.tsx index a399287bd..87f25a1c2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,13 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Content } from './components/Content'; +import { TodosProvider } from './context/TodosContex'; 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 */} - - - {/* this button should be disabled if there are no completed todos */} - -
-
+ + +
); }; diff --git a/src/components/Content.tsx b/src/components/Content.tsx new file mode 100644 index 000000000..86fd338ba --- /dev/null +++ b/src/components/Content.tsx @@ -0,0 +1,46 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Header } from './Header'; +import { TodoList } from './TodoList'; +import { Footer } from './Footer'; +import { TodosContext } from '../context/TodosContex'; + +export const Content: React.FC = () => { + const { todos, setTodos } = useContext(TodosContext); + + const [visibleTodos, setVisibleTodos] = useState(todos); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const savedTodos = localStorage.getItem('todos'); + + useEffect(() => { + if (savedTodos) { + setTodos(JSON.parse(savedTodos)); + } + }, []); + + useEffect(() => { + setVisibleTodos(todos); + }, [todos]); + + return ( + <> +

todos

+ +
+
+ + + + {todos.length > 0 && ( +
+ + ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..2405edacd --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,100 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { Filter } from '../types/Filter'; +import classNames from 'classnames'; +import { TodosContext } from '../context/TodosContex'; +import { Todo } from '../types/Todo'; + +type Props = { + visibleTodos: Todo[]; + setVisibleTodos: (vilteredTodos: Todo[]) => void; +}; + +export const Footer: React.FC = ({ visibleTodos, setVisibleTodos }) => { + const { todos, setTodos } = useContext(TodosContext); + + const [selectedFilter, setSelectedFilter] = useState(Filter.All); + + const findFilterKey = (value: string): string | undefined => { + return Object.keys(Filter).find( + key => Filter[key as keyof typeof Filter] === value, + ); + }; + + const todosCount = useCallback( + (type: Filter.Active | Filter.Completed) => { + const value = type === Filter.Active ? false : true; + + return todos.filter(todo => todo.completed === value).length; + }, + [todos], + ); + + const filterFunction = (filter: Filter) => { + switch (filter) { + case Filter.All: + setVisibleTodos(todos); + break; + + case Filter.Active: + setVisibleTodos(todos.filter(todo => !todo.completed)); + break; + + case Filter.Completed: + setVisibleTodos(todos.filter(todo => todo.completed)); + break; + + default: + setTodos(todos); + } + }; + + const clearFunction = () => { + setTodos(todos.filter(t => !t.completed)); + }; + + const setFilter = (filter: Filter) => { + setSelectedFilter(filter); + + filterFunction(filter); + }; + + useEffect(() => { + if (selectedFilter) { + filterFunction(selectedFilter); + } + }, [visibleTodos]); + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..f6a26f1f4 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,83 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { TodosContext } from '../context/TodosContex'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; + +export const Header: React.FC = () => { + const { todos, setTodos } = useContext(TodosContext); + + const [title, setTitle] = useState(''); + + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [todos]); + + const checkActiveTodos = (): boolean => { + if (todos.length > 0) { + return todos.every(todo => todo.completed); + } + + return false; + }; + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const normalizedTitle = title.trim(); + + if (!normalizedTitle) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: normalizedTitle, + completed: false, + }; + + setTodos([...todos, newTodo]); + setTitle(''); + }; + + const toggleAll = () => { + const allCompleted = checkActiveTodos(); + + const toggledTodos = todos.map(todo => ({ + ...todo, + completed: allCompleted ? false : true, + })); + + setTodos(toggledTodos); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..57027a63c --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,111 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; +import { TodosContext } from '../context/TodosContex'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const { todos, setTodos } = useContext(TodosContext); + + const [onEdit, setOnEdit] = useState(false); + const [title, setTitle] = useState(todo.title); + + const todoInputRef = useRef(null); + + useEffect(() => { + if (todoInputRef.current) { + todoInputRef.current.focus(); + } + }, [onEdit]); + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setOnEdit(false); + setTitle(todo.title); + } + }; + + const updateTodo = (updatedTodo: Todo) => { + setTodos(todos.map(t => (t.id === updatedTodo.id ? updatedTodo : t))); + }; + + const onDelete = () => { + setTodos(todos.filter(currentTodo => currentTodo.id !== todo.id)); + }; + + const statusChange = () => { + updateTodo({ ...todo, completed: !todo.completed }); + }; + + const onSubmit = (event?: React.FormEvent) => { + event?.preventDefault(); + setOnEdit(false); + + const normalizedTitle = title.trim(); + + if (!normalizedTitle) { + onDelete(); + + return; + } + + updateTodo({ ...todo, title: normalizedTitle }); + }; + + return ( + <> +
setOnEdit(true)} + className={classNames('todo', { completed: todo.completed })} + > + + + {!onEdit ? ( + <> + + {todo.title} + + + + + ) : ( +
onSubmit(event)}> + { + setTitle(event.target.value); + }} + onBlur={() => onSubmit()} + onKeyUp={event => handleKeyUp(event)} + type="text" + className="todo__title-field" + placeholder="Empty todo will be deleted" + /> +
+ )} +
+ + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..036cde44c --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,16 @@ +import { TodoItem } from './TodoItem'; +import { Todo } from '../types/Todo'; + +type Props = { + visibleTodos: Todo[]; +}; + +export const TodoList: React.FC = ({ visibleTodos }) => { + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodosContex.tsx b/src/context/TodosContex.tsx new file mode 100644 index 000000000..71047fdd4 --- /dev/null +++ b/src/context/TodosContex.tsx @@ -0,0 +1,32 @@ +import React, { createContext, useMemo, useState } from 'react'; +import { Todo } from '../types/Todo'; + +type ContextProps = { + todos: Todo[]; + setTodos: (todos: Todo[]) => void; +}; + +export const TodosContext = createContext({ + todos: [], + setTodos: () => {}, +}); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useState([]); + + const value = useMemo( + () => ({ + todos, + setTodos, + }), + [todos], + ); + + return ( + {children} + ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..8e38aa180 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,8 +1,9 @@ import { createRoot } from 'react-dom/client'; -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; +import './styles/index.scss'; +import './styles/todoapp.scss'; +import './styles/filter.scss'; +import './styles/todo.scss'; import { App } from './App'; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..2f9ee48e9 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,3 +1,7 @@ +* { + box-sizing: border-box; +} + .todoapp { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/types/FuncType.ts b/src/types/FuncType.ts new file mode 100644 index 000000000..74f3a0522 --- /dev/null +++ b/src/types/FuncType.ts @@ -0,0 +1,4 @@ +export enum FuncType { + update = 'update', + delete = 'delete', +} 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; +}