├── .gitignore ├── .eslintrc.json ├── public ├── favicon.ico └── index.html ├── src ├── Types.js ├── img │ └── destroy.png ├── TodoList.css ├── index.js ├── Body.css ├── Footer.css ├── TodoListItem.css ├── App.css ├── Footer.js ├── TodoList.js ├── Body.js ├── TodoListItem.js └── App.js ├── .flowconfig ├── prettier.config.js ├── README.md ├── LICENSE.md └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssorallen/react-todos/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/Types.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | export type Todo = { done: boolean, title: string }; 4 | -------------------------------------------------------------------------------- /src/img/destroy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssorallen/react-todos/HEAD/src/img/destroy.png -------------------------------------------------------------------------------- /src/TodoList.css: -------------------------------------------------------------------------------- 1 | #todo-list { 2 | margin: 10px 0; 3 | padding: 0; 4 | list-style: none; 5 | } 6 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | jsxBracketSameLine: true, 3 | printWidth: 100, 4 | singleQuote: true, 5 | trailingComma: "es5" 6 | }; 7 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import App from './App'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | 7 | const todoappRoot = document.getElementById('todoapp'); 8 | if (todoappRoot == null) throw new Error('App element #todoapp not found'); 9 | 10 | ReactDOM.render(, todoappRoot); 11 | -------------------------------------------------------------------------------- /src/Body.css: -------------------------------------------------------------------------------- 1 | #todoapp input[type="text"] { 2 | width: 466px; 3 | font-size: 24px; 4 | font-family: inherit; 5 | line-height: 1.4em; 6 | border: 0; 7 | outline: none; 8 | padding: 6px; 9 | border: 1px solid #999999; 10 | box-shadow: rgba(0, 0, 0, 0.2) 0 1px 2px 0 inset; 11 | } 12 | 13 | #todoapp input::-webkit-input-placeholder { 14 | font-style: italic; 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Todos 2 | 3 | Backbone's example [TODO app](http://backbonejs.org/docs/todos.html) with React JS 4 | Components for views in place of `Backbone.View`. 5 | 6 | ## Demo 7 | 8 | https://ssorallen.github.io/react-todos/ 9 | 10 | The demo uses local storage to save todos in the browser. The views are all rendered 11 | with React, which means there is no dependency on another DOM manipulation library 12 | like jQuery. 13 | 14 | ## Running Locally 15 | 16 | 1. Clone this repository 17 | 2. Install dependencies 18 | 19 | $ yarn install 20 | 3. Run the local server 21 | 22 | $ yarn start 23 | 4. See the running app at http://localhost:3000 24 | 25 | ### Favicon 26 | 27 | The great little todo list favicon comes from [Typicons](http://typicons.com/). 28 | -------------------------------------------------------------------------------- /src/Footer.css: -------------------------------------------------------------------------------- 1 | #todoapp footer { 2 | margin: 0 -20px -20px -20px; 3 | overflow: hidden; 4 | color: #555555; 5 | background: #f4fce8; 6 | border-top: 1px solid #ededed; 7 | padding: 0 20px; 8 | line-height: 37px; 9 | border-radius: 0 0 5px 5px; 10 | } 11 | 12 | #clear-completed { 13 | float: right; 14 | line-height: 20px; 15 | text-decoration: none; 16 | background: rgba(0, 0, 0, 0.1); 17 | color: #555555; 18 | font-size: 11px; 19 | margin-top: 8px; 20 | margin-bottom: 8px; 21 | padding: 0 10px 1px; 22 | cursor: pointer; 23 | border-radius: 12px; 24 | box-shadow: rgba(0, 0, 0, 0.2) 0 -1px 0 0; 25 | } 26 | 27 | #clear-completed:hover { 28 | background: rgba(0, 0, 0, 0.15); 29 | box-shadow: rgba(0, 0, 0, 0.3) 0 -1px 0 0; 30 | } 31 | 32 | #clear-completed:active { 33 | position: relative; 34 | top: 1px; 35 | } 36 | 37 | #todo-count span { 38 | font-weight: bold; 39 | } 40 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React.js Todos 7 | 9 | 10 | 11 | 12 |
13 |
14 | Double-click to edit a todo. 15 |
16 |
17 | Created by: 18 | Ross Allen
19 | The source: 20 | ssorallen/react-todos
21 | Adapted from: 22 | Backbone.js Todos 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/TodoListItem.css: -------------------------------------------------------------------------------- 1 | #todo-list li { 2 | padding: 18px 20px 18px 0; 3 | position: relative; 4 | font-size: 24px; 5 | border-bottom: 1px solid #cccccc; 6 | } 7 | 8 | #todo-list li:last-child { 9 | border-bottom: none; 10 | } 11 | 12 | #todo-list li.done label { 13 | color: #777777; 14 | text-decoration: line-through; 15 | } 16 | 17 | #todo-list .destroy { 18 | position: absolute; 19 | right: 5px; 20 | top: 20px; 21 | display: none; 22 | cursor: pointer; 23 | width: 20px; 24 | height: 20px; 25 | background: url(img/destroy.png) no-repeat; 26 | text-indent: -9999px; 27 | } 28 | 29 | #todo-list li:hover .destroy { 30 | display: block; 31 | } 32 | 33 | #todo-list .destroy:hover { 34 | background-position: 0 -20px; 35 | } 36 | 37 | #todo-list li.editing { 38 | border-bottom: none; 39 | margin-top: -1px; 40 | padding: 0; 41 | } 42 | 43 | #todo-list li.editing:last-child { 44 | margin-bottom: -1px; 45 | } 46 | 47 | #todo-list li .view label { 48 | word-break: break-word; 49 | } 50 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --text-secondary: #777777; 3 | } 4 | 5 | html, 6 | body { 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | body { 12 | font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; 13 | line-height: 1.4em; 14 | background: #eeeeee; 15 | color: #333333; 16 | width: 520px; 17 | margin: 0 auto; 18 | -webkit-font-smoothing: antialiased; 19 | } 20 | 21 | #todoapp { 22 | background: #fff; 23 | padding: 20px; 24 | margin-bottom: 40px; 25 | box-shadow: rgba(0, 0, 0, 0.2) 0 2px 6px 0; 26 | border-radius: 0 0 5px 5px; 27 | } 28 | 29 | #todoapp h1 { 30 | font-size: 36px; 31 | font-weight: bold; 32 | text-align: center; 33 | padding: 0 0 10px 0; 34 | } 35 | 36 | #instructions { 37 | margin: 10px auto; 38 | color: var(--text-secondary); 39 | text-shadow: rgba(255, 255, 255, 0.8) 0 1px 0; 40 | text-align: center; 41 | } 42 | 43 | #credits { 44 | margin: 30px auto; 45 | color: var(--text-secondary); 46 | text-shadow: rgba(255, 255, 255, 0.8) 0 1px 0; 47 | text-align: center; 48 | } 49 | 50 | #credits a { 51 | color: var(--text-secondary); 52 | } 53 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ross Allen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "react-todos", 4 | "version": "2.0.0", 5 | "description": "Backbone's example TODO app with React for views", 6 | "main": "todos.js", 7 | "directories": { 8 | "doc": "docs" 9 | }, 10 | "scripts": { 11 | "build": "react-scripts build", 12 | "deploy": "gh-pages -d build", 13 | "eject": "react-scripts eject", 14 | "flow": "flow", 15 | "predeploy": "npm run build", 16 | "start": "react-scripts start", 17 | "test": "react-scripts test --env=jsdom" 18 | }, 19 | "repository": "ssorallen/react-todos", 20 | "keywords": [ 21 | "backbone", 22 | "reactjs", 23 | "todos" 24 | ], 25 | "author": "Ross Allen ", 26 | "license": "Apache", 27 | "bugs": { 28 | "url": "https://github.com/ssorallen/react-todos/issues" 29 | }, 30 | "homepage": "http://www.ssorallen.com/react-todos/", 31 | "dependencies": { 32 | "react": "^16.8.6", 33 | "react-dom": "^16.8.6" 34 | }, 35 | "devDependencies": { 36 | "flow-bin": "^0.95.1", 37 | "gh-pages": "^1.0.0", 38 | "prettier": "^1.12.1", 39 | "react-scripts": "^1.0.14" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Footer.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import './Footer.css'; 4 | import * as React from 'react'; 5 | 6 | // Footer Component 7 | // ---------------- 8 | 9 | type Props = { 10 | clearCompletedItems: (event: SyntheticMouseEvent) => void, 11 | itemsDoneCount: number, 12 | itemsRemainingCount: number, 13 | }; 14 | 15 | // The footer shows the total number of todos and how many are completed. 16 | export default function Footer(props: Props) { 17 | let clearCompletedButton; 18 | 19 | // Show the "Clear X completed items" button only if there are completed 20 | // items. 21 | if (props.itemsDoneCount > 0) { 22 | clearCompletedButton = ( 23 | 24 | Clear {props.itemsDoneCount} completed 25 | {1 === props.itemsDoneCount ? ' item' : ' items'} 26 | 27 | ); 28 | } 29 | 30 | // Clicking the "Clear X completed items" button calls the 31 | // "clearCompletedItems" function passed in on `props`. 32 | return ( 33 |
34 | {clearCompletedButton} 35 |
36 | {props.itemsRemainingCount} 37 | {1 === props.itemsRemainingCount ? ' item' : ' items'} left 38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/TodoList.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import './TodoList.css'; 4 | import * as React from 'react'; 5 | import type { Todo } from './Types'; 6 | import TodoListItem from './TodoListItem'; 7 | 8 | // Todo List Component 9 | // ------------------- 10 | 11 | type Props = { 12 | onDestroyTodo: (Todo: Todo) => void, 13 | onSetTodoTitle: (Todo: Todo, title: string) => void, 14 | onToggleTodoCompleted: (Todo: Todo, done: boolean) => void, 15 | todos: Array, 16 | }; 17 | 18 | export default function TodoList(props: Props) { 19 | const [editingTodo, setEditingTodo] = React.useState(null); 20 | 21 | return ( 22 |
    23 | {props.todos.map((todo, index) => ( 24 | { 28 | props.onDestroyTodo(todo); 29 | }} 30 | onSetTitle={title => { 31 | props.onSetTodoTitle(todo, title); 32 | }} 33 | onStartEditing={() => { 34 | setEditingTodo(todo); 35 | }} 36 | onStopEditing={() => { 37 | if (todo === editingTodo) { 38 | setEditingTodo(null); 39 | } 40 | }} 41 | onToggleCompleted={() => { 42 | props.onToggleTodoCompleted(todo, !todo.done); 43 | }} 44 | todo={todo} 45 | /> 46 | ))} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/Body.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import './Body.css'; 4 | import * as React from 'react'; 5 | import Footer from './Footer'; 6 | import type { Todo } from './Types'; 7 | import TodoList from './TodoList'; 8 | 9 | // Main Component 10 | // -------------- 11 | 12 | interface Props { 13 | onClearCompletedTodos: () => void; 14 | onDestroyTodo: (Todo: Todo) => void; 15 | onSetTodoTitle: (Todo: Todo, title: string) => void; 16 | onToggleTodoCompleted: (Todo: Todo, done: boolean) => void; 17 | onToggleAllTodosCompleted: (toggle: boolean) => void; 18 | todos: Array; 19 | } 20 | 21 | // The main component contains the list of todos and the footer. 22 | export default function Body(props: Props) { 23 | // Tell the **App** to toggle the *done* state of all **Todo** items. 24 | const toggleAllTodosCompleted = (event: SyntheticInputEvent) => { 25 | props.onToggleAllTodosCompleted(event.target.checked); 26 | }; 27 | 28 | return ( 29 |
