diff --git a/README.md b/README.md index 903c876f9..443780cda 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://jk-npc.github.io/react_todo-app/) and add it to the PR description. diff --git a/package-lock.json b/package-lock.json index 1f19b4743..a7f06fc95 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.3", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", @@ -1170,10 +1170,11 @@ } }, "node_modules/@mate-academy/scripts": { - "version": "1.9.12", - "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz", - "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz", + "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==", "dev": true, + "license": "MIT", "dependencies": { "@octokit/rest": "^17.11.2", "@types/get-port": "^4.2.0", diff --git a/src/App.tsx b/src/App.tsx index a399287bd..81019ac75 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,100 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState } from 'react'; +import { useTodos } from './context/TodoContext'; +import { TodoItem } from './components/TodoItem'; +import { TodoFilter } from './components/TodoFilter'; + +type FilterType = 'all' | 'active' | 'completed'; export const App: React.FC = () => { + const { todos, inputRef, addTodo, toggleAll, clearCompleted } = useTodos(); + const [filter, setFilter] = useState('all'); + const [newTitle, setNewTitle] = useState(''); + + const filteredTodos = todos.filter(todo => { + if (filter === 'active') { + return !todo.completed; + } + + if (filter === 'completed') { + return todo.completed; + } + + return true; + }); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const hasCompleted = todos.some(todo => todo.completed); + const allCompleted = todos.length > 0 && todos.every(todo => todo.completed); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!newTitle.trim()) { + return; + } + + addTodo(newTitle.trim()); + setNewTitle(''); + inputRef.current?.focus(); + }; + return (

todos

- {/* this button should have `active` class only if all todos are completed */} -
-
- {/* This is a completed todo */} -
- - - - Completed Todo + {todos.length > 0 && ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ )} + + {todos.length > 0 && ( +
+ + {activeTodosCount} items left - {/* 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 */} - -
+ Clear completed + + + )}
); diff --git a/src/components/TodoFilter.tsx b/src/components/TodoFilter.tsx new file mode 100644 index 000000000..c87601290 --- /dev/null +++ b/src/components/TodoFilter.tsx @@ -0,0 +1,38 @@ +type FilterType = 'all' | 'active' | 'completed'; + +type Props = { + filter: FilterType; + onChange: (filter: FilterType) => void; +}; + +const FILTERS = [ + { type: 'all', label: 'All', href: '#/', dataCy: 'FilterLinkAll' }, + { + type: 'active', + label: 'Active', + href: '#/active', + dataCy: 'FilterLinkActive', + }, + { + type: 'completed', + label: 'Completed', + href: '#/completed', + dataCy: 'FilterLinkCompleted', + }, +] as const; + +export const TodoFilter = ({ filter, onChange }: Props) => ( + +); diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..0215b0ab2 --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,87 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Todo } from '../types/Todo'; +import { useTodos } from '../context/TodoContext'; + +export const TodoItem = ({ todo }: { todo: Todo }) => { + const { deleteTodo, toggleTodo, updateTodo } = useTodos(); + const [editing, setEditing] = useState(false); + const [editTitle, setEditTitle] = useState(todo.title); + const inputRef = useRef(null); + + useEffect(() => { + if (editing) { + inputRef.current?.focus(); + } + }, [editing]); + + const handleSubmit = () => { + updateTodo(todo.id, editTitle); + setEditing(false); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setEditTitle(todo.title); + setEditing(false); + } + }; + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleSubmit(); + }; + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {editing ? ( +
+ setEditTitle(e.target.value)} + onBlur={handleSubmit} + onKeyUp={handleKeyUp} + /> +
+ ) : ( + <> + setEditing(true)} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..ab12d096f --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,100 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useRef, +} from 'react'; +import { Todo } from '../types/Todo'; + +type TodoContextType = { + todos: Todo[]; + inputRef: React.RefObject; + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + toggleTodo: (id: number) => void; + toggleAll: () => void; + clearCompleted: () => void; + updateTodo: (id: number, title: string) => void; +}; + +const TodoContext = createContext({} as TodoContextType); + +export const useTodos = () => useContext(TodoContext); + +export const TodoProvider = ({ children }: { children: React.ReactNode }) => { + const [todos, setTodos] = useState(() => { + try { + const saved = localStorage.getItem('todos'); + + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } + }); + + const inputRef = useRef(null); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + const addTodo = (title: string) => { + setTodos(prev => [...prev, { id: +new Date(), title, completed: false }]); + }; + + const deleteTodo = (id: number) => { + setTodos(prev => prev.filter(todo => todo.id !== id)); + inputRef.current?.focus(); + }; + + const toggleTodo = (id: number) => { + setTodos(prev => + prev.map(todo => + todo.id === id ? { ...todo, completed: !todo.completed } : todo, + ), + ); + }; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + + setTodos(prev => prev.map(todo => ({ ...todo, completed: !allCompleted }))); + }; + + const clearCompleted = () => { + setTodos(prev => prev.filter(todo => !todo.completed)); + inputRef.current?.focus(); + }; + + const updateTodo = (id: number, title: string) => { + if (!title.trim()) { + deleteTodo(id); + + return; + } + + setTodos(prev => + prev.map(todo => + todo.id === id ? { ...todo, title: title.trim() } : todo, + ), + ); + }; + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..a6ffde95f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,9 +1,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..fd5f6aa10 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.scss @@ -73,6 +73,7 @@ &__title-field { width: 100%; padding: 11px 14px; + box-sizing: border-box; font-size: inherit; line-height: inherit; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..77d61afc6 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -25,6 +25,10 @@ &__header { position: relative; + + form { + display: block; + } } &__toggle-all { @@ -67,6 +71,7 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + box-sizing: border-box; border: 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; +}