In this tutorial, we’ll enhance our front-end skills by learning to build a “handmade” to-do app. To create it, we won’t take advantage of any JavaScript frameworks; we’ll just use HTML, CSS, and vanilla JavaScript.
What We’ll be Building
Here’s an introductory video which demonstrates the functionality of the JavaScript app that we’re going to create. Users will be able to add tasks, mark them as complete, and remove them. Task totals and their statuses will be shown on the status bar:
Here’s the demo on Codepen for you to fork and play with:
Note: This isn’t an introductory tutorial. It assumes that you are familiar with essential front-end skills like CSS Grid, flexbox, ES6, JavaScript arrays, etc. Also, for this demonstration, making the app fully accessible isn’t a priority.
1. Begin With the Required Assets
To make the layout a bit more unique, we’ll use some handmade SVG illustrations and a custom font taken from Envato Elements.
It’s worth noting that most of these assets will come from a previous tutorial. In actual fact, we’ll also use a lot of the positioning techniques that we learned in this tutorial, so it’s worth reading it.
2. Continue With the Page Markup
We’ll start with an SVG and a div
container:
<svg style="display:none;">...</svg> <div class="container">...</div>
SVG Sprites
Like we’ve done many times in the past, as a good practice, we’ll store all SVGs as symbol
s in an SVG sprite container. Then, we’ll render them on the screen whenever we need by calling the use
element.
Here’s the markup for the SVG sprite:
<svg style="display:none;"> <symbol id="input" viewBox="0 0 345.27 56.51" preserveAspectRatio="none">...</symbol> <symbol id="checkbox_empty" viewBox="0 0 33.18 33.34">...</symbol> <symbol id="checkmark" viewBox="0 0 37.92 33.3" preserveAspectRatio="none">...</symbol> <symbol id="button" viewBox="0 0 256.6 60.02" preserveAspectRatio="none">...</symbol> <symbol id="close" viewBox="0 0 29.71 30.59">...</symbol> <symbol id="stats" viewBox="0 0 998.06 602.62" preserveAspectRatio="none">...</symbol> </svg>
Notice the preserveAspectRatio="none"
attribute which we attached to most of the illustrations. We’ve done this because, as we’ll see later, our icons will scale and lose their initial dimensions.
Container
The container will include a form, a div
element, and an empty ordered list:
<div class="container"> <form class="todo-form">...</form> <div class="todo-stats">...</div> <ol class="todo-list"></ol> </div>
Inside the form, we’ll have an input and a submit button along with their associated SVGs:
<form class="todo-form"> <div class="form-wrapper"> <input type="text" name="name" autofocus> <svg> <use xlink:href="#input"></use> </svg> </div> <div class="form-wrapper"> <button type="submit">Add new task</button> <svg> <use xlink:href="#button"></use> </svg> </div> </form>
Notice the name
attribute that we’ve added to the input field. Later we’ll use this attribute to access the input value after the form submission.
Note: In our demo, the autofocus
attribute of the text field won’t work. In fact, it’ll throw the following error which you can see if you open your browser console:
However, if you run this app locally (not as a Codepen project), this issue won’t exist. Alternatively, you can set the focus via JavaScript.
Inside the div
, we’ll place three nested div
s and the associated SVG. In this section we’ll keep track of the total number of tasks (both remaining and completed):
<div class="todo-stats"> <div class="total-tasks"> Total Tasks: <span>0</span> </div> <div class="completed-tasks"> Completed Tasks: <span>0</span> </div> <div class="remaining-tasks"> Remaining Tasks: <span>0</span> </div> <svg> <use xlink:href="#stats"></use> </svg> </div>
Finally, the items of the ordered list will be added dynamically through JavaScript.
3. Define Some Basic Styles
With the markup ready, we’ll continue with some reset styles:
@font-face { font-family: "Summer"; src: url(SummerFont-Regular.woff); } @font-face { font-family: "Summer Bold"; src: url(SummerFont-Bold.woff); } :root { --white: #fff; } * { padding: 0; margin: 0; border: none; outline: none; box-sizing: border-box; } input, button { font-family: inherit; font-size: 100%; background: none; } [type="checkbox"] { position: absolute; left: -9999px; } button, label { cursor: pointer; } ol { list-style: none; } body { font: 28px/1.2 "Summer"; margin: 1.5rem 0; }
4. Set the Main Styles
Let’s now discuss the main styles of our app.
Container Styles
The container will have a maximum width with horizontally centered content:
.container { max-width: 700px; padding: 0 10px; margin: 0 auto; }
Form Styles
On small screens all form elements will be stacked:
However, on viewports 600 pixels wide and above, the form layout will change as follows:
Let’s take note of two things:
- On wide viewports, the input will be twice the size of the button.
- The SVGs will be absolutely positioned elements and sit below their adjacent form control. Again, for a more detailed explanation, have a look at this previous tutorial.
Here are the styles for this section:
/*CUSTOM VARIABLES HERE*/ .todo-form .form-wrapper { position: relative; } .todo-form input, .todo-form button { position: relative; width: 100%; z-index: 1; padding: 15px; } .todo-form svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .todo-form button { color: var(--white); text-transform: uppercase; } @media screen and (min-width: 600px) { .todo-form { display: grid; grid-template-columns: 2fr 1fr; grid-column-gap: 5px; } }
Stats Styles
Next, let’s look at the status bar which will give us a quick report about the total number of tasks.
On small screens it will have the following stacked appearance:
However, on viewports 600 pixels wide and above, it should change as follows:
Let’s take note of two things:
- On wide viewports, all child
div
elements will have equal widths. - Similarly to the previous SVGs, this will also be absolutely positioned and act as a background image that covers the whole section.
The related styles:
/*CUSTOM VARIABLES HERE*/ .todo-stats { position: relative; text-align: center; padding: 5px 10px; margin: 10px 0; color: var(--white); } .todo-stats > div { position: relative; z-index: 1; } .todo-stats svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } @media screen and (min-width: 600px) { .todo-stats { display: grid; grid-template-columns: repeat(3, 1fr); } }
Task Styles
The tasks layout, which we’ll generate dynamically in the upcoming section, will look like this:
Each task which will be represented by a li
will have two parts.
In the first part, you’ll see a checkbox along with the task name. In the second part, you’ll notice a delete button for removing the task.
Here are the related styles:
.todo-list li { display: grid; align-items: baseline; grid-template-columns: auto 20px; grid-column-gap: 10px; padding: 0 10px; } .todo-list li + li { margin-top: 10px; }
When a task is incomplete, an empty checkbox will appear. On the other hand, if a task is marked as completed, a checkmark will appear. Additionally, its name will be given 50% opacity as well as a line through it.
Here are the styles responsible for this behavior:
.todo-list .checkbox-wrapper { display: flex; align-items: baseline; } .todo-list .checkbox-wrapper label { display: grid; margin-right: 10px; } .todo-list .checkbox-wrapper svg { grid-column: 1; grid-row: 1; width: 20px; height: 20px; } .todo-list .checkbox-wrapper .checkmark { display: none; } .todo-list [type="checkbox"]:checked + label .checkmark { display: block; } .todo-list [type="checkbox"]:checked ~ span { text-decoration: line-through; opacity: 0.5; }
Finally, below are the styles for the delete button:
.todo-list .remove-task { display: flex; padding: 2px; } .todo-list .remove-task svg { width: 16px; height: 16px; }
5. Add the JavaScript
At this point, we’re ready to build the core functionality of our app. Let’s do it!
On Form Submission
Each time a user submits the form by pressing the Enter key or the Submit button, we’ll do the following things:
- Stop the form from submitting, thereby preventing a reload of the page.
- Grab the value which is contained in the input field.
- Assuming that the input field isn’t empty, we’ll create a new object literal which will represent the task. Each task will have a unique id, a name, and be active (not completed) by default.
- Add this task to the
tasks
array. - Store the array in local storage. Local storage only supports strings, so to do it, we have to use the
JSON.stringify()
method to convert the objects inside the array into strings. - Call the
createTask()
function for visually representing the task on the screen. - Clear the form.
- Give focus to the input field.
Here’s the relevant code:
const todoForm = document.querySelector(".todo-form"); let tasks = []; todoForm.addEventListener("submit", function(e) { // 1 e.preventDefault(); // 2 const input = this.name; const inputValue = input.value; if (inputValue != "") { // 3 const task = { id: new Date().getTime(), name: inputValue, isCompleted: false }; // 4 tasks.push(task); // 5 localStorage.setItem("tasks", JSON.stringify(tasks)); // 6 createTask(task); // 7 todoForm.reset(); } // 8 input.focus(); });
Create a Task
The createTask()
function will be responsible for creating the task’s markup.
For instance, here’s the structure for the “Go for a walk” task:
Two things are important here:
- If the task is completed, the checkmark will appear.
- If the task isn’t completed, its
span
element will receive thecontenteditable
attribute. This attribute will give us the ability to edit/update its name.
Below is the syntax for this function:
function createTask(task) { const taskEl = document.createElement("li"); taskEl.setAttribute("id", task.id); const taskElMarkup = ` <div class="checkbox-wrapper"> <input type="checkbox" id="${task.name}-${task.id}" name="tasks" ${ task.isCompleted ? "checked" : "" }> <label for="${task.name}-${task.id}"> <svg class="checkbox-empty"> <use xlink:href="#checkbox_empty"></use> </svg> <svg class="checkmark"> <use xlink:href="#checkmark"></use> </svg> </label> <span ${!task.isCompleted ? "contenteditable" : ""}>${task.name}</span> </div> <button class="remove-task" title="Remove ${task.name} task"> <svg> <use xlink:href="#close"></use> </svg> </button> `; taskEl.innerHTML = taskElMarkup; todoList.appendChild(taskEl); countTasks(); }
Update a Task
A task can be updated in two different ways:
- By changing its status from “incomplete” to “completed” and vice versa.
- By modifying its name in case the task is incomplete. Remember that in this case, the
span
element has thecontenteditable
attribute.
To keep track of these changes, we’ll take advantage of the input
event. This is an acceptable event for us because it applies both to input
elements and elements with contenteditable
enabled.
The tricky thing is that we cannot directly attach this event to the target elements (checkbox, span
) because they are created dynamically and aren’t part of the DOM on page load.
Thanks to the event delegation, we’ll attach the input
event to the parent list which is part of the initial markup. Then, via the target
property of that event we’ll check the elements on which the event occurred and call the updateTask()
function:
todoList.addEventListener("input", (e) => { const taskId = e.target.closest("li").id; updateTask(taskId, e.target); });
Inside the updateTask()
function, we’ll do the following things:
- Grab the task that needs to be updated.
- Check the element that triggered the event. If the element has the
contenteditable
attribute (i.e. it’s thespan
element), we’ll set the task’s name equal to thespan
’s text content. - Otherwise (i.e. it’s the checkbox), we’ll toggle the task’s status and its
checked
attribute. Plus, we’ll also toggle thecontenteditable
attribute of the adjacentspan
. - Update the value of the
tasks
key in local storage. - Call the
countTasks()
function.
Here’s the syntax for this function:
function updateTask(taskId, el) { // 1 const task = tasks.find((task) => task.id === parseInt(taskId)); if (el.hasAttribute("contentEditable")) { // 2 task.name = el.textContent; } else { // 3 const span = el.nextElementSibling.nextElementSibling; task.isCompleted = !task.isCompleted; if (task.isCompleted) { span.removeAttribute("contenteditable"); el.setAttribute("checked", ""); } else { el.removeAttribute("checked"); span.setAttribute("contenteditable", ""); } } // 4 localStorage.setItem("tasks", JSON.stringify(tasks)); // 5 countTasks(); }
Remove a Task
We can remove a task via the “close” button.
Similar to the update operation, we cannot directly attach an event to this button because it isn’t in the DOM when the page loads.
Thanks again to the event delegation, we’ll attach a click
event to the parent list and perform the following actions:
- Check if the element that is clicked is the “close” button or its child SVG.
- If that happens, we’ll grab the
id
of the parent list item. - Pass this
id
to theremoveTask()
function.
Here’s the relevant code:
const todoList = document.querySelector(".todo-list"); todoList.addEventListener("click", (e) => { // 1 if ( e.target.classList.contains("remove-task") || e.target.parentElement.classList.contains("remove-task") ) { // 2 const taskId = e.target.closest("li").id; // 3 removeTask(taskId); } });
Inside the removeTask()
function, we’ll do the following things:
- Remove from the
tasks
array the associated task. - Update the value of the
tasks
key in local storage. - Remove the associated list item.
- Call the
countTasks()
function.
Here’s the syntax for this function:
function removeTask(taskId) { // 1 tasks = tasks.filter((task) => task.id !== parseInt(taskId)); // 2 localStorage.setItem("tasks", JSON.stringify(tasks)); // 3 document.getElementById(taskId).remove(); // 4 countTasks(); }
Count Tasks
As we’ve already discussed, many of the functions above include the countTask()
function.
Its job is to monitor the tasks for changes (additions, updates, deletions) and update the content of the related elements.
Here’s its signature:
const totalTasks = document.querySelector(".total-tasks span"); const completedTasks = document.querySelector(".completed-tasks span"); const remainingTasks = document.querySelector(".remaining-tasks span"); function countTasks() { totalTasks.textContent = tasks.length; const completedTasksArray = tasks.filter((task) => task.isCompleted === true); completedTasks.textContent = completedTasksArray.length; remainingTasks.textContent = tasks.length - completedTasksArray.length; }
Prevent Adding New Lines
Each time a user updates the name of a task, they should not be able to create new lines by pressing the Enter key.
To disable this functionality, once again we’ll take advantage of the event delegation and attach the keydown
event to the list, like this:
todoList.addEventListener("keydown", function (e) { if (e.keyCode === 13) { e.preventDefault(); } });
Note that in this scenario only the span
elements could trigger that event, so there’s no need to make an additional check like this:
if (e.target.hasAttribute("contenteditable") && e.keyCode === 13) { e.preventDefault(); }
Persist Data on Page Load
So far, if we close the browser and navigate to the demo project, our tasks will disappear.
But, wait that isn’t 100% true! Remember that each time we do a task manipulation, we also store the tasks
array in local storage. For example, in Chrome, to see the local storage keys and values, click the Application tab then, expand the Local Storage menu and finally click a domain to view its key-value pairs.
In my case, here are the values for the tasks
key:
So, to display these tasks, we first need to retrieve them from local storage. To do this, we’ll use the JSON.parse()
method which will convert the strings back to JavaScript objects.
Next, we’ll store all tasks in the familiar tasks
array. Keep in mind that if there’s no data in local storage (for instance the very first time we visit the app), this array is empty. Then, we have to iterate through the array, and for each task, call the createTask()
function. And, that’s all!
The corresponding code snippet:
let tasks = JSON.parse(localStorage.getItem("tasks")) || []; if (localStorage.getItem("tasks")) { tasks.map((task) => { createTask(task); }); }
Conclusion
Phew! Thanks for following along on this long journey folks. Hopefully, you gained some new knowledge today which you’ll be able to apply to your own projects.
Let’s remind ourselves what we built:
Without a doubt, building such an app with a JavaScript framework might be more stable, easy, and efficient (repainting the DOM is expensive). However, knowing to solve this kind of exercise with plain JavaScript will help you get a solid grasp on its fundamentals and make you a better JavaScript developer.
Before closing, let me propose two ideas for extending this exercise:
- Use the HTML Drag and Drop API or a JavaScript library like Sortable.js for reordering the tasks.
- Store data (tasks) in the cloud instead of the browser. For example, replace local storage with a real-time database like Firebase.
As always, thanks a lot for reading!
More Vanilla JavaScript Apps
If you want to want to learn building small apps with plain JavaScript, check out the following tutorials:
No comments:
Post a Comment