├── LICENSE
├── README.md
├── index.html
├── script.js
├── script.min.js
└── style.css
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Tania Rascia
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🔱 MVC.js
2 | [](https://opensource.org/licenses/MIT)
3 |
4 | Simple todo MVC application in plain JavaScript
5 |
6 | ### [Read the tutorial](https://www.taniarascia.com/javascript-mvc-todo-app) | [View the demo](https://taniarascia.github.io/mvc)
7 |
8 | ## Purpose
9 |
10 | Learn the MVC pattern by building a small app!
11 |
12 | - **Model** - manages the data of an application
13 | - **View** - a visual representation of the model
14 | - **Controller** - links the user and the system
15 |
16 | This application consists of `index.html`, `script.js`, and `style.css`. This means that there are no frameworks or dependencies getting in the way of learning the MVC concept.
17 |
18 | ## Author
19 |
20 | - [Tania Rascia](https://www.taniarascia.com)
21 |
22 | ## License
23 |
24 | This project is open source and available under the [MIT License](LICENSE).
25 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Todo App
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/script.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @class Model
3 | *
4 | * Manages the data of the application.
5 | */
6 | class Model {
7 | constructor() {
8 | this.todos = JSON.parse(localStorage.getItem('todos')) || []
9 | }
10 |
11 | bindTodoListChanged(callback) {
12 | this.onTodoListChanged = callback
13 | }
14 |
15 | _commit(todos) {
16 | this.onTodoListChanged(todos)
17 | localStorage.setItem('todos', JSON.stringify(todos))
18 | }
19 |
20 | addTodo(todoText) {
21 | const todo = {
22 | id: this.todos.length > 0 ? this.todos[this.todos.length - 1].id + 1 : 1,
23 | text: todoText,
24 | complete: false,
25 | }
26 |
27 | this.todos.push(todo)
28 |
29 | this._commit(this.todos)
30 | }
31 |
32 | editTodo(id, updatedText) {
33 | this.todos = this.todos.map(todo =>
34 | todo.id === id ? { id: todo.id, text: updatedText, complete: todo.complete } : todo
35 | )
36 |
37 | this._commit(this.todos)
38 | }
39 |
40 | deleteTodo(id) {
41 | this.todos = this.todos.filter(todo => todo.id !== id)
42 |
43 | this._commit(this.todos)
44 | }
45 |
46 | toggleTodo(id) {
47 | this.todos = this.todos.map(todo =>
48 | todo.id === id ? { id: todo.id, text: todo.text, complete: !todo.complete } : todo
49 | )
50 |
51 | this._commit(this.todos)
52 | }
53 | }
54 |
55 | /**
56 | * @class View
57 | *
58 | * Visual representation of the model.
59 | */
60 | class View {
61 | constructor() {
62 | this.app = this.getElement('#root')
63 | this.form = this.createElement('form')
64 | this.input = this.createElement('input')
65 | this.input.type = 'text'
66 | this.input.placeholder = 'Add todo'
67 | this.input.name = 'todo'
68 | this.submitButton = this.createElement('button')
69 | this.submitButton.textContent = 'Submit'
70 | this.form.append(this.input, this.submitButton)
71 | this.title = this.createElement('h1')
72 | this.title.textContent = 'Todos'
73 | this.todoList = this.createElement('ul', 'todo-list')
74 | this.app.append(this.title, this.form, this.todoList)
75 |
76 | this._temporaryTodoText = ''
77 | this._initLocalListeners()
78 | }
79 |
80 | get _todoText() {
81 | return this.input.value
82 | }
83 |
84 | _resetInput() {
85 | this.input.value = ''
86 | }
87 |
88 | createElement(tag, className) {
89 | const element = document.createElement(tag)
90 |
91 | if (className) element.classList.add(className)
92 |
93 | return element
94 | }
95 |
96 | getElement(selector) {
97 | const element = document.querySelector(selector)
98 |
99 | return element
100 | }
101 |
102 | displayTodos(todos) {
103 | // Delete all nodes
104 | while (this.todoList.firstChild) {
105 | this.todoList.removeChild(this.todoList.firstChild)
106 | }
107 |
108 | // Show default message
109 | if (todos.length === 0) {
110 | const p = this.createElement('p')
111 | p.textContent = 'Nothing to do! Add a task?'
112 | this.todoList.append(p)
113 | } else {
114 | // Create nodes
115 | todos.forEach(todo => {
116 | const li = this.createElement('li')
117 | li.id = todo.id
118 |
119 | const checkbox = this.createElement('input')
120 | checkbox.type = 'checkbox'
121 | checkbox.checked = todo.complete
122 |
123 | const span = this.createElement('span')
124 | span.contentEditable = true
125 | span.classList.add('editable')
126 |
127 | if (todo.complete) {
128 | const strike = this.createElement('s')
129 | strike.textContent = todo.text
130 | span.append(strike)
131 | } else {
132 | span.textContent = todo.text
133 | }
134 |
135 | const deleteButton = this.createElement('button', 'delete')
136 | deleteButton.textContent = 'Delete'
137 | li.append(checkbox, span, deleteButton)
138 |
139 | // Append nodes
140 | this.todoList.append(li)
141 | })
142 | }
143 |
144 | // Debugging
145 | console.log(todos)
146 | }
147 |
148 | _initLocalListeners() {
149 | this.todoList.addEventListener('input', event => {
150 | if (event.target.className === 'editable') {
151 | this._temporaryTodoText = event.target.innerText
152 | }
153 | })
154 | }
155 |
156 | bindAddTodo(handler) {
157 | this.form.addEventListener('submit', event => {
158 | event.preventDefault()
159 |
160 | if (this._todoText) {
161 | handler(this._todoText)
162 | this._resetInput()
163 | }
164 | })
165 | }
166 |
167 | bindDeleteTodo(handler) {
168 | this.todoList.addEventListener('click', event => {
169 | if (event.target.className === 'delete') {
170 | const id = parseInt(event.target.parentElement.id)
171 |
172 | handler(id)
173 | }
174 | })
175 | }
176 |
177 | bindEditTodo(handler) {
178 | this.todoList.addEventListener('focusout', event => {
179 | if (this._temporaryTodoText) {
180 | const id = parseInt(event.target.parentElement.id)
181 |
182 | handler(id, this._temporaryTodoText)
183 | this._temporaryTodoText = ''
184 | }
185 | })
186 | }
187 |
188 | bindToggleTodo(handler) {
189 | this.todoList.addEventListener('change', event => {
190 | if (event.target.type === 'checkbox') {
191 | const id = parseInt(event.target.parentElement.id)
192 |
193 | handler(id)
194 | }
195 | })
196 | }
197 | }
198 |
199 | /**
200 | * @class Controller
201 | *
202 | * Links the user input and the view output.
203 | *
204 | * @param model
205 | * @param view
206 | */
207 | class Controller {
208 | constructor(model, view) {
209 | this.model = model
210 | this.view = view
211 |
212 | // Explicit this binding
213 | this.model.bindTodoListChanged(this.onTodoListChanged)
214 | this.view.bindAddTodo(this.handleAddTodo)
215 | this.view.bindEditTodo(this.handleEditTodo)
216 | this.view.bindDeleteTodo(this.handleDeleteTodo)
217 | this.view.bindToggleTodo(this.handleToggleTodo)
218 |
219 | // Display initial todos
220 | this.onTodoListChanged(this.model.todos)
221 | }
222 |
223 | onTodoListChanged = todos => {
224 | this.view.displayTodos(todos)
225 | }
226 |
227 | handleAddTodo = todoText => {
228 | this.model.addTodo(todoText)
229 | }
230 |
231 | handleEditTodo = (id, todoText) => {
232 | this.model.editTodo(id, todoText)
233 | }
234 |
235 | handleDeleteTodo = id => {
236 | this.model.deleteTodo(id)
237 | }
238 |
239 | handleToggleTodo = id => {
240 | this.model.toggleTodo(id)
241 | }
242 | }
243 |
244 | const app = new Controller(new Model(), new View())
245 |
--------------------------------------------------------------------------------
/script.min.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
4 |
5 | function _instanceof(left, right) { if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) { return !!right[Symbol.hasInstance](left); } else { return left instanceof right; } }
6 |
7 | function _classCallCheck(instance, Constructor) { if (!_instanceof(instance, Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
8 |
9 | function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
10 |
11 | function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
12 |
13 | /**
14 | * @class Model
15 | *
16 | * Manages the data of the application.
17 | */
18 | var Model =
19 | /*#__PURE__*/
20 | function () {
21 | function Model() {
22 | _classCallCheck(this, Model);
23 |
24 | this.todos = JSON.parse(localStorage.getItem('todos')) || [];
25 | }
26 |
27 | _createClass(Model, [{
28 | key: "bindTodoListChanged",
29 | value: function bindTodoListChanged(callback) {
30 | this.onTodoListChanged = callback;
31 | }
32 | }, {
33 | key: "_commit",
34 | value: function _commit(todos) {
35 | this.onTodoListChanged(todos);
36 | localStorage.setItem('todos', JSON.stringify(todos));
37 | }
38 | }, {
39 | key: "addTodo",
40 | value: function addTodo(todoText) {
41 | var todo = {
42 | id: this.todos.length > 0 ? this.todos[this.todos.length - 1].id + 1 : 1,
43 | text: todoText,
44 | complete: false
45 | };
46 | this.todos.push(todo);
47 |
48 | this._commit(this.todos);
49 | }
50 | }, {
51 | key: "editTodo",
52 | value: function editTodo(id, updatedText) {
53 | this.todos = this.todos.map(function (todo) {
54 | return todo.id === id ? {
55 | id: todo.id,
56 | text: updatedText,
57 | complete: todo.complete
58 | } : todo;
59 | });
60 |
61 | this._commit(this.todos);
62 | }
63 | }, {
64 | key: "deleteTodo",
65 | value: function deleteTodo(id) {
66 | this.todos = this.todos.filter(function (todo) {
67 | return todo.id !== id;
68 | });
69 |
70 | this._commit(this.todos);
71 | }
72 | }, {
73 | key: "toggleTodo",
74 | value: function toggleTodo(id) {
75 | this.todos = this.todos.map(function (todo) {
76 | return todo.id === id ? {
77 | id: todo.id,
78 | text: todo.text,
79 | complete: !todo.complete
80 | } : todo;
81 | });
82 |
83 | this._commit(this.todos);
84 | }
85 | }]);
86 |
87 | return Model;
88 | }();
89 | /**
90 | * @class View
91 | *
92 | * Visual representation of the model.
93 | */
94 |
95 |
96 | var View =
97 | /*#__PURE__*/
98 | function () {
99 | function View() {
100 | _classCallCheck(this, View);
101 |
102 | this.app = this.getElement('#root');
103 | this.form = this.createElement('form');
104 | this.input = this.createElement('input');
105 | this.input.type = 'text';
106 | this.input.placeholder = 'Add todo';
107 | this.input.name = 'todo';
108 | this.submitButton = this.createElement('button');
109 | this.submitButton.textContent = 'Submit';
110 | this.form.append(this.input, this.submitButton);
111 | this.title = this.createElement('h1');
112 | this.title.textContent = 'Todos';
113 | this.todoList = this.createElement('ul', 'todo-list');
114 | this.app.append(this.title, this.form, this.todoList);
115 | this._temporaryTodoText = '';
116 |
117 | this._initLocalListeners();
118 | }
119 |
120 | _createClass(View, [{
121 | key: "_resetInput",
122 | value: function _resetInput() {
123 | this.input.value = '';
124 | }
125 | }, {
126 | key: "createElement",
127 | value: function createElement(tag, className) {
128 | var element = document.createElement(tag);
129 | if (className) element.classList.add(className);
130 | return element;
131 | }
132 | }, {
133 | key: "getElement",
134 | value: function getElement(selector) {
135 | var element = document.querySelector(selector);
136 | return element;
137 | }
138 | }, {
139 | key: "displayTodos",
140 | value: function displayTodos(todos) {
141 | var _this = this;
142 |
143 | // Delete all nodes
144 | while (this.todoList.firstChild) {
145 | this.todoList.removeChild(this.todoList.firstChild);
146 | } // Show default message
147 |
148 |
149 | if (todos.length === 0) {
150 | var p = this.createElement('p');
151 | p.textContent = 'Nothing to do! Add a task?';
152 | this.todoList.append(p);
153 | } else {
154 | // Create nodes
155 | todos.forEach(function (todo) {
156 | var li = _this.createElement('li');
157 |
158 | li.id = todo.id;
159 |
160 | var checkbox = _this.createElement('input');
161 |
162 | checkbox.type = 'checkbox';
163 | checkbox.checked = todo.complete;
164 |
165 | var span = _this.createElement('span');
166 |
167 | span.contentEditable = true;
168 | span.classList.add('editable');
169 |
170 | if (todo.complete) {
171 | var strike = _this.createElement('s');
172 |
173 | strike.textContent = todo.text;
174 | span.append(strike);
175 | } else {
176 | span.textContent = todo.text;
177 | }
178 |
179 | var deleteButton = _this.createElement('button', 'delete');
180 |
181 | deleteButton.textContent = 'Delete';
182 | li.append(checkbox, span, deleteButton); // Append nodes
183 |
184 | _this.todoList.append(li);
185 | });
186 | } // Debugging
187 |
188 |
189 | console.log(todos);
190 | }
191 | }, {
192 | key: "_initLocalListeners",
193 | value: function _initLocalListeners() {
194 | var _this2 = this;
195 |
196 | this.todoList.addEventListener('input', function (event) {
197 | if (event.target.className === 'editable') {
198 | _this2._temporaryTodoText = event.target.innerText;
199 | }
200 | });
201 | }
202 | }, {
203 | key: "bindAddTodo",
204 | value: function bindAddTodo(handler) {
205 | var _this3 = this;
206 |
207 | this.form.addEventListener('submit', function (event) {
208 | event.preventDefault();
209 |
210 | if (_this3._todoText) {
211 | handler(_this3._todoText);
212 |
213 | _this3._resetInput();
214 | }
215 | });
216 | }
217 | }, {
218 | key: "bindDeleteTodo",
219 | value: function bindDeleteTodo(handler) {
220 | this.todoList.addEventListener('click', function (event) {
221 | if (event.target.className === 'delete') {
222 | var id = parseInt(event.target.parentElement.id);
223 | handler(id);
224 | }
225 | });
226 | }
227 | }, {
228 | key: "bindEditTodo",
229 | value: function bindEditTodo(handler) {
230 | var _this4 = this;
231 |
232 | this.todoList.addEventListener('focusout', function (event) {
233 | if (_this4._temporaryTodoText) {
234 | var id = parseInt(event.target.parentElement.id);
235 | handler(id, _this4._temporaryTodoText);
236 | _this4._temporaryTodoText = '';
237 | }
238 | });
239 | }
240 | }, {
241 | key: "bindToggleTodo",
242 | value: function bindToggleTodo(handler) {
243 | this.todoList.addEventListener('change', function (event) {
244 | if (event.target.type === 'checkbox') {
245 | var id = parseInt(event.target.parentElement.id);
246 | handler(id);
247 | }
248 | });
249 | }
250 | }, {
251 | key: "_todoText",
252 | get: function get() {
253 | return this.input.value;
254 | }
255 | }]);
256 |
257 | return View;
258 | }();
259 | /**
260 | * @class Controller
261 | *
262 | * Links the user input and the view output.
263 | *
264 | * @param model
265 | * @param view
266 | */
267 |
268 |
269 | var Controller = function Controller(model, view) {
270 | var _this5 = this;
271 |
272 | _classCallCheck(this, Controller);
273 |
274 | _defineProperty(this, "onTodoListChanged", function (todos) {
275 | _this5.view.displayTodos(todos);
276 | });
277 |
278 | _defineProperty(this, "handleAddTodo", function (todoText) {
279 | _this5.model.addTodo(todoText);
280 | });
281 |
282 | _defineProperty(this, "handleEditTodo", function (id, todoText) {
283 | _this5.model.editTodo(id, todoText);
284 | });
285 |
286 | _defineProperty(this, "handleDeleteTodo", function (id) {
287 | _this5.model.deleteTodo(id);
288 | });
289 |
290 | _defineProperty(this, "handleToggleTodo", function (id) {
291 | _this5.model.toggleTodo(id);
292 | });
293 |
294 | this.model = model;
295 | this.view = view; // Explicit this binding
296 |
297 | this.model.bindTodoListChanged(this.onTodoListChanged);
298 | this.view.bindAddTodo(this.handleAddTodo);
299 | this.view.bindEditTodo(this.handleEditTodo);
300 | this.view.bindDeleteTodo(this.handleDeleteTodo);
301 | this.view.bindToggleTodo(this.handleToggleTodo); // Display initial todos
302 |
303 | this.onTodoListChanged(this.model.todos);
304 | };
305 |
306 | var app = new Controller(new Model(), new View());
--------------------------------------------------------------------------------
/style.css:
--------------------------------------------------------------------------------
1 | *,
2 | *::before,
3 | *::after {
4 | box-sizing: border-box
5 | }
6 |
7 | html {
8 | font-family: sans-serif;
9 | font-size: 1rem;
10 | color: #444;
11 | }
12 |
13 | #root {
14 | max-width: 450px;
15 | margin: 2rem auto;
16 | padding: 0 1rem;
17 | }
18 |
19 | form {
20 | display: flex;
21 | margin-bottom: 2rem;
22 | }
23 |
24 | [type="text"],
25 | button {
26 | display: inline-block;
27 | -webkit-appearance: none;
28 | padding: .5rem 1rem;
29 | font-size: 1rem;
30 | border: 2px solid #ccc;
31 | border-radius: 4px;
32 | }
33 |
34 | button {
35 | cursor: pointer;
36 | background: #007bff;
37 | color: white;
38 | border: 2px solid #007bff;
39 | margin: 0 .5rem;
40 | }
41 |
42 | [type="text"] {
43 | width: 100%;
44 | }
45 |
46 | [type="text"]:active,
47 | [type="text"]:focus {
48 | outline: 0;
49 | border: 2px solid #007bff;
50 | }
51 |
52 | [type="checkbox"] {
53 | margin-right: 1rem;
54 | font-size: 2rem;
55 | }
56 |
57 | h1 {
58 | color: #222;
59 | }
60 |
61 | ul {
62 | padding: 0;
63 | }
64 |
65 | li {
66 | display: flex;
67 | align-items: center;
68 | padding: 1rem;
69 | margin-bottom: 1rem;
70 | background: #f4f4f4;
71 | border-radius: 4px;
72 | }
73 |
74 | li span {
75 | display: inline-block;
76 | padding: .5rem;
77 | width: 250px;
78 | border-radius: 4px;
79 | border: 2px solid transparent;
80 | }
81 |
82 | li span:hover {
83 | background: rgba(179, 215, 255, 0.52);
84 | }
85 |
86 | li span:focus {
87 | outline: 0;
88 | border: 2px solid #007bff;
89 | background: rgba(179, 207, 255, 0.52)
90 | }
--------------------------------------------------------------------------------