30 | t.done)} 34 | onChange={toggleAllTodosCompleted} 35 | /> 36 | 37 | 43 |
{ 46 | if (!t.done) count += 1; 47 | return count; 48 | }, 0)} 49 | itemsDoneCount={props.todos.reduce((count, t) => { 50 | if (t.done) count += 1; 51 | return count; 52 | }, 0)} 53 | /> 54 |
55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/TodoListItem.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import './TodoListItem.css'; 4 | import * as React from 'react'; 5 | import type { Todo } from './Types'; 6 | 7 | // Todo List Item Component 8 | // ------------------------ 9 | 10 | type Props = { 11 | editing: boolean, 12 | onDestroy: () => void, 13 | onSetTitle: (title: string) => void, 14 | onStartEditing: () => void, 15 | onStopEditing: () => void, 16 | onToggleCompleted: (done: boolean) => void, 17 | todo: Todo, 18 | }; 19 | 20 | export default function TodoListItem(props: Props) { 21 | const inputEl = React.createRef(); 22 | 23 | function setTitleAndStopEditing() { 24 | if (inputEl.current != null) props.onSetTitle(inputEl.current.value); 25 | props.onStopEditing(); 26 | } 27 | 28 | // // Stop editing if the input gets an "Enter" keypress. 29 | function handleEditKeyPress(event: SyntheticKeyboardEvent) { 30 | if (event.key === 'Enter') { 31 | setTitleAndStopEditing(); 32 | } 33 | } 34 | 35 | return ( 36 |
  • 37 | {props.editing ? ( 38 | 47 | ) : ( 48 |
    49 | 55 | 56 | 57 | Destroy 58 | 59 |
    60 | )} 61 |
  • 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | /* @flow */ 2 | 3 | import './App.css'; 4 | import * as React from 'react'; 5 | import Body from './Body'; 6 | import type { Todo } from './Types'; 7 | 8 | type State = { 9 | todos: Array, 10 | }; 11 | 12 | const initialState = { 13 | todos: [], 14 | }; 15 | 16 | function reducer(state: State, action) { 17 | switch (action.type) { 18 | case 'clear-completed-todos': 19 | return { 20 | ...state, 21 | todos: state.todos.filter(t => !t.done), 22 | }; 23 | case 'create-todo': 24 | return { 25 | ...state, 26 | todos: [ 27 | ...state.todos, 28 | { 29 | done: false, 30 | title: action.title, 31 | }, 32 | ], 33 | }; 34 | case 'destroy-todo': { 35 | const nextTodos = state.todos.slice(); 36 | nextTodos.splice(nextTodos.indexOf(action.todo), 1); 37 | return { 38 | ...state, 39 | todos: nextTodos, 40 | }; 41 | } 42 | case 'set-todo-title': { 43 | const nextTodos = state.todos.slice(); 44 | nextTodos.splice(nextTodos.indexOf(action.todo), 1, { ...action.todo, title: action.title }); 45 | return { 46 | ...state, 47 | todos: nextTodos, 48 | }; 49 | } 50 | case 'toggle-todo-completed': { 51 | const nextTodos = state.todos.slice(); 52 | nextTodos.splice(nextTodos.indexOf(action.todo), 1, { ...action.todo, done: action.done }); 53 | return { 54 | ...state, 55 | todos: nextTodos, 56 | }; 57 | } 58 | case 'toggle-all-todos-completed': 59 | return { 60 | ...state, 61 | todos: state.todos.map(t => ({ ...t, done: action.done })), 62 | }; 63 | default: 64 | return state; 65 | } 66 | } 67 | 68 | export default function App() { 69 | // Store the app's todos in local storage 70 | const [state, dispatch] = React.useReducer( 71 | reducer, 72 | JSON.parse(window.localStorage.getItem('react-todos')) || initialState 73 | ); 74 | React.useEffect( 75 | () => { 76 | window.localStorage.setItem('react-todos', JSON.stringify(state)); 77 | }, 78 | [state] 79 | ); 80 | const formEl = React.useRef(null); 81 | 82 | // If "Enter" is pressed in the main input field, it will submit the form. 83 | // Create a new **Todo** and reset the title. 84 | const handleTitleFormSubmit = (event: SyntheticEvent) => { 85 | event.preventDefault(); 86 | const titleEl = event.currentTarget.elements['title']; 87 | if (!(titleEl instanceof HTMLInputElement)) return; 88 | const title = titleEl.value; 89 | if (title === '') return; 90 | dispatch({ title, type: 'create-todo' }); 91 | if (formEl.current != null) formEl.current.reset(); 92 | }; 93 | 94 | return ( 95 | 96 |
    97 |

    Todos

    98 |
    99 | 100 |
    101 |
    102 | {/* Don't display the "Mark all as complete" button and the footer if there 103 | are no **Todo** items. */} 104 | {state.todos.length === 0 ? null : ( 105 | { 107 | dispatch({ type: 'clear-completed-todos' }); 108 | }} 109 | onDestroyTodo={todo => { 110 | dispatch({ todo, type: 'destroy-todo' }); 111 | }} 112 | onSetTodoTitle={(todo, title) => { 113 | dispatch({ title, todo, type: 'set-todo-title' }); 114 | }} 115 | onToggleTodoCompleted={(todo, done) => { 116 | dispatch({ 117 | done, 118 | todo, 119 | type: 'toggle-todo-completed', 120 | }); 121 | }} 122 | onToggleAllTodosCompleted={done => { 123 | dispatch({ done, type: 'toggle-all-todos-completed' }); 124 | }} 125 | todos={state.todos} 126 | /> 127 | )} 128 |
    129 | ); 130 | } 131 | --------------------------------------------------------------------------------