/** * @file Todo application with formotor.js. * @author Felix Yang */ /* eslint-disable-next-line */ jQuery(function($) { 'use strict' var Global = window || {} var Router = Global.Router || {} var Handlebars = Global.Handlebars || {} var Formotor = Global.Formotor || {} var KEYBOARD = { ENTER_KEY: 13, ESCAPE_KEY: 27 } /** * Create application instance with tiny MVC. * @param {object} options * @returns {object} */ var createMvcApp = (function () { var App = { version: '1.0.0' } var util = { pluralize: function (count, word) { return count === 1 ? word : word + 's' }, compile: function (selector) { return Handlebars.compile($(selector).html()) } } /** * Formotor component Config * @property {object} todo-header - todo input box. * @property {object} todo-content - todo list. * @property {object} todo-footer - todo status bar. */ App.components = { 'todo-header': { ready: function () { this.build() }, methods: { build: function () { this.bindEvents() }, bindEvents: function () { this.$el.on('keyup', '.new-todo', this.create) this.$listen('tree:render', this.render) }, render: function () { this.$find('.new-todo').focus() }, create: function (e) { var self = this var $element = $(e.target) var todoTask = $element.val().trim() if (e.which !== KEYBOARD.ENTER_KEY || !todoTask) { return } self.model.addTodo({ title: todoTask, completed: false }) $element.val('') self.$broadcast('tree:render') } } }, 'todo-content': { ready: function () { this.build() }, methods: { build: function () { this.todoTemplate = util.compile('#todo-template') this.bindEvents() }, bindEvents: function () { this.$el .on('change', '.toggle-all', this.toggleAll) .on('change', '.toggle', this.toggle) .on('dblclick', '.title', this.editTodo) .on('keyup', '.edit', this.editKeyup) .on('focusout', '.edit', this.update) .on('click', '.destroy', this.destroy) this.$listen('tree:render', this.render) }, render: function () { var self = this var model = self.model var todos = model.getFilteredTodos() self.$el.toggle(todos.length > 0) self.$find('.todo-list').html(self.todoTemplate(todos)) self .$find('.toggle-all') .prop('checked', model.getActiveTodos().length === 0) }, toggleAll: function (e) { var self = this var completed = $(e.target).prop('checked') self.model.toggleAll(completed) self.$broadcast('tree:render') }, toggle: function (e) { var self = this var $el = $(e.target) var id = $el.closest('li').data('id') self.model.toggle(id) self.$broadcast('tree:render') }, editTodo: function (e) { var $input = $(e.target) .closest('li') .addClass('editing') .find('.edit') var tempValue = $input.val() $input .val('') .val(tempValue) .focus() }, editKeyup: function (e) { if (e.which === KEYBOARD.ENTER_KEY) { e.target.blur() } if (e.which === KEYBOARD.ESCAPE_KEY) { $(e.target) .data('abort', true) .blur() } }, update: function (e) { var self = this var model = self.model var $el = $(e.target) var id = $el.closest('li').data('id') var todoTask = $el.val().trim() if ($el.data('abort')) { $el.data('abort', false) } else if (!todoTask) { model.destroy(id) return } else { model.updateTodo(id, todoTask) } self.$broadcast('tree:render') }, destroy: function (e) { var self = this var $el = $(e.target) var id = $el.closest('li').data('id') self.model.destroy(id) self.$broadcast('tree:render') } } }, 'todo-footer': { ready: function () { this.build() }, methods: { build: function () { this.footerTemplate = util.compile('#footer-template') this.bindEvents() }, bindEvents: function () { this.$el.on('click', '.clear-completed', this.destroyCompleted) this.$listen('tree:render', this.render) }, render: function () { var self = this var model = self.model var todoCount = model.getTodos().length var activeTodoCount = model.getActiveTodos().length var template = self.footerTemplate({ activeTodoCount: activeTodoCount, activeTodoWord: util.pluralize(activeTodoCount, 'item'), completedTodos: todoCount - activeTodoCount, filter: model.getFilter() }) self.$el.toggle(todoCount > 0).html(template) }, destroyCompleted: function () { var self = this self.model.destroyCompleted() self.$broadcast('tree:render') } } } } /** * Create a MVC Model. * @class * @param {object} options */ App.Model = function mvcModel (options) { this.options = options || {} this.init() } App.Model.prototype = { constructor: App.Model, init: function () { this.data = this.options.data || {} }, getTodos: function () { return this.data.todos }, setTodos: function (todos) { this.data.todos = todos }, getFilter: function () { return this.data.filter }, setFilter: function (filter) { this.data.filter = filter }, addTodo: function (todo) { todo = $.extend( { id: this.genUuid() }, todo ) this.data.todos.push(todo) }, getFilteredTodos: function () { var filter = this.getFilter() if (filter === 'active') { return this.getActiveTodos() } if (filter === 'completed') { return this.getCompletedTodos() } return this.data.todos }, getActiveTodos: function () { return this.data.todos.filter(function (todo) { return !todo.completed }) }, getCompletedTodos: function () { return this.data.todos.filter(function (todo) { return todo.completed }) }, getTodoById: function (id) { return this.data.todos.find(function (todo) { return todo.id === id }) }, updateTodo: function (id, title) { var todo = this.getTodoById(id) if (todo) { todo.title = title } }, toggle: function (id) { var todo = this.getTodoById(id) if (todo) { todo.completed = !todo.completed } }, toggleAll: function (completed) { this.data.todos.forEach(function (todo) { todo.completed = completed }) }, destroy: function (id) { this.data.todos = this.data.todos.filter(function (todo) { return todo.id !== id }) }, destroyCompleted: function () { this.setTodos(this.getActiveTodos()) }, genUuid: function () { var i, random var uuid = '' for (i = 0; i < 32; i++) { random = (Math.random() * 16) | 0 if (i === 8 || i === 12 || i === 16 || i === 20) { uuid += '-' } uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random ).toString(16) } return uuid } } /** * Create a MVC View. * @class * @param {object} options */ App.View = function mvcView (options) { this.options = options || {} this.init() } App.View.prototype = { constructor: App.View, init: function () { this.$el = $(this.options.el) }, render: function () { if (!this.formotorTree) { this.createFormotorTree() } this.formotorTree.$broadcast('tree:render') }, createFormotorTree: function () { var self = this /** @member {Formotor} */ self.formotorTree = new Formotor({ el: self.$el, data: { model: self.controller.getModel() }, components: App.components }) } } /** * Create a MVC Controller. * @class * @param {object} options */ App.Controller = function mvcController (options) { this.options = options || {} this.view = this.options.view this.model = this.options.model this.init(options) } App.Controller.prototype = { constructor: App.Controller, init: function () { this.view.controller = this.model.controller = this this.router = new Router({ '/:filter': function (filter) { this.model.setFilter(filter) this.view.render() }.bind(this) }).init('/all') }, getView: function () { return this.view }, getModel: function () { return this.model } } return function (options) { var app = {} app.view = new App.View(options) app.model = new App.Model(options) app.controller = new App.Controller( $.extend( { view: app.view, model: app.model }, options ) ) return app } })() Handlebars.registerHelper('eq', function (a, b, options) { return a === b ? options.fn(this) : options.inverse(this) }) // run application createMvcApp({ el: '.todoapp', data: { todos: [ { id: 'todo-001', title: 'Do Some X' }, { id: 'todo-002', title: 'Do Some Y', completed: true }, { id: 'todo-003', title: 'Do Some Z' } ] } }) })
<!DOCTYPE html> <html lang="en" data-framework="jquery"> <head> <meta charset="utf-8" /> <title>Formotor • TodoMVC</title> </head> <body> <section class="todoapp" fm-app> <header class="header" fm-component="todo-header"> <h1>todos</h1> <input class="new-todo" placeholder="What needs to be done?" autofocus /> </header> <section class="main" fm-component="todo-content"> <input id="toggle-all" class="toggle-all" type="checkbox" /> <label for="toggle-all">Mark all as complete</label> <ul class="todo-list"></ul> </section> <footer class="footer" fm-component="todo-footer"></footer> </section> <footer class="info"> <p>Double-click to edit a todo</p> <p>Created by <a href="https://felixpy.com">Felix Yang</a></p> <p>Part of <a href="https://felixpy.github.io/formotor">Formotor</a></p> </footer> <script id="todo-template" type="text/x-handlebars-template"> {{#this}} <li {{#if completed}}class="completed"{{/if}} data-id="{{id}}"> <div class="view"> <input class="toggle" type="checkbox" {{#if completed}}checked{{/if}}> <label class="title">{{title}}</label> <button class="destroy"></button> </div> <input class="edit" value="{{title}}"> </li> {{/this}} </script> <script id="footer-template" type="text/x-handlebars-template"> <span class="todo-count"><strong>{{activeTodoCount}}</strong> {{activeTodoWord}} left</span> <ul class="filters"> <li> <a {{#eq filter 'all'}}class="selected"{{/eq}} href="#/all">All</a> </li> <li> <a {{#eq filter 'active'}}class="selected"{{/eq}}href="#/active">Active</a> </li> <li> <a {{#eq filter 'completed'}}class="selected"{{/eq}}href="#/completed">Completed</a> </li> </ul> {{#if completedTodos}}<button class="clear-completed">Clear completed</button>{{/if}} </script> <script src="//cdn.bootcss.com/jquery/3.3.1/jquery.min.js"></script> <script src="//cdn.bootcss.com/handlebars.js/4.0.12/handlebars.min.js"></script> <script src="//cdn.bootcss.com/Director/1.2.8/director.min.js"></script> <script src="//unpkg.com/formotor"></script> </body> </html>