diff --git a/src/App.tsx b/src/App.tsx index 20e932bab..f25ec0ce3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,93 +1,10 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ import React from 'react'; - -export const App: React.FC = () => { - return ( -
-
-

todos

- -
- -
-
- -
- - - - -
- - -
- ); -}; +import { TodosProvider } from './TodosContext'; +import { TodoApp } from './components/TodoApp'; + +export const App: React.FC = () => ( + + + +); diff --git a/src/TodosContext.tsx b/src/TodosContext.tsx new file mode 100644 index 000000000..3a6d985fa --- /dev/null +++ b/src/TodosContext.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { Option } from './types/Option'; +import { useLocalStorage } from './hooks/useLocalStorage'; +import { Todo } from './types/Todo'; +import { Context } from './types/Context'; + +const initialValue: Context = { + todos: [], + setTodos: () => { }, + filter: Option.All, + setFilter: () => { }, + visibleTodos: [], + isToggleCheckedAll: false, + setIsToggleCheckedAll: () => {}, +}; + +export const TodosContext = React.createContext(initialValue); + +type Props = { + children: React.ReactNode; +}; + +export const TodosProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStorage('todos', []); + const [filter, setFilter] = useState(Option.All); + const [isToggleCheckedAll, setIsToggleCheckedAll] + = useState(todos.every(todo => todo.completed)); + + const visibleTodos = todos.filter(todo => { + switch (filter) { + case Option.Active: + return !todo.completed; + + case Option.Completed: + return todo.completed; + + case Option.All: + default: + return true; + } + }); + + const context: Context = { + todos, + setTodos, + filter, + setFilter, + visibleTodos, + isToggleCheckedAll, + setIsToggleCheckedAll, + }; + + return ( + + {children} + + ); +}; diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..b1369df04 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,76 @@ +import React, { useContext } from 'react'; +import cn from 'classnames'; +import { TodosContext } from '../TodosContext'; +import { Option } from '../types/Option'; + +export const Footer: React.FC = () => { + const { + todos, + setTodos, + filter, + setFilter, + } = useContext(TodosContext); + + const TotalUncompletedTodos = todos.filter(todo => !todo.completed); + + const isCompletedTodos = todos.some(todo => todo.completed); + + const cleanCompletedTodos = () => { + setTodos(todos.filter(todo => !todo.completed)); + }; + + return ( + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..77289ce60 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,45 @@ +import React, { useCallback, useContext, useState } from 'react'; +import { TodosContext } from '../TodosContext'; +import { Todo } from '../types/Todo'; + +export const Header: React.FC = () => { + const { todos, setTodos, setIsToggleCheckedAll } = useContext(TodosContext); + const [title, setTitle] = useState(''); + + const handleSubmit = useCallback(( + event: React.FormEvent, + ) => { + event.preventDefault(); + + if (!title.trim()) { + return; + } + + const newTodo: Todo = { + id: +new Date(), + title: title.trim(), + completed: false, + }; + + setTodos([...todos, newTodo]); + setTitle(''); + setIsToggleCheckedAll(false); + }, [setIsToggleCheckedAll, setTodos, title, todos]); + + return ( +
+

todos

+ +
+ setTitle(event.target.value)} + /> +
+
+ ); +}; diff --git a/src/components/Main.tsx b/src/components/Main.tsx new file mode 100644 index 000000000..ebdbf30b1 --- /dev/null +++ b/src/components/Main.tsx @@ -0,0 +1,41 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React, { useContext } from 'react'; +import { TodosContext } from '../TodosContext'; +import { TodoList } from './TodoList'; + +export const Main: React.FC = () => { + const { + todos, + setTodos, + visibleTodos, + isToggleCheckedAll, + setIsToggleCheckedAll, + } = useContext(TodosContext); + + const checkAllTodos = () => { + const newTodos = todos.map(todo => ( + isToggleCheckedAll + ? { ...todo, completed: false } + : { ...todo, completed: true } + )); + + setTodos(newTodos); + setIsToggleCheckedAll(!isToggleCheckedAll); + }; + + return ( +
+ + + + +
+ ); +}; diff --git a/src/components/TodoApp.tsx b/src/components/TodoApp.tsx new file mode 100644 index 000000000..515fe98f5 --- /dev/null +++ b/src/components/TodoApp.tsx @@ -0,0 +1,23 @@ +import React, { useContext } from 'react'; +import { Header } from './Header'; +import { Main } from './Main'; +import { Footer } from './Footer'; +import { TodosContext } from '../TodosContext'; + +export const TodoApp: React.FC = () => { + const { todos } = useContext(TodosContext); + + return ( +
+
+ + {!!todos.length && ( + <> +
+ +
+ + )} +
+ ); +}; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 000000000..bf4c170ef --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,121 @@ +import React, { + useContext, + useState, + useRef, + useEffect, +} from 'react'; +import cn from 'classnames'; +import { Todo } from '../types/Todo'; +import { TodosContext } from '../TodosContext'; + +type Props = { + item: Todo, +}; +export const TodoItem: React.FC = ({ item }) => { + const { todos, setTodos, setIsToggleCheckedAll } = useContext(TodosContext); + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(item.title); + + const selectedTodo = useRef(null); + + useEffect(() => { + if (selectedTodo.current) { + selectedTodo.current.focus(); + } + }, [isEditing]); + + const removeTodo = (id: number) => { + const newTodos = todos.filter(todo => todo.id !== id); + + setTodos(newTodos); + setIsToggleCheckedAll(newTodos.every(todo => todo.completed)); + }; + + const toggleTodo = (id: number) => { + const newTodos = todos.map(todo => ( + todo.id === id + ? { ...todo, completed: !todo.completed } + : todo + )); + + setTodos(newTodos); + setIsToggleCheckedAll(newTodos.every(todo => todo.completed)); + }; + + const handleDoubleClick = ( + event: React.MouseEvent, + ) => { + event.preventDefault(); + setIsEditing(true); + }; + + const handleBlur = () => { + setIsEditing(false); + + if (!title.trim()) { + removeTodo(item.id); + + return; + } + + setTodos(todos.map(todo => ( + todo.id === item.id + ? { ...todo, title: title.trim() } + : todo + ))); + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + switch (event.key) { + case 'Escape': + setIsEditing(false); + setTitle(item.title); + break; + + case 'Enter': + handleBlur(); + break; + + default: + break; + } + }; + + return ( +
  • +
    + toggleTodo(item.id)} + /> + +
    + setTitle(event.target.value)} + ref={selectedTodo} + onKeyUp={handleKeyUp} + onBlur={handleBlur} + /> +
  • + ); +}; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..d512095fc --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,16 @@ +/* eslint-disable jsx-a11y/control-has-associated-label */ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { TodoItem } from './TodoItem'; + +type Props = { + items: Todo[], +}; + +export const TodoList: React.FC = ({ items }) => ( +
      + {items.map(item => ( + + ))} +
    +); diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts new file mode 100644 index 000000000..d4069dfe6 --- /dev/null +++ b/src/hooks/useLocalStorage.ts @@ -0,0 +1,29 @@ +import { useState } from 'react'; + +export function useLocalStorage( + key: string, + startValue: T, +): [T, (v: T) => void] { + const [value, setValue] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + return startValue; + } + + try { + return JSON.parse(data); + } catch (e) { + localStorage.removeItem(key); + + return startValue; + } + }); + + const save = (newValue: T) => { + localStorage.setItem(key, JSON.stringify(newValue)); + setValue(newValue); + }; + + return [value, save]; +} diff --git a/src/types/Context.ts b/src/types/Context.ts new file mode 100644 index 000000000..577f4b434 --- /dev/null +++ b/src/types/Context.ts @@ -0,0 +1,12 @@ +import { Option } from './Option'; +import { Todo } from './Todo'; + +export interface Context { + todos: Todo[]; + setTodos: (v: Todo[]) => void; + filter: Option; + setFilter: (v: Option) => void; + visibleTodos: Todo[]; + isToggleCheckedAll: boolean; + setIsToggleCheckedAll: (v: boolean) => void; +} diff --git a/src/types/Option.ts b/src/types/Option.ts new file mode 100644 index 000000000..1e64b6482 --- /dev/null +++ b/src/types/Option.ts @@ -0,0 +1,5 @@ +export enum Option { + 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; +}