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 */} -
- -
- {/* 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 */} - -
-
- ); -}; +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 }) => ( +
+
+); 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 + + + + + +
+ ); +}; 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 */} + + + {selectedTodo ? ( +
+ setTitle(e.target.value)} + autoFocus + /> +
+ ) : ( + { + setSelectedTodo(todo); + }} + > + {title.trim()} + + )} + {!selectedTodo && ( + + )} + +
+
+
+
+
+ ); +}; 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 }));