UNPKG

todomvc-example-in-vanilla-javascript-using-elm-architecture-

Version:

Learn how to use The Elm Architecture in JavaScript to create functional and fast UI!

355 lines (332 loc) 12.9 kB
/* if require fn is available, it means we are in Node.js Land i.e. testing! */ /* istanbul ignore next */ if (typeof require !== 'undefined' && this.window !== this) { var { a, button, div, empty, footer, input, h1, header, label, li, mount, route, section, span, strong, text, ul } = require('./elmish.js'); } var initial_model = { todos: [], hash: "#/" } /** * `update` transforms the `model` based on the `action`. * @param {String} action - the desired action to perform on the model. * @param {String} data - the data we want to "apply" to the item. * @param {Object} model - the App's (current) model (or "state"). * @return {Object} new_model - the transformed model. */ function update(action, model, data) { var new_model = JSON.parse(JSON.stringify(model)) // "clone" the model switch(action) { case 'ADD': var last = (typeof model.todos !== 'undefined' && model.todos.length > 0) ? model.todos[model.todos.length - 1] : null; var id = last ? last.id + 1 : 1; var input = document.getElementById('new-todo'); new_model.todos = (new_model.todos && new_model.todos.length > 0) ? new_model.todos : []; new_model.todos.push({ id: id, title: data || input.value.trim(), done: false }); break; case 'TOGGLE': new_model.todos.forEach(function (item) { // takes 1ms on a "slow mobile" if(item.id === data) { // this should only "match" one item. item.done = !item.done; // invert state of "done" e.g false >> true } }); // if all todos are done=true then "check" the "toggle-all" checkbox: var all_done = new_model.todos.filter(function(item) { return item.done === false; // only care about items that are NOT done }).length; new_model.all_done = all_done === 0 ? true : false; break; case 'TOGGLE_ALL': new_model.all_done = new_model.all_done ? false : true; new_model.todos.forEach(function (item) { // takes 1ms on a "slow mobile" item.done = new_model.all_done; }); break; case 'DELETE': // console.log('DELETE', data); new_model.todos = new_model.todos.filter(function (item) { return item.id !== data; }); break; case 'EDIT': // this code is inspired by: https://stackoverflow.com/a/16033129/1148249 // simplified as we are not altering the DOM! if (new_model.clicked && new_model.clicked === data && Date.now() - 300 < new_model.click_time ) { // DOUBLE-CLICK < 300ms new_model.editing = data; } else { // first click new_model.clicked = data; // so we can check if same item clicked twice! new_model.click_time = Date.now(); // timer to detect double-click 300ms new_model.editing = false; // reset } break; case 'SAVE': var edit = document.getElementsByClassName('edit')[0]; var value = edit.value; var id = parseInt(edit.id, 10); // End Editing new_model.clicked = false; new_model.editing = false; if (!value || value.length === 0) { // delete item if title is blank: return update('DELETE', new_model, id); } // update the value of the item.title that has been edited: new_model.todos = new_model.todos.map(function (item) { if (item.id === id && value && value.length > 0) { item.title = value.trim(); } return item; // return all todo items. }); break; case 'CANCEL': new_model.clicked = false; new_model.editing = false; break; case 'CLEAR_COMPLETED': new_model.todos = new_model.todos.filter(function (item) { return !item.done; // only return items which are item.done = false }); break; case 'ROUTE': new_model.hash = // (window && window.location && window.location.hash) ? window.location.hash // : '#/'; break; default: // if action unrecognised or undefined, return model; // return model unmodified } // see: https://softwareengineering.stackexchange.com/a/201786/211301 return new_model; } /** * `render_item` creates an DOM "tree" with a single Todo List Item * using the "elmish" DOM functions (`li`, `div`, `input`, `label` and `button`) * returns an `<li>` HTML element with a nested `<div>` which in turn has the: * + `<input type=checkbox>` which lets users to "Toggle" the status of the item * + `<label>` which displays the Todo item text (`title`) in a `<text>` node * + `<button class="destroy">` lets people "delete" a todo item. * see: https://github.com/dwyl/learn-elm-architecture-in-javascript/issues/52 * @param {Object} item the todo item object * @param {Object} model - the App's (current) model (or "state"). * @param {Function} singal - the Elm Architicture "dispacher" which will run * @return {Object} <li> DOM Tree which is nested in the <ul>. * @example * // returns <li> DOM element with <div>, <input>. <label> & <button> nested * var DOM = render_item({id: 1, title: "Build Todo List App", done: false}); */ function render_item (item, model, signal) { return ( li([ "data-id=" + item.id, "id=" + item.id, item.done ? "class=completed" : "", model && model.editing && model.editing === item.id ? "class=editing" : "" ], [ div(["class=view"], [ input([ item.done ? "checked=true" : "", "class=toggle", "type=checkbox", typeof signal === 'function' ? signal('TOGGLE', item.id) : '' ], []), // <input> does not have any nested elements label([ typeof signal === 'function' ? signal('EDIT', item.id) : '' ], [text(item.title)]), button(["class=destroy", typeof signal === 'function' ? signal('DELETE', item.id) : '']) ] ), // </div> ].concat(model && model.editing && model.editing === item.id ? [ // editing? input(["class=edit", "id=" + item.id, "value=" + item.title, "autofocus"]) ] : []) // end concat() ) // </li> ) } /** * `render_main` renders the `<section class="main">` of the Todo List App * which contains all the "main" controls and the `<ul>` with the todo items. * @param {Object} model - the App's (current) model (or "state"). * @param {Function} singal - the Elm Architicture "dispacher" which will run * @return {Object} <section> DOM Tree which containing the todo list <ul>, etc. */ function render_main (model, signal) { // Requirement #1 - No Todos, should hide #footer and #main var display = "style=display:" + (model.todos && model.todos.length > 0 ? "block" : "none"); return ( section(["class=main", "id=main", display], [ // hide if no todo items. input(["id=toggle-all", "type=checkbox", typeof signal === 'function' ? signal('TOGGLE_ALL') : '', (model.all_done ? "checked=checked" : ""), "class=toggle-all" ], []), label(["for=toggle-all"], [ text("Mark all as complete") ]), ul(["class=todo-list"], (model.todos && model.todos.length > 0) ? model.todos .filter(function (item) { switch(model.hash) { case '#/active': return !item.done; case '#/completed': return item.done; default: // if hash doesn't match Active/Completed render ALL todos: return item; } }) .map(function (item) { return render_item(item, model, signal) }) : null ) // </ul> ]) // </section> ) } /** * `render_footer` renders the `<footer class="footer">` of the Todo List App * which contains count of items to (still) to be done and a `<ul>` "menu" * with links to filter which todo items appear in the list view. * @param {Object} model - the App's (current) model (or "state"). * @param {Function} singal - the Elm Architicture "dispacher" which will run * @return {Object} <section> DOM Tree which containing the <footer> element. * @example * // returns <footer> DOM element with other DOM elements nested: * var DOM = render_footer(model); */ function render_footer (model, signal) { // count how many "active" (not yet done) items by filtering done === false: var done = (model.todos && model.todos.length > 0) ? model.todos.filter( function (i) { return i.done; }).length : 0; var count = (model.todos && model.todos.length > 0) ? model.todos.filter( function (i) { return !i.done; }).length : 0; // Requirement #1 - No Todos, should hide #footer and #main var display = (count > 0 || done > 0) ? "block" : "none"; // number of completed items: var done = (model.todos && model.todos.length > 0) ? (model.todos.length - count) : 0; var display_clear = (done > 0) ? "block;" : "none;"; // pluarisation of number of items: var left = (" item" + ( count > 1 || count === 0 ? 's' : '') + " left"); return ( footer(["class=footer", "id=footer", "style=display:" + display], [ span(["class=todo-count", "id=count"], [ strong(count), text(left) ]), ul(["class=filters"], [ li([], [ a([ "href=#/", "id=all", "class=" + (model.hash === '#/' ? "selected" : '') ], [text("All")]) ]), li([], [ a([ "href=#/active", "id=active", "class=" + (model.hash === '#/active' ? "selected" : '') ], [text("Active")]) ]), li([], [ a([ "href=#/completed", "id=completed", "class=" + (model.hash === '#/completed' ? "selected" : '') ], [text("Completed")]) ]) ]), // </ul> button(["class=clear-completed", "style=display:" + display_clear, typeof signal === 'function' ? signal('CLEAR_COMPLETED') : '' ], [ text("Clear completed ["), span(["id=completed-count"], [ text(done) ]), text("]") ] ) ]) ) } /** * `view` renders the entire Todo List App * which contains count of items to (still) to be done and a `<ul>` "menu" * with links to filter which todo items appear in the list view. * @param {Object} model - the App's (current) model (or "state"). * @param {Function} singal - the Elm Architicture "dispacher" which will run * @return {Object} <section> DOM Tree which containing all other DOM elements. * @example * // returns <section class="todo-app"> DOM element with other DOM els nested: * var DOM = view(model); */ function view (model, signal) { return ( section(["class=todoapp"], [ // array of "child" elements header(["class=header"], [ h1([], [ text("todos") ]), // </h1> input([ "id=new-todo", "class=new-todo", "placeholder=What needs to be done?", "autofocus" ], []) // <input> is "self-closing" ]), // </header> render_main(model, signal), render_footer(model, signal) ]) // <section> ); } /** * `subscriptions` let us "listen" for events such as "key press" or "click". * and respond according to a pre-defined update/action. * @param {Function} singal - the Elm Architicture "dispacher" which will run * both the "update" and "render" functions when invoked with singal(action) */ function subscriptions (signal) { var ENTER_KEY = 13; // add a new todo item when [Enter] key is pressed var ESCAPE_KEY = 27; // used for "escaping" when editing a Todo item document.addEventListener('keyup', function handler (e) { // console.log('e.keyCode:', e.keyCode, '| key:', e.key); switch(e.keyCode) { case ENTER_KEY: var editing = document.getElementsByClassName('editing'); if (editing && editing.length > 0) { signal('SAVE')(); // invoke singal inner callback } var new_todo = document.getElementById('new-todo'); if(new_todo.value.length > 0) { signal('ADD')(); // invoke singal inner callback new_todo.value = ''; // reset <input> so we can add another todo document.getElementById('new-todo').focus(); } break; case ESCAPE_KEY: signal('CANCEL')(); break; } }); window.onhashchange = function route () { signal('ROUTE')(); } } /* module.exports is needed to run the functions using Node.js for testing! */ /* istanbul ignore next */ if (typeof module !== 'undefined' && module.exports) { module.exports = { model: initial_model, update: update, render_item: render_item, // export so that we can unit test render_main: render_main, // export for unit testing render_footer: render_footer, // export for unit testing subscriptions: subscriptions, view: view } }