diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..532192b08 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,156 +1,19 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+import { useTodoState } from './hooks/useTodoState';
+import { Header } from './components/Header';
+import { TodoList } from './components/TodoList';
+import { Footer } from './components/Footer';
+
+export const App = () => {
+ const { hasTodos } = useTodoState();
-export const App: React.FC = () => {
return (
todos
-
- {/* this button should have `active` class only if all todos are completed */}
-
-
- {/* Add a todo on form submit */}
-
-
-
-
-
- {/* Hide the footer if there are no todos */}
-
-
- 3 items left
-
-
- {/* Active link should have the 'selected' class */}
-
-
- All
-
-
-
- Active
-
-
-
- Completed
-
-
-
- {/* this button should be disabled if there are no completed todos */}
-
- Clear completed
-
-
+
+
+ {hasTodos &&
}
);
diff --git a/src/components/CreateTodoForm/CreateTodoForm.tsx b/src/components/CreateTodoForm/CreateTodoForm.tsx
new file mode 100644
index 000000000..6e6d3edfb
--- /dev/null
+++ b/src/components/CreateTodoForm/CreateTodoForm.tsx
@@ -0,0 +1,36 @@
+import { useState, FormEvent } from 'react';
+import { useTodoActions } from '../../hooks/useTodoActions';
+import { useInputRef } from '../../hooks/useInputRef';
+
+export const CreateTodoForm = () => {
+ const [title, setTitle] = useState('');
+ const { addTodo } = useTodoActions();
+ const inputRef = useInputRef();
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ const normalizedTitle = title.trim();
+
+ if (!normalizedTitle) {
+ return;
+ }
+
+ addTodo(normalizedTitle);
+ setTitle('');
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/CreateTodoForm/index.ts b/src/components/CreateTodoForm/index.ts
new file mode 100644
index 000000000..4a1b58b33
--- /dev/null
+++ b/src/components/CreateTodoForm/index.ts
@@ -0,0 +1 @@
+export * from './CreateTodoForm';
diff --git a/src/components/EditTodoForm/EditTodoForm.tsx b/src/components/EditTodoForm/EditTodoForm.tsx
new file mode 100644
index 000000000..f3249cf20
--- /dev/null
+++ b/src/components/EditTodoForm/EditTodoForm.tsx
@@ -0,0 +1,45 @@
+import { FormEvent, KeyboardEvent, useState } from 'react';
+
+interface Props {
+ title: string;
+ onChange: (title: string) => void;
+ onClose: () => void;
+}
+
+export const EditTodoForm = ({ title, onChange, onClose }: Props) => {
+ const [newTitle, setNewTitle] = useState(title);
+
+ const handleEscKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onClose();
+ }
+ };
+
+ const handleChangeTitle = () => {
+ const normalizedTitle = newTitle.trim();
+
+ onChange(normalizedTitle);
+ onClose();
+ };
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ handleChangeTitle();
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/EditTodoForm/index.ts b/src/components/EditTodoForm/index.ts
new file mode 100644
index 000000000..b7360e67f
--- /dev/null
+++ b/src/components/EditTodoForm/index.ts
@@ -0,0 +1 @@
+export * from './EditTodoForm';
diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx
new file mode 100644
index 000000000..10c1c31ab
--- /dev/null
+++ b/src/components/Footer/Footer.tsx
@@ -0,0 +1,71 @@
+import classNames from 'classnames';
+import { FilterOption } from '../../types/FilterOption';
+import { useTodoActions } from '../../hooks/useTodoActions';
+import { useTodoState } from '../../hooks/useTodoState';
+import { useFocusInput } from '../../hooks/useFocusInput';
+
+export const Footer = () => {
+ const { filter, activeTodosCount, completedTodosCount } = useTodoState();
+ const { setFilter, removeCompleted } = useTodoActions();
+ const focusInput = useFocusInput();
+
+ const hasCompletedTodos = completedTodosCount > 0;
+
+ const handleRemoveCompleted = () => {
+ removeCompleted();
+ focusInput();
+ };
+
+ return (
+
+ );
+};
diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts
new file mode 100644
index 000000000..ddcc5a9cd
--- /dev/null
+++ b/src/components/Footer/index.ts
@@ -0,0 +1 @@
+export * from './Footer';
diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx
new file mode 100644
index 000000000..e19f6fa1a
--- /dev/null
+++ b/src/components/Header/Header.tsx
@@ -0,0 +1,34 @@
+import classNames from 'classnames';
+import { useTodoState } from '../../hooks/useTodoState';
+import { useTodoActions } from '../../hooks/useTodoActions';
+import { useFocusInput } from '../../hooks/useFocusInput';
+import { CreateTodoForm } from '../CreateTodoForm';
+
+export const Header = () => {
+ const { hasTodos, allTodosCompleted } = useTodoState();
+ const { toggleAll } = useTodoActions();
+
+ const focusInput = useFocusInput();
+
+ const handleToggleAll = () => {
+ toggleAll();
+ focusInput();
+ };
+
+ return (
+
+ {hasTodos && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts
new file mode 100644
index 000000000..266dec8a1
--- /dev/null
+++ b/src/components/Header/index.ts
@@ -0,0 +1 @@
+export * from './Header';
diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx
new file mode 100644
index 000000000..b6cf9f7b1
--- /dev/null
+++ b/src/components/TodoList/TodoList.tsx
@@ -0,0 +1,14 @@
+import { useTodoState } from '../../hooks/useTodoState';
+import { TodoListItem } from '../TodoListItem';
+
+export const TodoList = () => {
+ const { filteredTodos } = useTodoState();
+
+ return (
+
+ {filteredTodos.map(todo => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts
new file mode 100644
index 000000000..f239f4345
--- /dev/null
+++ b/src/components/TodoList/index.ts
@@ -0,0 +1 @@
+export * from './TodoList';
diff --git a/src/components/TodoListItem/TodoListItem.tsx b/src/components/TodoListItem/TodoListItem.tsx
new file mode 100644
index 000000000..85f99bb70
--- /dev/null
+++ b/src/components/TodoListItem/TodoListItem.tsx
@@ -0,0 +1,79 @@
+/* eslint-disable jsx-a11y/control-has-associated-label */
+/* eslint-disable jsx-a11y/label-has-associated-control */
+import { useState } from 'react';
+import classNames from 'classnames';
+import { useTodoActions } from '../../hooks/useTodoActions';
+import { useFocusInput } from '../../hooks/useFocusInput';
+import { EditTodoForm } from '../EditTodoForm';
+import { Todo } from '../../types/Todo';
+
+interface Props {
+ todo: Todo;
+}
+
+export const TodoListItem = ({ todo }: Props) => {
+ const [isEditing, setIsEditing] = useState(false);
+ const { removeTodo, toggleTodo, editTodo } = useTodoActions();
+ const focusInput = useFocusInput();
+
+ const { id, completed, title } = todo;
+
+ const handleToggle = () => {
+ toggleTodo(id);
+ focusInput();
+ };
+
+ const handleRemove = () => {
+ removeTodo(id);
+ focusInput();
+ };
+
+ const handleEdit = (newTitle: string) => {
+ if (!newTitle) {
+ handleRemove();
+ } else {
+ editTodo(id, newTitle);
+ focusInput();
+ }
+ };
+
+ return (
+
+
+
+
+
+ {isEditing ? (
+ setIsEditing(false)}
+ />
+ ) : (
+ <>
+ setIsEditing(true)}
+ >
+ {title}
+
+
+ ×
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/TodoListItem/index.ts b/src/components/TodoListItem/index.ts
new file mode 100644
index 000000000..5ed5555f5
--- /dev/null
+++ b/src/components/TodoListItem/index.ts
@@ -0,0 +1 @@
+export * from './TodoListItem';
diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx
new file mode 100644
index 000000000..68d5e90b2
--- /dev/null
+++ b/src/context/TodoContext.tsx
@@ -0,0 +1,85 @@
+import {
+ createContext,
+ useReducer,
+ useRef,
+ useEffect,
+ useMemo,
+ ReactNode,
+ RefObject,
+ Dispatch,
+} from 'react';
+import { Action, todoReducer } from './todoReducer';
+import { initState } from './initState';
+import { filterTodos } from '../utils/filterTodos';
+import { Todo } from '../types/Todo';
+import { FilterOption } from '../types/FilterOption';
+
+type TodoState = {
+ todos: Todo[];
+ filteredTodos: Todo[];
+ filter: FilterOption;
+ hasTodos: boolean;
+ activeTodosCount: number;
+ completedTodosCount: number;
+ allTodosCompleted: boolean;
+};
+
+export const TodoStateContext = createContext(null);
+export const TodoDispatchContext = createContext | null>(null);
+export const TodoInputRefContext =
+ createContext | null>(null);
+
+interface Props {
+ children: ReactNode;
+}
+
+export const TodoProvider = ({ children }: Props) => {
+ const [state, dispatch] = useReducer(todoReducer, {}, initState);
+
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(state.todos));
+ }, [state.todos]);
+
+ const activeTodosCount = useMemo(
+ () => filterTodos(state.todos, FilterOption.ACTIVE).length,
+ [state.todos],
+ );
+
+ const completedTodosCount = state.todos.length - activeTodosCount;
+
+ const filteredTodos = useMemo(
+ () => filterTodos(state.todos, state.filter),
+ [state.todos, state.filter],
+ );
+
+ const stateValue = useMemo(
+ () => ({
+ todos: state.todos,
+ filteredTodos,
+ filter: state.filter,
+ hasTodos: state.todos.length > 0,
+ completedTodosCount,
+ activeTodosCount,
+ allTodosCompleted: state.todos.length > 0 && activeTodosCount === 0,
+ }),
+ [
+ state.todos,
+ state.filter,
+ filteredTodos,
+ completedTodosCount,
+ activeTodosCount,
+ ],
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/src/context/initState.ts b/src/context/initState.ts
new file mode 100644
index 000000000..02a0af84e
--- /dev/null
+++ b/src/context/initState.ts
@@ -0,0 +1,16 @@
+import { FilterOption } from '../types/FilterOption';
+import { State } from './todoReducer';
+
+export const initState = (): State => {
+ try {
+ return {
+ todos: JSON.parse(localStorage.getItem('todos') || '[]'),
+ filter: FilterOption.ALL,
+ };
+ } catch {
+ return {
+ todos: [],
+ filter: FilterOption.ALL,
+ };
+ }
+};
diff --git a/src/context/todoReducer.ts b/src/context/todoReducer.ts
new file mode 100644
index 000000000..a16becfd7
--- /dev/null
+++ b/src/context/todoReducer.ts
@@ -0,0 +1,89 @@
+import { Todo } from '../types/Todo';
+import { FilterOption } from '../types/FilterOption';
+
+export interface State {
+ todos: Todo[];
+ filter: FilterOption;
+}
+
+export type Action =
+ | { type: 'add'; payload: string }
+ | { type: 'edit'; payload: { id: Todo['id']; title: string } }
+ | { type: 'toggle'; payload: { id: Todo['id'] } }
+ | { type: 'toggleAll' }
+ | { type: 'remove'; payload: Todo['id'] }
+ | { type: 'removeCompleted' }
+ | { type: 'setFilter'; payload: FilterOption };
+
+export const todoReducer = (state: State, action: Action) => {
+ switch (action.type) {
+ case 'add': {
+ const newTodo = {
+ id: +new Date(),
+ title: action.payload,
+ completed: false,
+ };
+
+ return {
+ ...state,
+ todos: [...state.todos, newTodo],
+ };
+ }
+
+ case 'remove': {
+ return {
+ ...state,
+ todos: state.todos.filter(todo => todo.id !== action.payload),
+ };
+ }
+
+ case 'removeCompleted': {
+ return {
+ ...state,
+ todos: state.todos.filter(todo => !todo.completed),
+ };
+ }
+
+ case 'edit': {
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload.id
+ ? { ...todo, title: action.payload.title }
+ : todo,
+ ),
+ };
+ }
+
+ case 'toggle': {
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload.id
+ ? { ...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 'setFilter': {
+ return {
+ ...state,
+ filter: action.payload,
+ };
+ }
+
+ default: {
+ return state;
+ }
+ }
+};
diff --git a/src/hooks/useFocusInput.ts b/src/hooks/useFocusInput.ts
new file mode 100644
index 000000000..f6e309217
--- /dev/null
+++ b/src/hooks/useFocusInput.ts
@@ -0,0 +1,7 @@
+import { useInputRef } from './useInputRef';
+
+export const useFocusInput = () => {
+ const inputRef = useInputRef();
+
+ return () => inputRef.current?.focus();
+};
diff --git a/src/hooks/useInputRef.ts b/src/hooks/useInputRef.ts
new file mode 100644
index 000000000..b59e41aab
--- /dev/null
+++ b/src/hooks/useInputRef.ts
@@ -0,0 +1,12 @@
+import { useContext } from 'react';
+import { TodoInputRefContext } from '../context/TodoContext';
+
+export const useInputRef = () => {
+ const inputRef = useContext(TodoInputRefContext);
+
+ if (!inputRef) {
+ throw new Error('useInputRef must be used within TodoProvider');
+ }
+
+ return inputRef;
+};
diff --git a/src/hooks/useTodoActions.ts b/src/hooks/useTodoActions.ts
new file mode 100644
index 000000000..2a1911393
--- /dev/null
+++ b/src/hooks/useTodoActions.ts
@@ -0,0 +1,56 @@
+import { useCallback, useContext } from 'react';
+import { TodoDispatchContext } from '../context/TodoContext';
+import { Todo } from '../types/Todo';
+import { FilterOption } from '../types/FilterOption';
+
+export const useTodoActions = () => {
+ const dispatch = useContext(TodoDispatchContext);
+
+ if (!dispatch) {
+ throw new Error('useTodoActions must be used within TodoProvider');
+ }
+
+ return {
+ addTodo: useCallback(
+ (title: string) => {
+ dispatch({ type: 'add', payload: title });
+ },
+ [dispatch],
+ ),
+
+ editTodo: useCallback(
+ (id: Todo['id'], title: string) => {
+ dispatch({ type: 'edit', payload: { id, title } });
+ },
+ [dispatch],
+ ),
+
+ toggleTodo: useCallback(
+ (id: Todo['id']) => {
+ dispatch({ type: 'toggle', payload: { id } });
+ },
+ [dispatch],
+ ),
+
+ toggleAll: useCallback(() => {
+ dispatch({ type: 'toggleAll' });
+ }, [dispatch]),
+
+ removeTodo: useCallback(
+ (id: Todo['id']) => {
+ dispatch({ type: 'remove', payload: id });
+ },
+ [dispatch],
+ ),
+
+ removeCompleted: useCallback(() => {
+ dispatch({ type: 'removeCompleted' });
+ }, [dispatch]),
+
+ setFilter: useCallback(
+ (filter: FilterOption) =>
+ dispatch({ type: 'setFilter', payload: filter }),
+ [dispatch],
+ ),
+ };
+};
diff --git a/src/hooks/useTodoState.ts b/src/hooks/useTodoState.ts
new file mode 100644
index 000000000..bf60bc710
--- /dev/null
+++ b/src/hooks/useTodoState.ts
@@ -0,0 +1,12 @@
+import { useContext } from 'react';
+import { TodoStateContext } from '../context/TodoContext';
+
+export const useTodoState = () => {
+ const state = useContext(TodoStateContext);
+
+ if (!state) {
+ throw new Error('useTodoState must be used within TodoProvider');
+ }
+
+ return state;
+};
diff --git a/src/index.tsx b/src/index.tsx
index b2c38a17a..ce5f76d5a 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,9 +1,12 @@
import { createRoot } from 'react-dom/client';
-
-import './styles/index.scss';
-
+import { TodoProvider } from './context/TodoContext';
import { App } from './App';
+import './styles/index.scss';
const container = document.getElementById('root') as HTMLDivElement;
-createRoot(container).render( );
+createRoot(container).render(
+
+
+ ,
+);
diff --git a/src/styles/index.scss b/src/styles/index.scss
index d8d324941..58906a919 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -2,6 +2,12 @@ iframe {
display: none;
}
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
body {
min-width: 230px;
max-width: 550px;
diff --git a/src/types/FilterOption.ts b/src/types/FilterOption.ts
new file mode 100644
index 000000000..16f1d948d
--- /dev/null
+++ b/src/types/FilterOption.ts
@@ -0,0 +1,5 @@
+export enum FilterOption {
+ ALL = 'all',
+ ACTIVE = 'active',
+ COMPLETED = '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;
+}
diff --git a/src/utils/filterTodos.ts b/src/utils/filterTodos.ts
new file mode 100644
index 000000000..f804a9801
--- /dev/null
+++ b/src/utils/filterTodos.ts
@@ -0,0 +1,15 @@
+import { Todo } from '../types/Todo';
+import { FilterOption } from '../types/FilterOption';
+
+export const filterTodos = (todos: Todo[], filter: FilterOption) => {
+ switch (filter) {
+ case FilterOption.COMPLETED:
+ return todos.filter(todo => todo.completed);
+
+ case FilterOption.ACTIVE:
+ return todos.filter(todo => !todo.completed);
+
+ default:
+ return todos;
+ }
+};