From c0b3ce60a0d4ec584aab501cecaf9289cbc6d3f6 Mon Sep 17 00:00:00 2001 From: G E O R G E Date: Wed, 22 Apr 2026 09:27:39 +0300 Subject: [PATCH] add solution --- src/App.tsx | 159 +++--------------------- src/components/Footer/Footer.tsx | 81 +++++++++++++ src/components/Footer/index.ts | 1 + src/components/Header/Header.tsx | 72 +++++++++++ src/components/Header/index.ts | 1 + src/components/Todos/TodosList.tsx | 122 +++++++++++++++++++ src/components/Todos/index.ts | 1 + src/context/TodosContext.tsx | 189 +++++++++++++++++++++++++++++ src/index.tsx | 7 +- src/styles/todo-list.scss | 1 + src/styles/todoapp.scss | 1 + src/types/ActionType.ts | 11 ++ src/types/FilterEnum.ts | 5 + src/types/TodosInterface.ts | 5 + 14 files changed, 512 insertions(+), 144 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/Todos/TodosList.tsx create mode 100644 src/components/Todos/index.ts create mode 100644 src/context/TodosContext.tsx create mode 100644 src/types/ActionType.ts create mode 100644 src/types/FilterEnum.ts create mode 100644 src/types/TodosInterface.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..2c9170f2a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,29 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useEffect } from 'react'; +import { Header } from './components/Header'; +import { TodosList } from './components/Todos'; +import { Footer } from './components/Footer'; +import { StateContext } from './context/TodosContext'; export const App: React.FC = () => { + const { todos } = useContext(StateContext); + + useEffect(() => { + if (!localStorage.getItem('todos')) { + localStorage.setItem('todos', JSON.stringify([])); + } + }, []); + 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..1d74476ef --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,81 @@ +import React, { useContext, useMemo } from 'react'; +import { DispatchContext, StateContext } from '../../context/TodosContext'; +import { TodosType } from '../../types/TodosInterface'; +import { FilterEnum } from '../../types/FilterEnum'; +import classNames from 'classnames'; + +export const Footer: React.FC = () => { + const { todos, filter } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const activeTodos = useMemo(() => { + return [...todos].filter((currentTodo: TodosType) => { + return currentTodo.completed === false; + }); + }, [todos]); + + const completedTodos = useMemo(() => { + return [...todos].some((currentTodo: TodosType) => { + return currentTodo.completed === true; + }); + }, [todos]); + + return ( + + ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 000000000..65e2506fa --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export { Footer } from './Footer'; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..c10227cef --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,72 @@ +import React, { useContext, useEffect, useRef, useState } from 'react'; +import { DispatchContext, StateContext } from '../../context/TodosContext'; +import classNames from 'classnames'; +import { TodosType } from '../../types/TodosInterface'; + +export const Header: React.FC = () => { + const { todos } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const [title, setTitle] = useState(''); + const fieldFocus = useRef(null); + + const allTodosCopleted = [...todos].every( + (currentTodos: TodosType) => currentTodos.completed, + ); + + const handleTitle = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (title.trim().length === 0) { + return; + } + + dispatch({ type: 'add', title: title }); + setTitle(''); + }; + + useEffect(() => { + if (fieldFocus.current) { + fieldFocus.current.focus(); + } + }, []); + + useEffect(() => { + if (fieldFocus.current) { + fieldFocus.current.focus(); + } + }, [todos]); + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..29429dc97 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/src/components/Todos/TodosList.tsx b/src/components/Todos/TodosList.tsx new file mode 100644 index 000000000..defaa36c4 --- /dev/null +++ b/src/components/Todos/TodosList.tsx @@ -0,0 +1,122 @@ +import React, { useContext, useEffect, useMemo, useState } from 'react'; +import { DispatchContext, StateContext } from '../../context/TodosContext'; +import classNames from 'classnames'; +import { TodosType } from '../../types/TodosInterface'; + +export const TodosList: React.FC = () => { + const { todos, filter } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const [title, setTitle] = useState(''); + const [updateTodo, setUpdateTodo] = useState(null); + + const handleTitle = (event: React.ChangeEvent) => { + setTitle(event.target.value); + }; + + const onDelete = (id: number) => { + dispatch({ type: 'delete', todoID: id }); + }; + + const handleSubmit = () => { + const trimmedTitle = title.trim(); + + if (trimmedTitle === '') { + return onDelete(updateTodo?.id as number); + } + + dispatch({ + type: 'update', + currentTodo: updateTodo as TodosType, + newTitle: trimmedTitle, + }); + + setUpdateTodo(null); + }; + + const onEscape = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setUpdateTodo(null); + } + }; + + const visibleTodos = useMemo(() => { + if (filter === 'active') { + return [...todos].filter( + (currentTodos: TodosType) => currentTodos.completed === false, + ); + } + + if (filter === 'completed') { + return [...todos].filter( + (currentTodos: TodosType) => currentTodos.completed === true, + ); + } + + return todos; + }, [todos, filter]); + + useEffect(() => { + if (updateTodo) { + setTitle(updateTodo?.title); + } + }, [updateTodo]); + + return ( +
+ {/* eslint-disable jsx-a11y/label-has-associated-control */} + + {visibleTodos.map(currentTodo => { + return ( +
setUpdateTodo(currentTodo)} + > + + {updateTodo?.id === currentTodo.id ? ( +
+ +
+ ) : ( + <> + + {currentTodo.title} + + + + + )} +
+ ); + })} +
+ ); +}; diff --git a/src/components/Todos/index.ts b/src/components/Todos/index.ts new file mode 100644 index 000000000..7b27cff16 --- /dev/null +++ b/src/components/Todos/index.ts @@ -0,0 +1 @@ +export { TodosList } from './TodosList'; diff --git a/src/context/TodosContext.tsx b/src/context/TodosContext.tsx new file mode 100644 index 000000000..a93d9e40c --- /dev/null +++ b/src/context/TodosContext.tsx @@ -0,0 +1,189 @@ +import React, { useReducer } from 'react'; +import { Action } from '../types/ActionType'; +import { TodosType } from '../types/TodosInterface'; +import { FilterEnum } from '../types/FilterEnum'; + +interface State { + todos: TodosType[]; + filter: FilterEnum; +} + +function getInitialTodos() { + const storage = localStorage.getItem('todos'); + + if (!storage) { + return []; + } + + return JSON.parse(storage); +} + +const initialState: State = { + todos: getInitialTodos(), + filter: FilterEnum.ALL, +}; + +export const StateContext = React.createContext(initialState); +export const DispatchContext = React.createContext>( + () => {}, +); + +function addTodo(state: State, title: string) { + const newTodos: TodosType = { + id: +new Date(), + title: title.trim(), + completed: false, + }; + + localStorage.setItem('todos', JSON.stringify([...state.todos, newTodos])); + + return [...state.todos, newTodos]; +} + +function deleteTodo(state: State, todosID: number) { + const newTodos = state.todos.filter( + (currentTodo: TodosType) => currentTodo.id !== todosID, + ); + + localStorage.setItem('todos', JSON.stringify(newTodos)); + + return newTodos; +} + +function updateTodos(state: State, updateTodo: TodosType, newTitle: string) { + const index = [...state.todos].findIndex((currentTodo: TodosType) => { + return currentTodo.id === updateTodo.id; + }); + + const newTodos: TodosType = { + id: updateTodo.id, + title: newTitle, + completed: updateTodo.completed, + }; + + state.todos.splice(index, 1, newTodos); + + localStorage.setItem('todos', JSON.stringify(state.todos)); + + return state.todos; +} + +function completeTodos(state: State, todosID: number) { + const newTodos = [...state.todos].map((currentTodos: TodosType) => { + return currentTodos.id === todosID + ? { ...currentTodos, completed: !currentTodos.completed } + : currentTodos; + }); + + localStorage.setItem('todos', JSON.stringify(newTodos)); + + return newTodos; +} + +function deleteCopleted(state: State) { + const newTodos = [...state.todos].filter( + (currentTodos: TodosType) => currentTodos.completed === false, + ); + + localStorage.setItem('todos', JSON.stringify(newTodos)); + + return newTodos; +} + +function completeAll(state: State) { + const isEveryActive = [...state.todos].some( + (currentTodos: TodosType) => currentTodos.completed === false, + ); + + const isEveryCompleted = [...state.todos].some( + (currentTodos: TodosType) => currentTodos.completed === true, + ); + + if (isEveryActive) { + const everyCompletedTodos = [...state.todos].map( + (currentTodos: TodosType) => { + return { ...currentTodos, completed: true }; + }, + ); + + localStorage.setItem('todos', JSON.stringify(everyCompletedTodos)); + + return everyCompletedTodos; + } + + if (isEveryCompleted) { + const everyactiveTodos = [...state.todos].map((currentTodos: TodosType) => { + return { ...currentTodos, completed: false }; + }); + + localStorage.setItem('todos', JSON.stringify(everyactiveTodos)); + + return everyactiveTodos; + } + + const newTodos = [...state.todos].map((currentTodos: TodosType) => { + return currentTodos.completed === false + ? { ...currentTodos, completed: true } + : { ...currentTodos }; + }); + + localStorage.setItem('todos', JSON.stringify(newTodos)); + + return newTodos; +} + +function TodosReducer(state: State, action: Action): State { + switch (action.type) { + case 'add': + return { + ...state, + todos: addTodo(state, action.title), + }; + case 'delete': + return { + ...state, + todos: deleteTodo(state, action.todoID), + }; + case 'update': + return { + ...state, + todos: updateTodos(state, action.currentTodo, action.newTitle), + }; + case 'complete': + return { + ...state, + todos: completeTodos(state, action.todoID), + }; + case 'filter': + return { + ...state, + filter: action.filterType, + }; + case 'deleteCompleted': + return { + ...state, + todos: deleteCopleted(state), + }; + case 'completeAll': + return { + ...state, + todos: completeAll(state), + }; + default: + return state; + } +} + +interface Props { + children: React.ReactNode; +} + +export const GlobalTodosContext: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(TodosReducer, initialState); + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..e9e15570d 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 { GlobalTodosContext } from './context/TodosContext'; 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/ActionType.ts b/src/types/ActionType.ts new file mode 100644 index 000000000..39be04465 --- /dev/null +++ b/src/types/ActionType.ts @@ -0,0 +1,11 @@ +import { FilterEnum } from './FilterEnum'; +import { TodosType } from './TodosInterface'; + +export type Action = + | { type: 'add'; title: string } + | { type: 'update'; currentTodo: TodosType; newTitle: string } + | { type: 'delete'; todoID: number } + | { type: 'complete'; todoID: number } + | { type: 'deleteCompleted' } + | { type: 'completeAll' } + | { type: 'filter'; filterType: FilterEnum }; diff --git a/src/types/FilterEnum.ts b/src/types/FilterEnum.ts new file mode 100644 index 000000000..9537f05ee --- /dev/null +++ b/src/types/FilterEnum.ts @@ -0,0 +1,5 @@ +export enum FilterEnum { + ALL = 'all', + ACTIVE = 'active', + COMPLETED = 'completed', +} diff --git a/src/types/TodosInterface.ts b/src/types/TodosInterface.ts new file mode 100644 index 000000000..7917594e7 --- /dev/null +++ b/src/types/TodosInterface.ts @@ -0,0 +1,5 @@ +export interface TodosType { + id: number; + title: string; + completed: boolean; +}