diff --git a/src/App.tsx b/src/App.tsx index a399287bd..2f0e68e32 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 { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { useTodoContext } from './hooks/useTodoContext'; + export const App: React.FC = () => { + const { + state: { todos }, + } = useTodoContext(); + 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.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..14cbe3334 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,69 @@ +import React from 'react'; + +import { Filter } from '../enums/Filter'; +import { useTodoContext } from '../hooks/useTodoContext'; + +export const Footer: React.FC = () => { + const { + state: { todos, filter }, + dispatch, + } = useTodoContext(); + + const activeTodosCount = todos.filter(todo => !todo.completed).length; + const completedTodosCount = todos.filter(todo => todo.completed).length; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..b6d95841d --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,72 @@ +import React, { + FormEvent, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +import { TodoContext } from '../context/TodoContext'; + +export const Header: React.FC = () => { + const { + state: { todos }, + dispatch, + } = useContext(TodoContext); + + const [title, setTitle] = useState(''); + + const newTodoFieldRef = useRef(null); + + const allCompleted = todos.length > 0 && todos.every(todo => todo.completed); + + useEffect(() => { + newTodoFieldRef.current?.focus(); + }, [todos]); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + const trimmedTitle = title.trim(); + + if (!trimmedTitle) { + return; + } + + dispatch({ + type: 'addTodo', + payload: { + id: +new Date(), + title: trimmedTitle, + completed: false, + }, + }); + + setTitle(''); + }; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..bf91141fe --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; + +import { useTodoContext } from '../hooks/useTodoContext'; +import { Todo } from '../types/Todo'; + +type Props = { + todo: Todo; +}; + +export const TodoItem: React.FC = ({ todo }) => { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(todo.title); + + const { dispatch } = useTodoContext(); + + const saveTodo = () => { + const preparedTitle = title.trim(); + + if (!preparedTitle) { + dispatch({ type: 'deleteTodo', payload: todo.id }); + + return; + } + + dispatch({ + type: 'updateTodo', + payload: { + id: todo.id, + title: preparedTitle, + }, + }); + + setIsEditing(false); + }; + + const cancelEditing = () => { + setTitle(todo.title); + setIsEditing(false); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + saveTodo(); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + cancelEditing(); + } + }; + + return ( +
+ + + {isEditing ? ( +
+ setTitle(event.target.value)} + onBlur={saveTodo} + onKeyUp={handleKeyUp} + autoFocus + /> +
+ ) : ( + <> + setIsEditing(true)} + > + {todo.title} + + + + + )} +
+ ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..922849230 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { Filter } from '../enums/Filter'; +import { useTodoContext } from '../hooks/useTodoContext'; +import { TodoItem } from './TodoItem'; + +export const TodoList: React.FC = () => { + const { + state: { todos, filter }, + } = useTodoContext(); + + const visibleTodos = todos.filter(todo => { + if (filter === Filter.Active) { + return !todo.completed; + } + + if (filter === Filter.Completed) { + return todo.completed; + } + + return true; + }); + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..11dc51a01 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,46 @@ +import React, { createContext, useEffect, useReducer } from 'react'; + +import { Filter } from '../enums/Filter'; +import { Action, reducer, RootState } from '../store/reducer'; + +const STORAGE_KEY = 'todos'; + +type ContextType = { + state: RootState; + dispatch: React.Dispatch; +}; + +export const TodoContext = createContext({} as ContextType); + +type ProviderProps = { + children: React.ReactNode; +}; + +const getInitialTodos = () => { + const todosFromStorage = localStorage.getItem(STORAGE_KEY); + + if (!todosFromStorage) { + return []; + } + + return JSON.parse(todosFromStorage); +}; + +const initialState: RootState = { + todos: getInitialTodos(), + filter: Filter.All, +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; diff --git a/src/enums/Filter.ts b/src/enums/Filter.ts new file mode 100644 index 000000000..174408fd6 --- /dev/null +++ b/src/enums/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'all', + Active = 'active', + Completed = 'completed', +} diff --git a/src/hooks/useTodoContext.ts b/src/hooks/useTodoContext.ts new file mode 100644 index 000000000..ded89fb91 --- /dev/null +++ b/src/hooks/useTodoContext.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; + +import { TodoContext } from '../context/TodoContext'; + +export const useTodoContext = () => { + return useContext(TodoContext); +}; 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/store/reducer.ts b/src/store/reducer.ts new file mode 100644 index 000000000..359c1cd7d --- /dev/null +++ b/src/store/reducer.ts @@ -0,0 +1,79 @@ +import { Filter } from '../enums/Filter'; +import { Todo } from '../types/Todo'; + +export type RootState = { + todos: Todo[]; + filter: Filter; +}; + +export type Action = + | { type: 'addTodo'; payload: Todo } + | { type: 'deleteTodo'; payload: number } + | { type: 'toggleTodo'; payload: number } + | { type: 'toggleAll' } + | { type: 'clearCompleted' } + | { type: 'updateTodo'; payload: { id: number; title: string } } + | { type: 'setFilter'; payload: Filter }; + +export const reducer = (state: RootState, action: Action): RootState => { + switch (action.type) { + case 'addTodo': + return { + ...state, + todos: [...state.todos, action.payload], + }; + + case 'deleteTodo': + return { + ...state, + todos: state.todos.filter(todo => todo.id !== action.payload), + }; + + case 'toggleTodo': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload + ? { ...todo, completed: !todo.completed } + : todo, + ), + }; + + case 'toggleAll': { + const allCompleted = state.todos.every(todo => todo.completed); + + return { + ...state, + todos: state.todos.map(todo => ({ + ...todo, + completed: !allCompleted, + })), + }; + } + + case 'clearCompleted': + return { + ...state, + todos: state.todos.filter(todo => !todo.completed), + }; + + case 'updateTodo': + 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, + }; + + default: + return state; + } +}; 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; +};