diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..feb441ef5 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,157 +1,8 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
-
-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
-
-
-
-
- );
-};
+import { Todos } from './components/Todo/Todos';
+import { TodosProvider } from './context/TodoContext';
+
+export const App = () => (
+
+
+
+);
diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx
new file mode 100644
index 000000000..41d53e745
--- /dev/null
+++ b/src/components/ErrorNotification/ErrorNotification.tsx
@@ -0,0 +1,20 @@
+import { FC } from 'react';
+import { getTodoErrorsMessage } from '../../utils/todos/getTodoErrorsMessage';
+import cn from 'classnames';
+import { TodoErrors } from '../../utils/enums/TodoErrors';
+
+interface ErrorNotificationProps {
+ error: TodoErrors | null;
+}
+
+export const ErrorNotification: FC = ({ error }) => (
+
+
+ {error && getTodoErrorsMessage(error)}
+
+);
diff --git a/src/components/Todo/TodoFooter/TodoFooter.tsx b/src/components/Todo/TodoFooter/TodoFooter.tsx
new file mode 100644
index 000000000..6a02e2eea
--- /dev/null
+++ b/src/components/Todo/TodoFooter/TodoFooter.tsx
@@ -0,0 +1,61 @@
+import { Dispatch, FC, SetStateAction } from 'react';
+import cn from 'classnames';
+
+import { Todo } from '../../../types/Todo';
+import { TODO_FILTER_OPTIONS } from '../../../constants/TodoFilter';
+
+import { FilterStatuses } from '../../../utils/enums/FilterStatuses';
+import {
+ getInCompletedTodos,
+ hasCompletedTodos,
+} from '../../../utils/todos/getTodos';
+import { useDeleteTodo } from '../../../hooks/useDeleteTodo';
+
+interface TodoFooterProps {
+ todos: Todo[];
+ setStatus: Dispatch>;
+ status: FilterStatuses;
+}
+
+export const TodoFooter: FC = ({
+ todos,
+ setStatus,
+ status,
+}) => {
+ const isCompletedTodoCounter = getInCompletedTodos(todos).length;
+ const { handleDeleteCompletedTodos } = useDeleteTodo();
+
+ return (
+
+
+ {isCompletedTodoCounter}
+ {isCompletedTodoCounter === 1 ? ' item ' : ' items '}
+ left
+
+
+
+ {TODO_FILTER_OPTIONS.map(({ value, title, href, id }) => (
+ setStatus(value)}
+ >
+ {title}
+
+ ))}
+
+
+
+ Clear completed
+
+
+ );
+};
diff --git a/src/components/Todo/TodoForm/TodoForm.tsx b/src/components/Todo/TodoForm/TodoForm.tsx
new file mode 100644
index 000000000..2745fe7ae
--- /dev/null
+++ b/src/components/Todo/TodoForm/TodoForm.tsx
@@ -0,0 +1,25 @@
+import { useContext } from 'react';
+
+import { TodosContext } from '../../../context/TodoContext';
+import { useTodoFormManager } from '../../../hooks/useTodoFormManager';
+
+export const TodoForm = () => {
+ const { inputRef } = useContext(TodosContext);
+ const { title, handleSubmit, handleChangeTitle, isInputDisabled } =
+ useTodoFormManager();
+
+ return (
+
+ );
+};
diff --git a/src/components/Todo/TodoHeader/TodoHeader.tsx b/src/components/Todo/TodoHeader/TodoHeader.tsx
new file mode 100644
index 000000000..a5b374afa
--- /dev/null
+++ b/src/components/Todo/TodoHeader/TodoHeader.tsx
@@ -0,0 +1,32 @@
+import { FC } from 'react';
+import cn from 'classnames';
+
+import { Todo } from '../../../types/Todo';
+import { isAllTodosCompleted } from '../../../utils/todos/getTodos';
+import { TodoForm } from '../TodoForm/TodoForm';
+import { useTodoFormManager } from '../../../hooks/useTodoFormManager';
+
+interface TodoHeaderProps {
+ todos: Todo[];
+}
+
+export const TodoHeader: FC = ({ todos }) => {
+ const { handleToogleAllTodoStatus } = useTodoFormManager();
+
+ return (
+
+ {!!todos.length && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Todo/TodoItem/TodoItem.tsx b/src/components/Todo/TodoItem/TodoItem.tsx
new file mode 100644
index 000000000..d194e8b2d
--- /dev/null
+++ b/src/components/Todo/TodoItem/TodoItem.tsx
@@ -0,0 +1,98 @@
+import { FC, FormEvent } from 'react';
+import { Todo } from '../../../types/Todo';
+import cn from 'classnames';
+import { useDeleteTodo } from '../../../hooks/useDeleteTodo';
+import { useTodoFormManager } from '../../../hooks/useTodoFormManager';
+import { useSelectedTodo } from '../../../hooks/useSelectedTodo';
+
+interface TodoItemProps {
+ todo: Todo;
+ isLoading?: boolean;
+}
+
+export const TodoItem: FC = ({ todo, isLoading = false }) => {
+ const { completed, id, title } = todo;
+ const { isDeleting, handleDeleteTodo } = useDeleteTodo();
+ const { selectedTodo, setSelectedTodo } = useSelectedTodo();
+ const {
+ title: updatingTitle,
+ isUpdating,
+ setTitle,
+ handleUpdateTodo,
+ } = useTodoFormManager(todo.title);
+
+ const handleSubmit = async (event: FormEvent) => {
+ event.preventDefault();
+
+ if (title == updatingTitle) {
+ return setSelectedTodo(null);
+ }
+
+ const res = handleUpdateTodo({ ...todo, title: updatingTitle });
+
+ if (res) {
+ setSelectedTodo(null);
+ }
+ };
+
+ return (
+
+ {/* eslint-disable jsx-a11y/label-has-associated-control */}
+
+ {
+ handleUpdateTodo({ ...todo, completed: !todo.completed });
+ }}
+ />
+
+
+ {selectedTodo ? (
+
+ ) : (
+
{
+ setSelectedTodo(todo);
+ }}
+ >
+ {title.trim()}
+
+ )}
+ {!selectedTodo && (
+
handleDeleteTodo(id)}
+ >
+ ×
+
+ )}
+
+
+
+ );
+};
diff --git a/src/components/Todo/TodoList/TodoList.tsx b/src/components/Todo/TodoList/TodoList.tsx
new file mode 100644
index 000000000..9e6eac322
--- /dev/null
+++ b/src/components/Todo/TodoList/TodoList.tsx
@@ -0,0 +1,15 @@
+import { FC } from 'react';
+import { Todo } from '../../../types/Todo';
+import { TodoItem } from '../TodoItem/TodoItem';
+
+interface TodoListProps {
+ todos: Todo[];
+}
+
+export const TodoList: FC = ({ todos }) => (
+
+ {todos.map(todo => (
+
+ ))}
+
+);
diff --git a/src/components/Todo/Todos.tsx b/src/components/Todo/Todos.tsx
new file mode 100644
index 000000000..473cad9d5
--- /dev/null
+++ b/src/components/Todo/Todos.tsx
@@ -0,0 +1,35 @@
+import { useContext } from 'react';
+
+import { TodoHeader } from './TodoHeader/TodoHeader';
+import { TodoList } from './TodoList/TodoList';
+import { TodoFooter } from './TodoFooter/TodoFooter';
+import { ErrorNotification } from '../ErrorNotification/ErrorNotification';
+import { TodosContext } from '../../context/TodoContext';
+import { useTodoFilter } from '../../hooks/useTodoFilter';
+
+export const Todos = () => {
+ const { todos, error } = useContext(TodosContext);
+ const { filtredTodos, setTodoStatus, todoStatus } = useTodoFilter(todos);
+
+ return (
+
+
todos
+
+
+
+
+
+
+ {!!todos.length && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/constants/TodoFilter.ts b/src/constants/TodoFilter.ts
new file mode 100644
index 000000000..6f0c78d15
--- /dev/null
+++ b/src/constants/TodoFilter.ts
@@ -0,0 +1,12 @@
+import { FilterStatuses } from '../utils/enums/FilterStatuses';
+
+export const TODO_FILTER_OPTIONS = [
+ { value: FilterStatuses.All, title: 'All', href: '#/', id: 1 },
+ { value: FilterStatuses.Active, title: 'Active', href: '#/active', id: 2 },
+ {
+ value: FilterStatuses.Completed,
+ title: 'Completed',
+ href: '#/completed',
+ id: 3,
+ },
+];
diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx
new file mode 100644
index 000000000..a8ee015cc
--- /dev/null
+++ b/src/context/TodoContext.tsx
@@ -0,0 +1,88 @@
+import { createContext, ReactNode, useMemo, RefObject } from 'react';
+import { TodoErrors } from '../utils/enums/TodoErrors';
+import { Todo } from '../types/Todo';
+import { useTodoInput } from '../hooks/useTodoInput';
+import { useTodoErrors } from '../hooks/useTodoErrors';
+import { useTodos } from '../hooks/useTodos';
+
+interface ITodosContext {
+ todos: Todo[];
+ error: TodoErrors | null;
+ inputRef: RefObject | null;
+
+ onFocus: () => void;
+ fetchTodos: () => void;
+ addTodo: (title: string) => Todo | void;
+ deleteTodo: (todoId: string) => string | void;
+ deleteCompletedTodos: () => void;
+ updateTodo: (todo: Todo) => Todo | void;
+ updatedAllTodo: () => void;
+ showError: (err: TodoErrors) => void;
+}
+
+export const TodosContext = createContext({
+ todos: [],
+ error: null,
+ inputRef: null,
+
+ fetchTodos: () => {},
+ addTodo: () => {},
+ deleteTodo: () => {},
+ deleteCompletedTodos: () => {},
+ updateTodo: () => {},
+ updatedAllTodo: () => {},
+ showError: () => {},
+ onFocus: () => {},
+});
+
+export const TodosProvider = ({
+ children,
+}: {
+ children: ReactNode;
+}): ReactNode => {
+ const { error, showError } = useTodoErrors();
+ const { inputRef, onFocus } = useTodoInput();
+ const {
+ todos,
+ fetchTodos,
+ addTodo,
+ deleteTodo,
+ deleteCompletedTodos,
+ updateTodo,
+ updatedAllTodo,
+ } = useTodos();
+
+ const store = useMemo(
+ () => ({
+ todos,
+ error,
+ inputRef,
+
+ fetchTodos,
+ addTodo,
+ deleteTodo,
+ showError,
+ deleteCompletedTodos,
+ updateTodo,
+ updatedAllTodo,
+ onFocus,
+ }),
+ [
+ todos,
+ error,
+ inputRef,
+ fetchTodos,
+ addTodo,
+ deleteTodo,
+ showError,
+ deleteCompletedTodos,
+ updateTodo,
+ updatedAllTodo,
+ onFocus,
+ ],
+ );
+
+ return (
+ {children}
+ );
+};
diff --git a/src/hooks/useDeleteTodo.ts b/src/hooks/useDeleteTodo.ts
new file mode 100644
index 000000000..8b8971760
--- /dev/null
+++ b/src/hooks/useDeleteTodo.ts
@@ -0,0 +1,26 @@
+import { useContext, useState } from 'react';
+
+import { TodosContext } from '../context/TodoContext';
+
+export const useDeleteTodo = () => {
+ const [isDeleting, setDeleting] = useState(false);
+ const { deleteCompletedTodos, onFocus, deleteTodo } =
+ useContext(TodosContext);
+
+ const handleDeleteTodo = (id: string) => {
+ setDeleting(true);
+
+ deleteTodo(id);
+
+ setDeleting(false);
+ onFocus();
+ };
+
+ const handleDeleteCompletedTodos = () => {
+ deleteCompletedTodos();
+
+ onFocus();
+ };
+
+ return { handleDeleteTodo, isDeleting, handleDeleteCompletedTodos };
+};
diff --git a/src/hooks/useLocaLStorage.ts b/src/hooks/useLocaLStorage.ts
new file mode 100644
index 000000000..e56b4c042
--- /dev/null
+++ b/src/hooks/useLocaLStorage.ts
@@ -0,0 +1,27 @@
+interface IUseLocalStorage {
+ setItem: (value: unknown) => void;
+ getItem: () => unknown;
+ removeItem: () => void;
+}
+
+const useLocaLStorage = (key: string): IUseLocalStorage => {
+ const setItem = (value: unknown): void => {
+ if (typeof value === 'string') {
+ localStorage.setItem(key, value);
+ } else {
+ localStorage.setItem(key, JSON.stringify(value));
+ }
+ };
+
+ const getItem = (): unknown => {
+ const item = localStorage.getItem(key);
+
+ return item ? JSON.parse(item) : null;
+ };
+
+ const removeItem = (): void => localStorage.removeItem(key);
+
+ return { setItem, getItem, removeItem };
+};
+
+export default useLocaLStorage;
diff --git a/src/hooks/useSelectedTodo.ts b/src/hooks/useSelectedTodo.ts
new file mode 100644
index 000000000..8de40fe4d
--- /dev/null
+++ b/src/hooks/useSelectedTodo.ts
@@ -0,0 +1,25 @@
+import { useCallback, useLayoutEffect, useState } from 'react';
+import { Todo } from '../types/Todo';
+
+export const useSelectedTodo = () => {
+ const [selectedTodo, setSelectedTodo] = useState(null);
+
+ const handleKeyPress = useCallback(
+ (event: KeyboardEvent) => {
+ if (event.key === 'Escape' && selectedTodo) {
+ setSelectedTodo(null);
+ }
+ },
+ [selectedTodo],
+ );
+
+ useLayoutEffect(() => {
+ window.addEventListener('keydown', handleKeyPress);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyPress);
+ };
+ }, [selectedTodo, handleKeyPress]);
+
+ return { selectedTodo, setSelectedTodo };
+};
diff --git a/src/hooks/useTodoErrors.ts b/src/hooks/useTodoErrors.ts
new file mode 100644
index 000000000..a698a4ff8
--- /dev/null
+++ b/src/hooks/useTodoErrors.ts
@@ -0,0 +1,13 @@
+import { useState } from 'react';
+import { TodoErrors } from '../utils/enums/TodoErrors';
+
+export const useTodoErrors = () => {
+ const [error, setError] = useState(null);
+
+ const showError = (err: TodoErrors) => {
+ setError(err);
+ setTimeout(() => setError(null), 3000);
+ };
+
+ return { error, showError };
+};
diff --git a/src/hooks/useTodoFilter.ts b/src/hooks/useTodoFilter.ts
new file mode 100644
index 000000000..fb5b5b664
--- /dev/null
+++ b/src/hooks/useTodoFilter.ts
@@ -0,0 +1,17 @@
+import { useMemo, useState } from 'react';
+import { FilterStatuses } from '../utils/enums/FilterStatuses';
+import { getFiltredTodo } from '../utils/todos/filterTodo';
+import { Todo } from '../types/Todo';
+
+export const useTodoFilter = (todos: Todo[]) => {
+ const [todoStatus, setTodoStatus] = useState(
+ FilterStatuses.All,
+ );
+
+ const filtredTodos = useMemo(
+ () => getFiltredTodo(todos, todoStatus),
+ [todoStatus, todos],
+ );
+
+ return { filtredTodos, setTodoStatus, todoStatus };
+};
diff --git a/src/hooks/useTodoFormManager.ts b/src/hooks/useTodoFormManager.ts
new file mode 100644
index 000000000..c1cfe091d
--- /dev/null
+++ b/src/hooks/useTodoFormManager.ts
@@ -0,0 +1,88 @@
+import { useState, useContext, useLayoutEffect } from 'react';
+import { TodosContext } from '../context/TodoContext';
+import { Todo } from '../types/Todo';
+import { isOnlyWhiteSpace } from '../utils/string/isOnlyWhiteSpace';
+import { TodoErrors } from '../utils/enums/TodoErrors';
+
+export const useTodoFormManager = (initialTitle = '') => {
+ const {
+ addTodo,
+ updateTodo,
+ updatedAllTodo,
+ showError,
+ onFocus,
+ deleteTodo,
+ } = useContext(TodosContext);
+ const [title, setTitle] = useState(initialTitle);
+ const [isInputDisabled, setInputDisabled] = useState(false);
+ const [isUpdating, setIsUpdating] = useState(false);
+
+ useLayoutEffect(() => {
+ if (!isInputDisabled) {
+ onFocus();
+ }
+ }, [isInputDisabled, onFocus]);
+
+ const handleAddTodo = () => {
+ setInputDisabled(true);
+
+ try {
+ addTodo(title.trim());
+ setTitle('');
+ } catch {
+ showError(TodoErrors.add);
+ } finally {
+ setInputDisabled(false);
+ }
+ };
+
+ const handleUpdateTodo = (todo: Todo) => {
+ setIsUpdating(true);
+
+ try {
+ if (todo.title.trim()) {
+ const updatedTodo = updateTodo(todo);
+ setIsUpdating(false);
+
+ return updatedTodo;
+ } else {
+ deleteTodo(todo.id);
+ }
+ } catch (err) {
+ showError(TodoErrors.update);
+ }
+
+ setIsUpdating(false);
+ };
+
+ const handleToogleAllTodoStatus = () => {
+ updatedAllTodo();
+ };
+
+ const handleChangeTitle = (e: React.ChangeEvent) => {
+ setTitle(e.target.value);
+ };
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (!title || isOnlyWhiteSpace(title)) {
+ showError(TodoErrors.title);
+ return;
+ }
+
+ handleAddTodo();
+ };
+
+ return {
+ title,
+ isInputDisabled,
+ isUpdating,
+ setTitle,
+ handleSubmit,
+ handleChangeTitle,
+ handleAddTodo,
+ handleUpdateTodo,
+ handleToogleAllTodoStatus,
+ };
+};
diff --git a/src/hooks/useTodoInput.ts b/src/hooks/useTodoInput.ts
new file mode 100644
index 000000000..9c08d14ea
--- /dev/null
+++ b/src/hooks/useTodoInput.ts
@@ -0,0 +1,11 @@
+import { useCallback, useRef } from 'react';
+
+export const useTodoInput = () => {
+ const inputRef = useRef(null);
+
+ const onFocus = useCallback(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ return { inputRef, onFocus };
+};
diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts
new file mode 100644
index 000000000..9c7a3a939
--- /dev/null
+++ b/src/hooks/useTodos.ts
@@ -0,0 +1,94 @@
+import { useCallback, useLayoutEffect, useState } from 'react';
+import { Todo } from '../types/Todo';
+import {
+ getCompletedTodos,
+ getInCompletedTodos,
+ hasInCompletedTodos,
+} from '../utils/todos/getTodos';
+import { updateTodosCompleted } from '../utils/todos/updateTodo';
+import { getRandomId } from '../utils/getRandomId';
+import useLocaLStorage from './useLocaLStorage';
+
+export const useTodos = () => {
+ const [todos, setTodos] = useState([]);
+ const { getItem, setItem } = useLocaLStorage('todos');
+
+ const fetchTodos = useCallback(() => {
+ const localTodos = getItem();
+
+ setTodos(localTodos ? localTodos : []);
+ }, []);
+
+ const addTodo = (title: string): Todo | void => {
+ const todo = {
+ title,
+ completed: false,
+ id: getRandomId(),
+ };
+
+ setItem([...todos, todo]);
+
+ setTodos(prevState => [...prevState, todo]);
+ };
+
+ const deleteTodo = (todoId: string) => {
+ const newTodos = todos.filter(todo => todo.id !== todoId);
+
+ setTodos(newTodos);
+
+ setItem(newTodos);
+ };
+
+ const deleteCompletedTodos = async () => {
+ const inCompletedTodos = getInCompletedTodos(todos);
+
+ setTodos(inCompletedTodos);
+
+ setItem(inCompletedTodos);
+ };
+
+ const updateTodo = (todo: Todo): Todo | void => {
+ const updatedTodos = todos.map(currentTodo =>
+ currentTodo.id === todo.id ? todo : currentTodo,
+ );
+
+ setTodos(updatedTodos);
+
+ setItem(updatedTodos);
+
+ return todo;
+ };
+
+ const updatedAllTodo = (): void => {
+ const isIncompletedTodo = hasInCompletedTodos(todos);
+
+ let newTodos: Todo[] = [];
+
+ if (isIncompletedTodo) {
+ newTodos = [
+ ...updateTodosCompleted(getInCompletedTodos(todos)),
+ ...getCompletedTodos(todos),
+ ];
+ } else {
+ newTodos = updateTodosCompleted(todos);
+ }
+
+ setTodos(newTodos);
+
+ setItem(newTodos);
+ };
+
+ useLayoutEffect(() => {
+ fetchTodos();
+ }, [fetchTodos]);
+
+ return {
+ todos,
+ fetchTodos,
+ addTodo,
+ deleteTodo,
+ deleteCompletedTodos,
+ updateTodo,
+ updatedAllTodo,
+ };
+};
diff --git a/src/index.tsx b/src/index.tsx
index a9689cb38..fee7a5959 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,11 +1,9 @@
import { createRoot } from 'react-dom/client';
-import './styles/index.css';
-import './styles/todo-list.css';
-import './styles/filters.css';
+import 'bulma/css/bulma.css';
+import '@fortawesome/fontawesome-free/css/all.css';
+import './styles/index.scss';
import { App } from './App';
-const container = document.getElementById('root') as HTMLDivElement;
-
-createRoot(container).render( );
+createRoot(document.getElementById('root') as HTMLDivElement).render( );
diff --git a/src/styles/todo.scss b/src/styles/todo.scss
index 4576af434..c7f93ff6b 100644
--- a/src/styles/todo.scss
+++ b/src/styles/todo.scss
@@ -15,13 +15,13 @@
&__status-label {
cursor: pointer;
- background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center left;
}
&.completed &__status-label {
- background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
+ background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E");
}
&__status {
@@ -92,8 +92,58 @@
.overlay {
position: absolute;
- inset: 0;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 58px;
opacity: 0.5;
}
}
+
+.item-enter {
+ max-height: 0;
+}
+
+.item-enter-active {
+ overflow: hidden;
+ max-height: 58px;
+ transition: max-height 0.3s ease-in-out;
+}
+
+.item-exit {
+ max-height: 58px;
+}
+
+.item-exit-active {
+ overflow: hidden;
+ max-height: 0;
+ transition: max-height 0.3s ease-in-out;
+}
+
+.temp-item-enter {
+ max-height: 0;
+}
+
+.temp-item-enter-active {
+ overflow: hidden;
+ max-height: 58px;
+ transition: max-height 0.3s ease-in-out;
+}
+
+.temp-item-exit {
+ max-height: 58px;
+}
+
+.temp-item-exit-active {
+ transform: translateY(-58px);
+ max-height: 0;
+ opacity: 0;
+ transition: 0.3s ease-in-out;
+ transition-property: opacity, max-height, transform;
+}
+
+.has-error .temp-item-exit-active {
+ transform: translateY(0);
+ overflow: hidden;
+}
diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss
index e289a9458..ad28bcb2f 100644
--- a/src/styles/todoapp.scss
+++ b/src/styles/todoapp.scss
@@ -1,5 +1,6 @@
+
.todoapp {
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 24px;
font-weight: 300;
color: #4d4d4d;
@@ -8,8 +9,7 @@
&__content {
margin-bottom: 20px;
background: #fff;
- box-shadow:
- 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
@@ -49,7 +49,7 @@
}
&::before {
- content: '❯';
+ content: "❯";
transform: translateY(2px) rotate(90deg);
line-height: 0;
}
@@ -69,7 +69,7 @@
border: none;
background: rgba(0, 0, 0, 0.01);
- box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
&::placeholder {
font-style: italic;
@@ -97,8 +97,7 @@
text-align: center;
border-top: 1px solid #e6e6e6;
- box-shadow:
- 0 1px 1px rgba(0, 0, 0, 0.2),
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
0 8px 0 -3px #f6f6f6,
0 9px 1px -3px rgba(0, 0, 0, 0.2),
0 16px 0 -6px #f6f6f6,
@@ -122,6 +121,7 @@
appearance: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+ transition: opacity 0.3s;
&:hover {
text-decoration: underline;
@@ -130,5 +130,9 @@
&:active {
text-decoration: none;
}
+
+ &:disabled {
+ visibility: hidden;
+ }
}
}
diff --git a/src/types/Todo.ts b/src/types/Todo.ts
new file mode 100644
index 000000000..476a3f9ef
--- /dev/null
+++ b/src/types/Todo.ts
@@ -0,0 +1,5 @@
+export interface Todo {
+ id: string;
+ title: string;
+ completed: boolean;
+}
diff --git a/src/utils/enums/FilterStatuses.ts b/src/utils/enums/FilterStatuses.ts
new file mode 100644
index 000000000..bb9a642ef
--- /dev/null
+++ b/src/utils/enums/FilterStatuses.ts
@@ -0,0 +1,5 @@
+export enum FilterStatuses {
+ All = 'all',
+ Active = 'active',
+ Completed = 'completed',
+}
diff --git a/src/utils/enums/TodoErrors.ts b/src/utils/enums/TodoErrors.ts
new file mode 100644
index 000000000..5b0735cff
--- /dev/null
+++ b/src/utils/enums/TodoErrors.ts
@@ -0,0 +1,7 @@
+export enum TodoErrors {
+ load = 'load',
+ title = 'title',
+ add = 'add',
+ delete = 'delete',
+ update = 'update',
+}
diff --git a/src/utils/getRandomId.ts b/src/utils/getRandomId.ts
new file mode 100644
index 000000000..ffa785e90
--- /dev/null
+++ b/src/utils/getRandomId.ts
@@ -0,0 +1 @@
+export const getRandomId = (): string => self.crypto.randomUUID();
diff --git a/src/utils/string/isOnlyWhiteSpace.ts b/src/utils/string/isOnlyWhiteSpace.ts
new file mode 100644
index 000000000..5ff1d9610
--- /dev/null
+++ b/src/utils/string/isOnlyWhiteSpace.ts
@@ -0,0 +1,2 @@
+export const isOnlyWhiteSpace = (string: string): boolean =>
+ /^\s*$/.test(string);
diff --git a/src/utils/todos/filterTodo.ts b/src/utils/todos/filterTodo.ts
new file mode 100644
index 000000000..ba4d0b364
--- /dev/null
+++ b/src/utils/todos/filterTodo.ts
@@ -0,0 +1,14 @@
+import { Todo } from '../../types/Todo';
+import { FilterStatuses } from '../enums/FilterStatuses';
+
+export const getFiltredTodo = (todos: Todo[], status: FilterStatuses) =>
+ todos.filter(({ completed }) => {
+ switch (status) {
+ case FilterStatuses.Active:
+ return !completed;
+ case FilterStatuses.Completed:
+ return completed;
+ default:
+ return true;
+ }
+ });
diff --git a/src/utils/todos/getTodoErrorsMessage.ts b/src/utils/todos/getTodoErrorsMessage.ts
new file mode 100644
index 000000000..33cf878bc
--- /dev/null
+++ b/src/utils/todos/getTodoErrorsMessage.ts
@@ -0,0 +1,18 @@
+import { TodoErrors } from '../enums/TodoErrors';
+
+export const getTodoErrorsMessage = (error: TodoErrors) => {
+ switch (error) {
+ case TodoErrors.load:
+ return 'Unable to load todos';
+ case TodoErrors.title:
+ return 'Title should not be empty';
+ case TodoErrors.add:
+ return 'Unable to add a todo';
+ case TodoErrors.delete:
+ return 'Unable to delete a todo';
+ case TodoErrors.update:
+ return 'Unable to update a todo';
+ default:
+ return 'An unexpected error';
+ }
+};
diff --git a/src/utils/todos/getTodos.ts b/src/utils/todos/getTodos.ts
new file mode 100644
index 000000000..e2e8d485e
--- /dev/null
+++ b/src/utils/todos/getTodos.ts
@@ -0,0 +1,16 @@
+import { Todo } from '../../types/Todo';
+
+export const getInCompletedTodos = (todos: Todo[]) =>
+ todos.filter(({ completed }) => !completed);
+
+export const getCompletedTodos = (todos: Todo[]) =>
+ todos.filter(({ completed }) => completed);
+
+export const isAllTodosCompleted = (todos: Todo[]) =>
+ todos.every(({ completed }) => completed);
+
+export const hasCompletedTodos = (todos: Todo[]) =>
+ todos.some(({ completed }) => completed);
+
+export const hasInCompletedTodos = (todos: Todo[]) =>
+ todos.some(({ completed }) => completed === false);
diff --git a/src/utils/todos/updateTodo.ts b/src/utils/todos/updateTodo.ts
new file mode 100644
index 000000000..bd43eb2cd
--- /dev/null
+++ b/src/utils/todos/updateTodo.ts
@@ -0,0 +1,4 @@
+import { Todo } from '../../types/Todo';
+
+export const updateTodosCompleted = (todos: Todo[]) =>
+ todos.map(todo => ({ ...todo, completed: !todo.completed }));