diff --git a/src/App.tsx b/src/App.tsx index a399287bd..c9ff0965a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,23 @@ -/* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext } from 'react'; + +import { Title } from './components/App/Title'; +import { Content } from './components/App/Content'; +import { Header } from './components/Header'; +import { Main } from './components/Main'; +import { Footer } from './components/Footer'; +import { TodoContext } from './context/TodoContext'; export const App: React.FC = () => { + const todos = useContext(TodoContext); + 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 */} - -
-
+ + <Content> + <Header /> + <Main /> + {todos?.length !== 0 && <Footer />} + </Content> </div> ); }; diff --git a/src/components/App/Content.tsx b/src/components/App/Content.tsx new file mode 100644 index 000000000..4d4593708 --- /dev/null +++ b/src/components/App/Content.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +type Props = { + children: React.ReactNode; +}; + +const ContentBase: React.FC<Props> = ({ children }) => { + return <div className="todoapp__content">{children}</div>; +}; + +export const Content = React.memo(ContentBase); diff --git a/src/components/App/Title.tsx b/src/components/App/Title.tsx new file mode 100644 index 000000000..76f7e2555 --- /dev/null +++ b/src/components/App/Title.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const TitleBase: React.FC = () => { + return <div className="todoapp__title">todos</div>; +}; + +export const Title = React.memo(TitleBase); diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..94466ab98 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import cn from 'classnames'; + +import { useTodos } from '../../hooks/useTodos'; +import { useTodosView } from '../../hooks/useTodosView'; + +type Props = {}; + +const FooterBase: React.FC<Props> = () => { + const { uncompletedTodos, completedTodos, clearCompletedTodos } = useTodos(); + const { filter, setFilter } = useTodosView(); + + return ( + <footer className="todoapp__footer" data-cy="Footer"> + <span className="todo-count" data-cy="TodosCounter"> + {uncompletedTodos.length} items left + </span> + + <nav className="filter" data-cy="Filter"> + <a + onClick={() => setFilter('all')} + href="#/" + className={cn('filter__link', { + selected: filter === 'all', + })} + data-cy="FilterLinkAll" + > + All + </a> + + <a + onClick={() => setFilter('active')} + href="#/active" + className={cn('filter__link', { + selected: filter === 'active', + })} + data-cy="FilterLinkActive" + > + Active + </a> + + <a + onClick={() => setFilter('completed')} + href="#/completed" + className={cn('filter__link', { + selected: filter === 'completed', + })} + data-cy="FilterLinkCompleted" + > + Completed + </a> + </nav> + + <button + type="button" + className="todoapp__clear-completed" + data-cy="ClearCompletedButton" + disabled={completedTodos.length === 0} + onClick={clearCompletedTodos} + > + Clear completed + </button> + </footer> + ); +}; + +export const Footer = React.memo(FooterBase); 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..899e56769 --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import { useTodos } from '../../hooks/useTodos'; + +type Props = {}; + +const HeaderBase: React.FC<Props> = () => { + const { toggleAllTodos, completedTodos, todos, addTodo } = useTodos(); + const [title, setTitle] = useState(''); + + const newTodoFieldRef = useRef<HTMLInputElement | null>(null); + + const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + const titleToSave = title.trim(); + + if (titleToSave !== '') { + addTodo(titleToSave); + setTitle(''); + } + }; + + const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { + setTitle(e.target.value); + }; + + useEffect(() => { + if (newTodoFieldRef.current) { + newTodoFieldRef.current.focus(); + } + }, [todos.length]); + + return ( + <header className="todoapp__header"> + {todos.length !== 0 && ( + <button + onClick={toggleAllTodos} + type="button" + className={cn('todoapp__toggle-all', { + active: todos.length === completedTodos.length, + })} + data-cy="ToggleAllButton" + /> + )} + <form onSubmit={handleSubmit}> + <input + ref={newTodoFieldRef} + value={title} + onChange={handleTitleChange} + data-cy="NewTodoField" + type="text" + className="todoapp__new-todo" + placeholder="What needs to be done?" + /> + </form> + </header> + ); +}; + +export const Header = React.memo(HeaderBase); 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/Main/Main.tsx b/src/components/Main/Main.tsx new file mode 100644 index 000000000..d54a20446 --- /dev/null +++ b/src/components/Main/Main.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { TodoList } from '../TodoList'; + +type Props = {}; + +const MainBase: React.FC<Props> = () => { + return ( + <section className="todoapp__main" data-cy="TodoList"> + <TodoList /> + </section> + ); +}; + +export const Main = React.memo(MainBase); diff --git a/src/components/Main/index.ts b/src/components/Main/index.ts new file mode 100644 index 000000000..9e3006756 --- /dev/null +++ b/src/components/Main/index.ts @@ -0,0 +1 @@ +export * from './Main'; diff --git a/src/components/TodoList/TodoItem.tsx b/src/components/TodoList/TodoItem.tsx new file mode 100644 index 000000000..192c0ad48 --- /dev/null +++ b/src/components/TodoList/TodoItem.tsx @@ -0,0 +1,129 @@ +import React, { useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import { Todo } from '../../types/todo'; +import { useTodos } from '../../hooks/useTodos'; + +type Props = { + todo: Todo; +}; + +const TodoItemBase: React.FC<Props> = ({ todo }) => { + const { id, title, completed } = todo; + const { deleteTodo, toggleTodoStatus, updateTodoTitle } = useTodos(); + + const [isEditing, setIsEditing] = useState(false); + const [newTitle, setNewTitle] = useState(title); + + const newTitleRef = useRef<HTMLInputElement | null>(null); + + const handleEditing = (e: React.ChangeEvent<HTMLInputElement>) => { + setNewTitle(e.target.value); + }; + + const handleTitleDoubleClick = () => { + setIsEditing(true); + }; + + const updateTodo = () => { + const titleToSave = newTitle.trim(); + + if (titleToSave !== '' && titleToSave !== title) { + updateTodoTitle(id, titleToSave); + } + + if (titleToSave === '') { + deleteTodo(id); + } + + setIsEditing(false); + }; + + const handleInputBlur = () => { + updateTodo(); + }; + + const handleSubmitTitle = (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + updateTodo(); + }; + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + setNewTitle(title); + setIsEditing(false); + } + }; + + if (isEditing) { + document.addEventListener('keyup', handleEscape); + } else { + document.removeEventListener('keyup', handleEscape); + } + + return () => { + document.removeEventListener('keyup', handleEscape); + }; + }, [isEditing, title]); + + useEffect(() => { + if (isEditing) { + newTitleRef.current?.focus(); + } + }, [isEditing]); + + return ( + <div + data-cy="Todo" + className={cn('todo', { + completed: completed, + })} + > + <label className="todo__status-label" aria-label="todo-status-label"> + <input + onChange={() => toggleTodoStatus(id)} + data-cy="TodoStatus" + type="checkbox" + className="todo__status" + checked={completed} + /> + </label> + {isEditing ? ( + <form onSubmit={handleSubmitTitle}> + <input + ref={newTitleRef} + value={newTitle} + onChange={handleEditing} + onBlur={handleInputBlur} + data-cy="TodoTitleField" + type="text" + className="todo__title-field" + placeholder="Empty Todo will be deleted" + /> + </form> + ) : ( + <span + data-cy="TodoTitle" + className="todo__title" + onDoubleClick={handleTitleDoubleClick} + > + {title} + </span> + )} + + {!isEditing && ( + <button + type="button" + className="todo__remove" + data-cy="TodoDelete" + onClick={() => deleteTodo(id)} + > + × + </button> + )} + </div> + ); +}; + +export const TodoItem = React.memo(TodoItemBase); diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..2d921a917 --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { TodoItem } from './TodoItem'; +import { useTodosView } from '../../hooks/useTodosView'; + +type Props = {}; + +const TodoListBase: React.FC<Props> = () => { + const { visibleTodos } = useTodosView(); + + return ( + <> + {visibleTodos && + visibleTodos.map(todo => <TodoItem todo={todo} key={todo.id} />)} + </> + ); +}; + +export const TodoList = React.memo(TodoListBase); + +// +// {/* This is a completed todo */} +// <div data-cy="Todo" className="todo completed"> +// <label className="todo__status-label"> +// <input +// data-cy="TodoStatus" +// type="checkbox" +// className="todo__status" +// checked +// /> +// </label> +// +// <span data-cy="TodoTitle" className="todo__title"> +// Completed Todo +// </span> +// +// {/* Remove button appears only on hover */} +// <button type="button" className="todo__remove" data-cy="TodoDelete"> +// × +// </button> +// </div> +// +// {/* This todo is an active todo */} +// <div data-cy="Todo" className="todo"> +// <label className="todo__status-label"> +// <input +// data-cy="TodoStatus" +// type="checkbox" +// className="todo__status" +// /> +// </label> +// +// <span data-cy="TodoTitle" className="todo__title"> +// Not Completed Todo +// </span> +// +// <button type="button" className="todo__remove" data-cy="TodoDelete"> +// × +// </button> +// </div> +// +// {/* This todo is being edited */} +// <div data-cy="Todo" className="todo"> +// <label className="todo__status-label"> +// <input +// data-cy="TodoStatus" +// type="checkbox" +// className="todo__status" +// /> +// </label> +// +// {/* This form is shown instead of the title and remove button */} +// <form> +// <input +// data-cy="TodoTitleField" +// type="text" +// className="todo__title-field" +// placeholder="Empty todo will be deleted" +// value="Todo is being edited now" +// /> +// </form> +// </div> +// +// {/* This todo is in loadind state */} +// <div data-cy="Todo" className="todo"> +// <label className="todo__status-label"> +// <input +// data-cy="TodoStatus" +// type="checkbox" +// className="todo__status" +// /> +// </label> +// +// <span data-cy="TodoTitle" className="todo__title"> +// Todo is being saved now +// </span> +// +// <button type="button" className="todo__remove" data-cy="TodoDelete"> +// × +// </button> +// </div> 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/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..9ba39b708 --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/indent */ +import React, { createContext, useReducer, useEffect, useState } from 'react'; +import { Todo } from '../types/todo'; +import { useTodoLocalStorage } from '../hooks/useTodoLocalStorage'; + +type Filter = 'all' | 'active' | 'completed'; + +export const TodoContext = createContext<Todo[]>([]); +export const TodoContextDispatch = createContext<React.Dispatch<Action> | null>( + null, +); + +export const TodoFilterContext = createContext<Filter | null>(null); +export const TodoFilterContextDispatch = createContext<React.Dispatch< + React.SetStateAction<Filter> +> | null>(null); + +type Action = + | { type: 'ADD_TODO'; payload: Todo } + | { type: 'SET_TODOS'; payload: Todo[] } + | { type: 'DELETE_TODO'; payload: { id: Todo['id'] } } + | { type: 'TOGGLE_TODO_STATUS'; payload: { id: Todo['id'] } } + | { type: 'UPDATE_TODO_TITLE'; payload: { id: Todo['id']; title: string } }; + +const reducer = (state: Todo[], action: Action) => { + if (action.type === 'ADD_TODO') { + return [...state, { ...action.payload }]; + } + + if (action.type === 'SET_TODOS') { + return action.payload; + } + + if (action.type === 'DELETE_TODO') { + return state.filter(todo => todo.id !== action.payload.id); + } + + if (action.type === 'TOGGLE_TODO_STATUS') { + const targetTodoIndex = state.findIndex(todo => { + return todo.id === action.payload.id; + }); + const targetTodo = state[targetTodoIndex]; + + if (!targetTodo) { + return state; + } + + return [ + ...state.slice(0, targetTodoIndex), + { ...targetTodo, completed: !targetTodo?.completed }, + ...state.slice(targetTodoIndex + 1), + ]; + } + + if (action.type === 'UPDATE_TODO_TITLE') { + const targetTodoIndex = state.findIndex(todo => { + return todo.id === action.payload.id; + }); + const targetTodo = state[targetTodoIndex]; + + if (!targetTodo) { + return state; + } + + return [ + ...state.slice(0, targetTodoIndex), + { ...targetTodo, title: action.payload.title }, + ...state.slice(targetTodoIndex + 1), + ]; + } + + return state; +}; + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC<Props> = ({ children }) => { + const { getTodosFromLS, setTodosToLS } = useTodoLocalStorage(); + + const [todos, dispatch] = useReducer(reducer, [], getTodosFromLS); + const [filter, setFilter] = useState<Filter>('all'); + + useEffect(() => { + setTodosToLS(todos); + }, [todos, setTodosToLS]); + + return ( + <TodoContext.Provider value={todos}> + <TodoContextDispatch.Provider value={dispatch}> + <TodoFilterContext.Provider value={filter}> + <TodoFilterContextDispatch.Provider value={setFilter}> + {children} + </TodoFilterContextDispatch.Provider> + </TodoFilterContext.Provider> + </TodoContextDispatch.Provider> + </TodoContext.Provider> + ); +}; diff --git a/src/hooks/useTodoLocalStorage.ts b/src/hooks/useTodoLocalStorage.ts new file mode 100644 index 000000000..35a11afab --- /dev/null +++ b/src/hooks/useTodoLocalStorage.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; + +import { isTodosArray, Todo } from '../types/todo'; + +const TODO_LS_KEY = 'todos'; + +export const useTodoLocalStorage = () => { + const todosFromLSJSON = localStorage.getItem(TODO_LS_KEY); + + const getTodosFromLS = (): Todo[] => { + if (!todosFromLSJSON) { + return []; + } + + const todosFromLSParsed = JSON.parse(todosFromLSJSON); + + if (isTodosArray(todosFromLSParsed)) { + return todosFromLSParsed; + } else { + return []; + } + }; + + const setTodosToLS = useCallback((todos: Todo[]) => { + localStorage.setItem(TODO_LS_KEY, JSON.stringify(todos)); + }, []); + + return { + setTodosToLS, + getTodosFromLS, + }; +}; diff --git a/src/hooks/useTodos.ts b/src/hooks/useTodos.ts new file mode 100644 index 000000000..d10228440 --- /dev/null +++ b/src/hooks/useTodos.ts @@ -0,0 +1,84 @@ +import { useContext, useMemo } from 'react'; +import { TodoContext, TodoContextDispatch } from '../context/TodoContext'; +import { Todo } from '../types/todo'; + +export const useTodos = () => { + const todos = useContext(TodoContext); + const dispatch = useContext(TodoContextDispatch); + + const uncompletedTodos = useMemo(() => { + return todos.filter(todo => !todo.completed); + }, [todos]); + + const completedTodos = useMemo(() => { + return todos.filter(todo => todo.completed); + }, [todos]); + + if (!dispatch) { + throw new Error('useTodos must be used within TodoProvider'); + } + + const addTodo = (title: Todo['title']) => { + if (title.trim() === '') { + return; + } + + dispatch({ + type: 'ADD_TODO', + payload: { + id: +new Date(), + title: title.trim(), + completed: false, + }, + }); + }; + + const deleteTodo = (todoId: Todo['id']) => { + dispatch({ type: 'DELETE_TODO', payload: { id: todoId } }); + }; + + const clearCompletedTodos = () => { + completedTodos.forEach(todo => { + dispatch({ type: 'DELETE_TODO', payload: { id: todo.id } }); + }); + }; + + const toggleTodoStatus = (todoId: Todo['id']) => { + dispatch({ type: 'TOGGLE_TODO_STATUS', payload: { id: todoId } }); + }; + + const toggleAllTodos = () => { + if (completedTodos.length === todos.length) { + completedTodos.forEach(todo => { + dispatch({ type: 'TOGGLE_TODO_STATUS', payload: { id: todo.id } }); + }); + } else { + uncompletedTodos.forEach(todo => { + dispatch({ type: 'TOGGLE_TODO_STATUS', payload: { id: todo.id } }); + }); + } + }; + + const updateTodoTitle = (todoId: Todo['id'], title: Todo['title']) => { + if (title.trim() === '') { + return; + } + + dispatch({ + type: 'UPDATE_TODO_TITLE', + payload: { id: todoId, title: title.trim() }, + }); + }; + + return { + todos, + uncompletedTodos, + completedTodos, + addTodo, + deleteTodo, + clearCompletedTodos, + toggleTodoStatus, + toggleAllTodos, + updateTodoTitle, + }; +}; diff --git a/src/hooks/useTodosView.ts b/src/hooks/useTodosView.ts new file mode 100644 index 000000000..e1af3307b --- /dev/null +++ b/src/hooks/useTodosView.ts @@ -0,0 +1,39 @@ +import { useContext, useMemo } from 'react'; + +import { + TodoFilterContext, + TodoFilterContextDispatch, +} from '../context/TodoContext'; +import { useTodos } from './useTodos'; + +export const useTodosView = () => { + const { todos, completedTodos, uncompletedTodos } = useTodos(); + const filter = useContext(TodoFilterContext); + const setFilter = useContext(TodoFilterContextDispatch); + + if (!setFilter || !filter) { + throw new Error('useTodosView must be used within TodoProvider'); + } + + const visibleTodos = useMemo(() => { + if (filter === 'all') { + return todos; + } + + if (filter === 'completed') { + return completedTodos; + } + + if (filter === 'active') { + return uncompletedTodos; + } + + return todos; + }, [todos, filter, completedTodos, uncompletedTodos]); + + return { + visibleTodos, + setFilter, + filter, + }; +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..a4207b1fc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,12 @@ import { createRoot } from 'react-dom/client'; import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './context/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(<App />); +createRoot(container).render( + <TodoProvider> + <App /> + </TodoProvider>, +); diff --git a/src/styles/filters.scss b/src/styles/filters.scss index 75b5804e5..fa0a68de7 100644 --- a/src/styles/filters.scss +++ b/src/styles/filters.scss @@ -4,13 +4,12 @@ &__link { margin: 3px; padding: 3px 7px; + border: 1px solid transparent; + border-radius: 3px; color: inherit; text-decoration: none; - border: 1px solid transparent; - border-radius: 3px; - &:hover { border-color: rgba(175, 47, 47, 0.1); } diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..2600b0d83 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -9,15 +9,15 @@ body { } .notification { - transition-property: opacity, min-height; - transition-duration: 1s; min-height: 36px; + transition-duration: 1s; + transition-property: opacity, min-height; } .notification.hidden { + pointer-events: none; min-height: 0; opacity: 0; - pointer-events: none; } @import './todoapp'; diff --git a/src/styles/todo-list.scss b/src/styles/todo-list.scss index 4576af434..c125c81f0 100644 --- a/src/styles/todo-list.scss +++ b/src/styles/todo-list.scss @@ -5,9 +5,10 @@ grid-template-columns: 45px 1fr; justify-items: stretch; + border-bottom: 1px solid #ededed; + font-size: 24px; line-height: 1.4em; - border-bottom: 1px solid #ededed; &:last-child { border-bottom: 0; @@ -30,7 +31,6 @@ &__title { padding: 12px 15px; - word-break: break-all; transition: color 0.4s; } @@ -41,24 +41,27 @@ } &__remove { + cursor: pointer; + position: absolute; - right: 12px; top: 0; + right: 12px; bottom: 0; + transform: translateY(-2px); + + float: right; + + border: 0; - font-size: 120%; - line-height: 1; font-family: inherit; + font-size: 120%; font-weight: inherit; + line-height: 1; color: #cc9a9a; - float: right; - border: 0; + opacity: 0; background: none; - cursor: pointer; - transform: translateY(-2px); - opacity: 0; transition: color 0.2s ease-out; &:hover { @@ -71,21 +74,22 @@ } &__title-field { + box-sizing: border-box; width: 100%; padding: 11px 14px; + border: 1px solid #999; - font-size: inherit; - line-height: inherit; font-family: inherit; + font-size: inherit; font-weight: inherit; + line-height: inherit; color: inherit; - border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); &::placeholder { - font-style: italic; font-weight: 300; + font-style: italic; color: #e6e6e6; } } @@ -93,7 +97,6 @@ .overlay { position: absolute; inset: 0; - opacity: 0.5; } } diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss index e289a9458..1dea05606 100644 --- a/src/styles/todoapp.scss +++ b/src/styles/todoapp.scss @@ -1,9 +1,10 @@ .todoapp { + margin: 40px 20px; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 24px; font-weight: 300; color: #4d4d4d; - margin: 40px 20px; &__content { margin-bottom: 20px; @@ -16,8 +17,8 @@ &__title { font-size: 100px; font-weight: 100; - text-align: center; color: rgba(175, 47, 47, 0.15); + text-align: center; -webkit-text-rendering: optimizeLegibility; -moz-text-rendering: optimizeLegibility; text-rendering: optimizeLegibility; @@ -28,52 +29,54 @@ } &__toggle-all { - position: absolute; + cursor: pointer; - height: 100%; - width: 45px; + position: absolute; display: flex; - justify-content: center; align-items: center; + justify-content: center; + + width: 45px; + height: 100%; + border: 0; font-size: 24px; color: #e6e6e6; - border: 0; background-color: transparent; - cursor: pointer; - - &.active { - color: #737373; - } &::before { content: '❯'; transform: translateY(2px) rotate(90deg); line-height: 0; } + + &.active { + color: #737373; + } } &__new-todo { + box-sizing: border-box; width: 100%; padding: 16px 16px 16px 60px; + border: none; - font-size: 24px; - line-height: 1.4em; font-family: inherit; + font-size: 24px; font-weight: inherit; - color: inherit; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + line-height: 1.4em; + color: inherit; - border: none; background: rgba(0, 0, 0, 0.01); box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); &::placeholder { - font-style: italic; font-weight: 300; + font-style: italic; color: #e6e6e6; } } @@ -84,18 +87,17 @@ &__footer { display: flex; - justify-content: space-between; align-items: center; + justify-content: space-between; box-sizing: content-box; height: 20px; padding: 10px 15px; + border-top: 1px solid #e6e6e6; font-size: 14px; - color: #777; text-align: center; - border-top: 1px solid #e6e6e6; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), @@ -106,22 +108,22 @@ } &__clear-completed { + cursor: pointer; + margin: 0; padding: 0; border: 0; font-family: inherit; font-weight: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; color: inherit; text-decoration: none; - cursor: pointer; - background: none; - -webkit-appearance: none; appearance: none; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + background: none; &:hover { text-decoration: underline; diff --git a/src/types/todo.ts b/src/types/todo.ts new file mode 100644 index 000000000..469eda1cb --- /dev/null +++ b/src/types/todo.ts @@ -0,0 +1,39 @@ +export type Todo = { + title: string; + completed: boolean; + id: number; +}; + +export const isTodo = (value: unknown): value is Todo => { + if ( + typeof value !== 'object' || + value === null || + !('title' in value) || + !('completed' in value) || + !('id' in value) + ) { + return false; + } + + return !( + typeof value.title !== 'string' || + typeof value.completed !== 'boolean' || + typeof value.id !== 'number' + ); +}; + +export const isTodosArray = (todos: unknown): todos is Todo[] => { + if (!Array.isArray(todos)) { + return false; + } + + let isTodos = true; + + todos.forEach(todo => { + if (!isTodo(todo)) { + isTodos = false; + } + }); + + return isTodos; +};