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 */} -
- -
- {/* 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 */} - -
+
+ + {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 ( +
+ setTitle(event.target.value)} + /> +
+ ); +}; 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 ( +
+ setNewTitle(event.target.value)} + onBlur={handleChangeTitle} + onKeyDown={handleEscKeyDown} + /> +
+ ); +}; 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; + } +};