From fea7cd55f7a4892c52550626b92240f307c7dbc0 Mon Sep 17 00:00:00 2001 From: Yaroslav65 Date: Tue, 25 Feb 2025 21:00:42 +0100 Subject: [PATCH] add task solution --- src/App.tsx | 156 ++------------------------- src/components/Footer/Footer.tsx | 50 +++++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 63 +++++++++++ src/components/Header/index.ts | 1 + src/components/TodoItem/TodoItem.tsx | 130 ++++++++++++++++++++++ src/components/TodoItem/index.ts | 1 + src/components/TodoList/TodoList.tsx | 16 +++ src/components/TodoList/index.ts | 1 + src/context/TodoContext.tsx | 130 ++++++++++++++++++++++ src/index.tsx | 7 +- src/styles/todo-list.scss | 1 + src/styles/todoapp.scss | 1 + src/types/types.ts | 27 +++++ 14 files changed, 439 insertions(+), 146 deletions(-) create mode 100644 src/components/Footer/Footer.tsx create mode 100644 src/components/Footer/index.ts create mode 100644 src/components/Header/Header.tsx create mode 100644 src/components/Header/index.ts create mode 100644 src/components/TodoItem/TodoItem.tsx create mode 100644 src/components/TodoItem/index.ts create mode 100644 src/components/TodoList/TodoList.tsx create mode 100644 src/components/TodoList/index.ts create mode 100644 src/context/TodoContext.tsx create mode 100644 src/types/types.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..22e4410e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,22 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import { Header } from './components/Header/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; +import { useContext } from 'react'; +import { TodoContext } from './context/TodoContext'; +import { TodoContextType } from './types/types'; export const App: React.FC = () => { + const { todos } = useContext(TodoContext) as TodoContextType; + 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 */} - -
+
+ + {todos.length > 0 &&
}
); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..e3a34861c --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,50 @@ +import { useContext } from 'react'; +import { TodoContext } from '../../context/TodoContext'; +import { Status, TodoContextType } from '../../types/types'; +import classNames from 'classnames'; + +export const Footer: React.FC = () => { + const { + counterTodos, + setStatus, + status, + counterCompletedTodos, + clearCompleted, + } = useContext(TodoContext) as TodoContextType; + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 000000000..ddcc5a9cd --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..1e9e11b0d --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,63 @@ +import { useContext, useState } from 'react'; +import { TodoContext } from '../../context/TodoContext'; +import { TodoContextType } from '../../types/types'; +import classNames from 'classnames'; + +export const Header: React.FC = () => { + const [query, setQuery] = useState(''); + const { addTodo, todos, counterCompletedTodos, toggleAllTodo, inputRef } = + useContext(TodoContext) as TodoContextType; + + const reset = () => { + setQuery(''); + }; + + const handleQueryChange = (newValue: string) => { + setQuery(newValue); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (!query.trim()) { + return; + } + + addTodo({ + id: 0, + title: query.trim(), + completed: false, + }); + + reset(); + }; + + return ( +
+ {todos.length !== 0 && ( +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..266dec8a1 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..2eaf7cbf2 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,130 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { TodoContext } from '../../context/TodoContext'; +import { TodoContextType } from '../../types/types'; + +import classNames from 'classnames'; +import { Todo } from '../../types/types'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const [updatedTitle, setUpdatedTitle] = useState(''); + const [editingTodoId, setEditingTodoId] = useState(null); + const { toggleTodo, deleteTodo, setTodos, todos } = useContext( + TodoContext, + ) as TodoContextType; + + const { id, completed, title } = todo; + + const editTodoRef = useRef(null); + + useEffect(() => { + if (editTodoRef.current !== null) { + editTodoRef.current.focus(); + } + }, [editingTodoId]); + + const startEditTodo = (currentTodo: Todo) => { + setEditingTodoId(currentTodo.id); + setUpdatedTitle(currentTodo.title); + }; + + const changeTitleTodo = (selectedTodo: Todo) => { + if (updatedTitle.trim() === selectedTodo.title.trim()) { + setEditingTodoId(null); + + return; + } + + if (updatedTitle.trim() === '') { + deleteTodo(selectedTodo.id); + } else { + const updatedTodo = { + ...selectedTodo, + title: updatedTitle.trim(), + }; + + const updatedTodos = todos.map(currentTodo => + currentTodo.id === updatedTodo.id ? updatedTodo : currentTodo, + ); + + setTodos(updatedTodos); + } + + setEditingTodoId(null); + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {editingTodoId === id ? ( +
{ + event.preventDefault(); + changeTitleTodo(todo); + }} + > + setUpdatedTitle(event.target.value)} + onBlur={() => { + changeTitleTodo(todo); + }} + onKeyDown={event => { + if (event.key === 'Escape') { + setEditingTodoId(null); + } + }} + ref={editTodoRef} + /> +
+ ) : ( + <> + { + startEditTodo(todo); + }} + > + {title} + + + + )} +
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..21f4abac3 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..abb85fa60 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { TodoContextType } from '../../types/types'; +import { TodoItem } from '../TodoItem'; +import { TodoContext } from '../../context/TodoContext'; + +export const TodoList: React.FC = () => { + const { filteredTodos } = useContext(TodoContext) as TodoContextType; + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..21bbe65f0 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,130 @@ +import { createContext, useEffect, useRef, useState } from 'react'; +import { Status, Todo, TodoContextType } from '../types/types'; + +export const TodoContext = createContext(null); + +function useLocalStorage(key: string, startValue: T): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + return startValue; + } + + try { + return JSON.parse(data); + } catch (e) { + return startValue; + } + }); + + useEffect(() => { + localStorage.setItem(key, JSON.stringify(value)); + }, [key, value]); + + const save = (newValue: T) => { + setValue(newValue); + }; + + return [value, save]; +} + +export const TodoProvider = ({ children }: { children: React.ReactNode }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + const [counterTodos, setCounterTodos] = useState(0); + const [status, setStatus] = useState(Status.All); + const [counterCompletedTodos, setCounterCompletedTodos] = useState(0); + + const inputRef = useRef(null); + + const checkInputFocus = () => { + if (inputRef.current !== null) { + inputRef.current.focus(); + } + }; + + useEffect(() => { + checkInputFocus(); + }, []); + + const filteredTodos = todos.filter(todo => { + if (status === Status.Active) { + return !todo.completed; + } + + if (status === Status.Completed) { + return todo.completed; + } + + return todo; + }); + + useEffect(() => { + const activeTodo = todos.filter(todo => !todo.completed).length; + const completedTodos = todos.filter(todo => todo.completed).length; + + setCounterTodos(activeTodo); + setCounterCompletedTodos(completedTodos); + }, [todos]); + + const addTodo = ({ id, ...data }: Todo) => { + const newTodo = { + id: +new Date(), + ...data, + }; + + setTodos([...todos, newTodo]); + }; + + const deleteTodo = (todoId: number) => { + setTodos(todos.filter(todo => todo.id !== todoId)); + checkInputFocus(); + }; + + const toggleTodo = (selectedTodo: Todo) => { + const newTodos = todos.map(todo => + todo.id === selectedTodo.id ? selectedTodo : todo, + ); + + setTodos(newTodos); + checkInputFocus(); + }; + + const toggleAllTodo = () => { + const hasNoCompletedTodos = todos.some(todo => !todo.completed); + const newCompletionState = hasNoCompletedTodos ? true : false; + const noCompleteTodos = todos.filter(todo => !todo.completed); + + if (noCompleteTodos) { + setTodos(todos.map(todo => ({ ...todo, completed: newCompletionState }))); + checkInputFocus(); + } + }; + + const clearCompleted = () => { + setTodos(todos.filter(todo => !todo.completed)); + checkInputFocus(); + }; + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..a4207b1fc 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 { TodoProvider } from './context/TodoContext'; 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..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/types.ts b/src/types/types.ts new file mode 100644 index 000000000..f173f73a0 --- /dev/null +++ b/src/types/types.ts @@ -0,0 +1,27 @@ +export enum Status { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} + +export interface Todo { + completed: boolean; + id: number; + title: string; +} + +export type TodoContextType = { + todos: Todo[]; + filteredTodos: Todo[]; + counterTodos: number; + setTodos: (v: Todo[]) => void; + addTodo: (todo: Todo) => void; + toggleTodo: (todo: Todo) => void; + status: Status; + setStatus: React.Dispatch>; + counterCompletedTodos: number; + toggleAllTodo: () => void; + deleteTodo: (todoId: number) => void; + clearCompleted: () => void; + inputRef: React.RefObject; +};