diff --git a/.eslintrc.cjs b/.eslintrc.cjs index b51149cf5..26bf31468 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -73,9 +73,7 @@ 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-associated-control': ['off'], 'jsx-a11y/label-has-for': [2, { components: ['Label'], required: { 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..08139dec4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,42 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Footer } from './components/footer'; +import { Header } from './components/header'; +import { StateContext } from './context/global-context'; +import { ToDo } from './components/todo'; export const App: React.FC = () => { + const state = React.useContext(StateContext); + const visibleTodos = state.todos.filter(todo => { + if (state.filter === 'active') { + return !todo.completed; + } + + if (state.filter === 'completed') { + return todo.completed; + } + + return true; + }); + 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 */} - -
+
+ + {state.todos.length > 0 && ( + <> +
+ {visibleTodos.map(todo => ( + + ))} +
+ +
); diff --git a/src/components/footer.tsx b/src/components/footer.tsx new file mode 100644 index 000000000..5c953892d --- /dev/null +++ b/src/components/footer.tsx @@ -0,0 +1,48 @@ +import { useContext } from 'react'; +import { + StateContext, + FILTER_NAMES, + DispatchContext, +} from '../context/global-context'; + +export const Footer = () => { + const state = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const activeTodosCount = state.todos.filter(todo => !todo.completed).length; + const hasCompletedTodos = state.todos.some(todo => todo.completed); + + return ( + + ); +}; diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 000000000..6221e6bc1 --- /dev/null +++ b/src/components/header.tsx @@ -0,0 +1,66 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { DispatchContext, StateContext } from '../context/global-context'; + +export const Header = () => { + const dispatch = useContext(DispatchContext); + const state = useContext(StateContext); + const [title, setTitle] = useState(''); + + const inputRef = React.useRef(null); + const isAllCompleted = state.todos.every(todo => todo.completed); + + useEffect(() => { + inputRef.current?.focus(); + }, [state.todos.length]); + + const addTodo = () => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + dispatch({ + type: 'add', + payload: { + id: +new Date(), + title: trimmedTitle, + completed: false, + }, + }); + }; + + return ( +
+ {/* this button should have `active` class only if all todos are completed */} + {!!state.todos.length && ( +
+ ); +}; diff --git a/src/components/todo.tsx b/src/components/todo.tsx new file mode 100644 index 000000000..04cdfa89a --- /dev/null +++ b/src/components/todo.tsx @@ -0,0 +1,91 @@ +import { useContext, useEffect, useRef, useState } from 'react'; +import { DispatchContext, Todo } from '../context/global-context'; + +export const ToDo = ({ todo }: { todo: Todo }) => { + const dispatch = useContext(DispatchContext); + const [isEditing, setIsEditing] = useState(null); + const [title, setTitle] = useState(todo.title); + + const editFieldRef = useRef(null); + + useEffect(() => { + if (isEditing) { + editFieldRef.current?.focus(); + } + }, [isEditing]); + + const submit = (e?: React.FormEvent) => { + e?.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + dispatch({ type: 'remove', payload: { id: todo.id } }); + } else if (trimmedTitle !== todo.title) { + dispatch({ + type: 'edit', + payload: { id: todo.id, title: trimmedTitle }, + }); + } + + setIsEditing(null); + }; + + return ( +
+ + + {!isEditing ? ( + { + setIsEditing(todo); + setTitle(todo.title); + }} + > + {todo.title} + + ) : ( +
+ setTitle(e.target.value)} + onBlur={() => submit()} + onKeyUp={e => { + if (e.key === 'Escape') { + setTitle(todo.title); + setIsEditing(null); + } + }} + /> +
+ )} + {/* Remove button appears only on hover */} + {!isEditing && ( + + )} +
+ ); +}; diff --git a/src/context/global-context.tsx b/src/context/global-context.tsx new file mode 100644 index 000000000..fc0393b9f --- /dev/null +++ b/src/context/global-context.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { State } from '../types/state'; +import { FilterType } from '../types/filters'; +import { Action } from '../types/action'; + +const getInitialState = (): State => { + const saved = localStorage.getItem('todos'); + + try { + return { + todos: saved ? JSON.parse(saved) : [], + filter: 'all', + }; + } catch (e) { + return { todos: [], filter: 'all' }; + } +}; + +export const FILTER_NAMES: FilterType[] = ['all', 'active', 'completed']; + +const initialState = getInitialState(); + +const reducer = (state: State, action: Action): State => { + switch (action.type) { + case 'add': + return { + ...state, + todos: [...state.todos, action.payload], + }; + case 'remove': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload.id), + }; + case 'toggle': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, completed: !todo.completed } + : todo, + ), + }; + case 'edit': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id + ? { ...todo, title: action.payload.title } + : todo, + ), + }; + case 'setFilter': + return { + ...state, + filter: action.payload, + }; + case 'clearCompleted': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + case 'toggle_all': + return { + ...state, + todos: state.todos.map(todo => { + return { + ...todo, + completed: !state.todos.every(toDo => toDo.completed), + }; + }), + }; + default: + return state; + } +}; + +export const StateContext = React.createContext(initialState); +export const DispatchContext = React.createContext>( + () => {}, +); + +export const GlobalStateProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [state, dispatch] = React.useReducer(reducer, initialState); + + React.useEffect(() => { + localStorage.setItem('todos', JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..bdc069b7f 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 { GlobalStateProvider } from './context/global-context'; 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/action.ts b/src/types/action.ts new file mode 100644 index 000000000..f5498226a --- /dev/null +++ b/src/types/action.ts @@ -0,0 +1,11 @@ +import { FilterType } from './filters'; +import { Todo } from './todo'; + +export type Action = + | { type: 'add'; payload: Todo } + | { type: 'remove'; payload: { id: number } } + | { type: 'toggle'; payload: { id: number } } + | { type: 'edit'; payload: { id: number; title: string } } + | { type: 'setFilter'; payload: FilterType } + | { type: 'clearCompleted' } + | { type: 'toggle_all' }; diff --git a/src/types/filters.ts b/src/types/filters.ts new file mode 100644 index 000000000..30fddb0a8 --- /dev/null +++ b/src/types/filters.ts @@ -0,0 +1 @@ +export type FilterType = 'all' | 'active' | 'completed'; diff --git a/src/types/state.ts b/src/types/state.ts new file mode 100644 index 000000000..feb418b9b --- /dev/null +++ b/src/types/state.ts @@ -0,0 +1,7 @@ +import { FilterType } from './filters'; +import { Todo } from './todo'; + +export type State = { + todos: Todo[]; + filter: FilterType; +}; diff --git a/src/types/todo.ts b/src/types/todo.ts new file mode 100644 index 000000000..d94ea1bff --- /dev/null +++ b/src/types/todo.ts @@ -0,0 +1,5 @@ +export type Todo = { + id: number; + title: string; + completed: boolean; +};