diff --git a/src/App.tsx b/src/App.tsx index a399287bd..56e919b39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,8 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { Footer } from './components/Footer'; export const App: React.FC = () => { return ( @@ -7,150 +10,9 @@ export const App: React.FC = () => {

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 */} - +
+ +
); diff --git a/src/GlobalProvider.tsx b/src/GlobalProvider.tsx new file mode 100644 index 000000000..0d7e1e655 --- /dev/null +++ b/src/GlobalProvider.tsx @@ -0,0 +1,67 @@ +import { Action } from './types/Action'; +import { Filter } from './types/Filter'; +import { State } from './types/State'; +import { useEffect, useReducer } from 'react'; +import React from 'react'; + +const reducer = (state: State, action: Action): State => { + 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 'updateTodo': + return { + ...state, + todos: state.todos.map(todo => + todo.id === action.payload.id ? action.payload : todo, + ), + }; + + case 'setFilter': + return { + ...state, + filter: action.payload, + }; + + default: + return state; + } +}; + +const loadedTodos = localStorage.getItem('todos'); +const initialState: State = { + todos: loadedTodos ? JSON.parse(loadedTodos) : [], + filter: Filter.All, +}; + +export const StateContext = React.createContext(initialState); +export const DispatchContext = React.createContext>( + () => {}, +); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(state.todos)); + }, [state.todos]); + + return ( + + {children} + + ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..17c4c1cad --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,64 @@ +import { useContext, useMemo } from 'react'; +import { DispatchContext, StateContext } from '../GlobalProvider'; +import { Filter } from '../types/Filter'; +import classNames from 'classnames'; + +export const Footer = () => { + const { todos, filter } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + const activeTodosCount = useMemo( + () => todos.filter(todo => !todo.completed).length, + [todos], + ); + + const deleteCompleted = () => { + todos.forEach(todo => { + if (todo.completed) { + dispatch({ type: 'deleteTodo', payload: todo.id }); + } + }); + }; + + if (!todos.length) { + return; + } + + return ( + <> + + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..454226db5 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,92 @@ +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Todo } from '../types/Todo'; +import classNames from 'classnames'; +import { DispatchContext, StateContext } from '../GlobalProvider'; + + +export const Header = () => { + const [title, setTitle] = useState(''); + + const titleField = useRef(null); + + const { todos } = useContext(StateContext); + const dispatch = useContext(DispatchContext); + + useEffect(() => { + if (titleField.current) { + titleField.current.focus(); + } + }, [todos.length]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!title.trim()) { + return; + } + + const newTodo: Todo = { + id: +Date.now(), + title: title.trim(), + completed: false, + }; + + dispatch({ type: 'addTodo', payload: newTodo }); + setTitle(''); + }; + + const handleTitleChange = (e: React.ChangeEvent) => { + setTitle(e.target.value); + }; + + const allTodosCompleted = useMemo(() => { + return todos.filter(todo => todo.completed).length === todos.length; + }, [todos]); + + const handleToggleAllButtonClick = () => { + let todosToChange = []; + + if (allTodosCompleted) { + todosToChange = [...todos]; + } else { + todosToChange = todos.filter(todo => !todo.completed); + } + + todosToChange.forEach(todo => { + const { id, title: todoTitle, completed } = todo; + + dispatch({ + type: 'updateTodo', + payload: { id, title: todoTitle, completed: !completed }, + }); + }); + }; + + return ( +
+ {!!todos.length && ( +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..021685c1e --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,111 @@ +import classNames from 'classnames'; +import { Todo } from '../types/Todo'; +import { useContext, useState } from 'react'; +import { DispatchContext } from '../GlobalProvider'; + +type Props = { + todo: Todo; +}; + +/* eslint-disable jsx-a11y/label-has-associated-control */ +export const TodoItem: React.FC = ({ todo }) => { + const { id, title, completed } = todo; + + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(title); + + const dispatch = useContext(DispatchContext); + + const handleEditedTitleChange = (e: React.ChangeEvent) => { + setEditedTitle(e.target.value); + }; + + const saveChanges = () => { + const trimmedEditedTitle = editedTitle.trim(); + + if (!trimmedEditedTitle) { + dispatch({ type: 'deleteTodo', payload: id }); + setIsEditing(false); + + return; + } + + dispatch({ + type: 'updateTodo', + payload: { id, title: trimmedEditedTitle, completed }, + }); + setIsEditing(false); + }; + + const handleEditSubmit = (e: React.FormEvent) => { + e.preventDefault(); + saveChanges(); + }; + + const handleEscapeKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setIsEditing(false); + } + }; + + return ( + <> + {/* This is a completed todo */} +
+ + + {isEditing ? ( +
+ +
+ ) : ( + <> + setIsEditing(true)} + > + {title} + + + + + )} +
+ + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..05462a368 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,26 @@ +import { useContext, useMemo } from 'react'; +import { StateContext } from '../GlobalProvider'; +import { TodoItem } from './TodoItem'; +import { filterTodos } from '../utils/filterTodos'; + +/* eslint-disable jsx-a11y/label-has-associated-control */ +export const TodoList = () => { + const { todos, filter } = useContext(StateContext); + + const visibleTodos = useMemo( + () => filterTodos(todos, filter), + [filter, todos], + ); + + if (!todos.length) { + return null; + } + + return ( +
+ {visibleTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/index.tsx b/src/index.tsx index a9689cb38..22acad026 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,12 @@ import { createRoot } from 'react-dom/client'; - -import './styles/index.css'; -import './styles/todo-list.css'; -import './styles/filters.css'; - +import './styles/index.scss'; import { App } from './App'; +import { GlobalProvider } from './GlobalProvider'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/todo.scss b/src/styles/todo.scss index 4576af434..cfb34ec2f 100644 --- a/src/styles/todo.scss +++ b/src/styles/todo.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..d56bc7e12 --- /dev/null +++ b/src/types/Action.ts @@ -0,0 +1,8 @@ +import { Filter } from './Filter'; +import { Todo } from './Todo'; + +export type Action = + | { type: 'addTodo'; payload: Todo } + | { type: 'deleteTodo'; payload: number } + | { type: 'updateTodo'; payload: Todo } + | { type: 'setFilter'; payload: Filter }; diff --git a/src/types/Filter.ts b/src/types/Filter.ts new file mode 100644 index 000000000..66887875b --- /dev/null +++ b/src/types/Filter.ts @@ -0,0 +1,5 @@ +export enum Filter { + All = 'All', + Active = 'Active', + Completed = 'Completed', +} diff --git a/src/types/State.ts b/src/types/State.ts new file mode 100644 index 000000000..d8f8851e2 --- /dev/null +++ b/src/types/State.ts @@ -0,0 +1,7 @@ +import { Filter } from './Filter'; +import { Todo } from './Todo'; + +export interface State { + todos: Todo[]; + filter: Filter; +} 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; +} diff --git a/src/utils/filterTodos.ts b/src/utils/filterTodos.ts new file mode 100644 index 000000000..7658b1668 --- /dev/null +++ b/src/utils/filterTodos.ts @@ -0,0 +1,15 @@ +import { Filter } from '../types/Filter'; +import { Todo } from '../types/Todo'; + +export const filterTodos = (todos: Todo[], filterBy: Filter): Todo[] => { + switch (filterBy) { + case Filter.Completed: + return todos.filter(todo => todo.completed); + + case Filter.Active: + return todos.filter(todo => !todo.completed); + + default: + return todos; + } +};