diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b51149cf5..be46e87f8 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,7 +5,7 @@ module.exports = { }, extends: [ 'plugin:react/recommended', - "plugin:react-hooks/recommended", + 'plugin:react-hooks/recommended', 'airbnb-typescript', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', @@ -14,11 +14,11 @@ module.exports = { ], overrides: [ { - 'files': ['**/*.spec.jsx'], - 'rules': { + files: ['**/*.spec.jsx'], + rules: { 'react/jsx-filename-extension': ['off'], - } - } + }, + }, ], parser: '@typescript-eslint/parser', parserOptions: { @@ -34,18 +34,21 @@ module.exports = { 'import', 'react-hooks', '@typescript-eslint', - 'prettier' + 'prettier', ], rules: { // JS - 'semi': 'off', + semi: 'off', '@typescript-eslint/semi': ['error', 'always'], 'prefer-const': 2, curly: [2, 'all'], - 'max-len': ['error', { - ignoreTemplateLiterals: true, - ignoreComments: true, - }], + 'max-len': [ + 'error', + { + ignoreTemplateLiterals: true, + ignoreComments: true, + }, + ], 'no-redeclare': [2, { builtinGlobals: true }], 'no-console': 2, 'operator-linebreak': 0, @@ -57,11 +60,15 @@ module.exports = { 2, { blankLine: 'always', prev: '*', next: 'return' }, { blankLine: 'always', prev: ['const', 'let', 'var'], next: '*' }, - { blankLine: 'any', prev: ['const', 'let', 'var'], next: ['const', 'let', 'var'] }, + { + blankLine: 'any', + prev: ['const', 'let', 'var'], + next: ['const', 'let', 'var'], + }, { blankLine: 'always', prev: 'directive', next: '*' }, { blankLine: 'always', prev: 'block-like', next: '*' }, ], - 'implicit-arrow-linebreak:': 0, + 'implicit-arrow-linebreak': 0, // React 'react/prop-types': 0, @@ -73,16 +80,22 @@ module.exports = { 'react/jsx-props-no-spreading': 0, 'react/state-in-constructor': [2, 'never'], 'react-hooks/rules-of-hooks': 2, - 'jsx-a11y/label-has-associated-control': ["error", { - assert: "either", - }], - 'jsx-a11y/label-has-for': [2, { - components: ['Label'], - required: { - some: ['id', 'nesting'], + 'jsx-a11y/label-has-associated-control': [ + 'error', + { + assert: 'either', }, - allowChildren: true, - }], + ], + 'jsx-a11y/label-has-for': [ + 2, + { + components: ['Label'], + required: { + some: ['id', 'nesting'], + }, + allowChildren: true, + }, + ], 'react/jsx-uses-react': 'off', 'react/react-in-jsx-scope': 'off', @@ -91,7 +104,9 @@ module.exports = { '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-unused-vars': ['error'], '@typescript-eslint/indent': ['error', 2], - '@typescript-eslint/ban-types': ['error', { + '@typescript-eslint/ban-types': [ + 'error', + { extendDefaults: true, types: { '{}': false, @@ -99,7 +114,13 @@ module.exports = { }, ], }, - ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts', 'src/vite-env.d.ts', 'cypress'], + ignorePatterns: [ + 'dist', + '.eslintrc.cjs', + 'vite.config.ts', + 'src/vite-env.d.ts', + 'cypress', + ], settings: { react: { version: 'detect', diff --git a/README.md b/README.md index 903c876f9..3510e3847 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://nkoropka.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/package.json b/package.json index 91d7489b9..446974833 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,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", diff --git a/src/App.tsx b/src/App.tsx index a399287bd..aeb0ed7b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,28 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import classNames from 'classnames'; +import { TodoItem } from './components/TodoItem'; +import { useTodo } from './hooks/useTodo'; +import { Footer } from './components/Footer'; export const App: React.FC = () => { + const { + todos, + visibleTodos, + title, + setTitle, + filter, + setFilter, + newTodoInputRef, + handleSubmit, + handleDeleteTodo, + handleToggleAll, + handleClearCompleted, + hasCompletedTodos, + activeTodosCount, + focusNewTodoInput, + } = useTodo(); + return (

todos

@@ -9,148 +30,53 @@ export const App: React.FC = () => {
{/* 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 && ( + <> +
+ {visibleTodos.map(todo => ( + + ))} +
+ +
+ + )}
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..7bdabe91b --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,75 @@ +import classNames from 'classnames'; +import React from 'react'; +import { FilteredStatus } from '../types/FilteredStatus'; + +interface Props { + activeTodosCount: number; + filter: FilteredStatus; + setFilter: (filter: FilteredStatus) => void; + handleClearCompleted: () => void; + hasCompletedTodos: boolean; +} + +export const Footer: React.FC = ({ + activeTodosCount, + filter, + setFilter, + handleClearCompleted, + hasCompletedTodos, +}) => { + const filterOptions: { + type: FilteredStatus; + label: string; + href: string; + cy: string; + }[] = [ + { type: 'all', label: 'All', href: '#/', cy: 'FilterLinkAll' }, + { + type: 'active', + label: 'Active', + href: '#/active', + cy: 'FilterLinkActive', + }, + { + type: 'completed', + label: 'Completed', + href: '#/completed', + cy: 'FilterLinkCompleted', + }, + ]; + + return ( +
+ + {activeTodosCount} items left + + + {/* Active link should have the 'selected' class */} + + + +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..0e894d7ba --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useTodos } from '../context/TodosContext'; +import { useTodoItem } from '../hooks/useTodoItem'; + +type Props = { + todo: Todo; + onDelete: (todoId: number) => void; + onFocusNewTodo?: () => void; +}; + +export const TodoItem: React.FC = ({ + todo, + onDelete, + onFocusNewTodo, +}) => { + const { dispatch } = useTodos(); + const { + isEditing, + editingTitle, + setEditingTitle, + handleSave, + handleKeyUp, + handleBlur, + handleDoubleClick, + } = useTodoItem({ todo, onDelete, onFocusNewTodo }); + + return ( +
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + {!isEditing ? ( + <> + + {todo.title} + + + + + ) : ( +
+ setEditingTitle(e.target.value)} + onBlur={handleBlur} + onKeyUp={handleKeyUp} + autoFocus + /> +
+ )} +
+ ); +}; diff --git a/src/context/TodosContext.tsx b/src/context/TodosContext.tsx new file mode 100644 index 000000000..9dbad92de --- /dev/null +++ b/src/context/TodosContext.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useReducer } from 'react'; +import { Todo } from '../types/Todo'; + +export type Action = + | { type: 'ADD'; payload: string } + | { type: 'DELETE'; payload: number } + | { type: 'TOGGLE'; payload: number } + | { type: 'RENAME'; payload: { id: number; title: string } } + | { type: 'TOGGLE_ALL'; payload: boolean } + | { type: 'CLEAR' }; + +function reducer(todos: Todo[], action: Action): Todo[] { + switch (action.type) { + case 'ADD': + const newTodo = { + id: +new Date(), + title: action.payload, + completed: false, + }; + + return [...todos, newTodo]; + + case 'DELETE': + return todos.filter(todo => todo.id !== action.payload); + + case 'TOGGLE': + return todos.map(todo => + todo.id === action.payload + ? { ...todo, completed: !todo.completed } + : todo, + ); + + case 'RENAME': + return todos.map(todo => + todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo, + ); + + case 'TOGGLE_ALL': + return todos.map(todo => ({ + ...todo, + completed: action.payload, + })); + case 'CLEAR': + return todos.filter(todo => !todo.completed); + + default: + return todos; + } +} + +interface TodosContextType { + todos: Todo[]; + dispatch: React.Dispatch; +} + +export const TodosContext = React.createContext(null); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, dispatch] = useReducer(reducer, [], () => { + const savedTodos = localStorage.getItem('todos'); + + return savedTodos ? JSON.parse(savedTodos) : []; + }); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); + + return ( + + {children} + + ); +}; + +export const useTodos = () => { + const context = React.useContext(TodosContext); + + if (!context) { + throw new Error('useTodos must be used within a TodosProvider'); + } + + return context; +}; diff --git a/src/hooks/useTodo.ts b/src/hooks/useTodo.ts new file mode 100644 index 000000000..8e81034ea --- /dev/null +++ b/src/hooks/useTodo.ts @@ -0,0 +1,80 @@ +import { useRef, useState } from 'react'; +import { useTodos } from '../context/TodosContext'; +import { FilteredStatus } from '../types/FilteredStatus'; + +export const useTodo = () => { + const { todos, dispatch } = useTodos(); + const [title, setTitle] = useState(''); + const [filter, setFilter] = useState('all'); + + const newTodoInputRef = useRef(null); + + const focusNewTodoInput = () => { + newTodoInputRef.current?.focus(); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + dispatch({ type: 'ADD', payload: trimmedTitle }); + + setTitle(''); + }; + + const handleDeleteTodo = (todoId: number) => { + dispatch({ type: 'DELETE', payload: todoId }); + focusNewTodoInput(); + }; + + const handleToggleAll = () => { + const areAllCompleted = todos.every(t => t.completed); + + dispatch({ + type: 'TOGGLE_ALL', + payload: !areAllCompleted, + }); + }; + + const handleClearCompleted = () => { + dispatch({ type: 'CLEAR' }); + focusNewTodoInput(); + }; + + const hasCompletedTodos = todos.some(t => t.completed); + const activeTodosCount = todos.filter(t => !t.completed).length; + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case 'active': + return !todo.completed; + case 'completed': + return todo.completed; + + default: + return true; + } + }); + + return { + todos, + visibleTodos, + title, + setTitle, + filter, + setFilter, + newTodoInputRef, + handleSubmit, + handleDeleteTodo, + handleToggleAll, + handleClearCompleted, + hasCompletedTodos, + activeTodosCount, + focusNewTodoInput, + }; +}; diff --git a/src/hooks/useTodoItem.ts b/src/hooks/useTodoItem.ts new file mode 100644 index 000000000..9053eab2f --- /dev/null +++ b/src/hooks/useTodoItem.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTodos } from '../context/TodosContext'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; + onDelete: (todoId: number) => void; + onFocusNewTodo?: () => void; +}; + +export const useTodoItem = ({ todo, onDelete, onFocusNewTodo }: Props) => { + const { dispatch } = useTodos(); + const [isEditing, setIsEditing] = useState(false); + const [editingTitle, setEditingTitle] = useState(todo.title); + + const isCancelledRef = useRef(false); + + const handleSave = (event?: React.FormEvent) => { + if (event) { + event.preventDefault(); + } + + if (isCancelledRef.current || !isEditing) { + return; + } + + setIsEditing(false); + + const trimmedTitle = editingTitle.trim(); + + if (!trimmedTitle) { + onDelete(todo.id); + + return; + } + + if (trimmedTitle !== todo.title) { + dispatch({ + type: 'RENAME', + payload: { id: todo.id, title: trimmedTitle }, + }); + } + + onFocusNewTodo?.(); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + isCancelledRef.current = true; + setEditingTitle(todo.title); + + setIsEditing(false); + onFocusNewTodo?.(); + } + }; + + const handleBlur = () => { + if (isCancelledRef.current || !isEditing) { + return; + } + + handleSave(); + }; + + const handleDoubleClick = () => { + setIsEditing(true); + setEditingTitle(todo.title); + isCancelledRef.current = false; + }; + + useEffect(() => { + setEditingTitle(todo.title); + }, [todo.title]); + + return { + isEditing, + editingTitle, + setEditingTitle, + handleSave, + handleKeyUp, + handleBlur, + handleDoubleClick, + }; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..fc71601c9 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 { TodosProvider } 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..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..a186995cb 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,5 +1,6 @@ + .todoapp { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 300; color: #4d4d4d; @@ -8,8 +9,7 @@ &__content { margin-bottom: 20px; background: #fff; - box-shadow: - 0 2px 4px 0 rgba(0, 0, 0, 0.2), + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); } @@ -49,7 +49,7 @@ } &::before { - content: '❯'; + content: "❯"; transform: translateY(2px) rotate(90deg); line-height: 0; } @@ -58,6 +58,7 @@ &__new-todo { width: 100%; padding: 16px 16px 16px 60px; + box-sizing: border-box; font-size: 24px; line-height: 1.4em; @@ -69,7 +70,7 @@ border: none; background: rgba(0, 0, 0, 0.01); - box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); &::placeholder { font-style: italic; @@ -97,8 +98,7 @@ text-align: center; border-top: 1px solid #e6e6e6; - box-shadow: - 0 1px 1px rgba(0, 0, 0, 0.2), + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, @@ -122,6 +122,7 @@ appearance: none; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + transition: opacity 0.3s; &:hover { text-decoration: underline; @@ -130,5 +131,9 @@ &:active { text-decoration: none; } + + &:disabled { + visibility: hidden; + } } } diff --git a/src/types/FilteredStatus.ts b/src/types/FilteredStatus.ts new file mode 100644 index 000000000..7061de16b --- /dev/null +++ b/src/types/FilteredStatus.ts @@ -0,0 +1 @@ +export type FilteredStatus = 'all' | 'active' | 'completed'; 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; +}