Note This article refers to this branch
The Code
The mainline branch is pretty straight forward: it is a Node package that has a source directory, a static directory, a gitignore file, our README.md, the requisite package.json file, our pnpm lock file, and our Snowpack config file.
The source directory has only two files, main.js
and styles.module.css
.
the static directory also has just two files, index.html
and styles.main.css
.
Let's look at each of those in a bit more depth; we'll save the main.js
file for last as it's arguably the biggest file.
// snowpack.config.js
// Snowpack Configuration File
// See all supported options: https://www.snowpack.dev/#configuration
/** @type {import("snowpack").SnowpackUserConfig } */
module.exports = {
mount: {
static: '/',
src: '/_dist_'
},
// plugins: [],
// installOptions: {},
devOptions: {
fallback: 'index.html'
},
// buildOptions: {},
};
The Snowpack config is pretty straight forward, if you know how to read Snowpack configs. The mount
object takes key-value pairs, with the key being the directory on your file system and the value is where it will be mounted to in the Snowpack dev server. Notice static
mounts to /
, so you can directly access our items in the static
folder. The src
directory mounts to /_dist_
meaning any files we include have to be relative to that base.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo List</title>
<link rel="stylesheet" href="/styles.main.css">
</head>
<body>
<h2>Todos</h2>
<form id="todo-form">
<input class="block" type="text" id="add-input" placeholder="Add a todo">
<input class="block" type="text" id="description-input" placeholder="Add extra details">
<button type="submit">Submit</button>
</form>
<dl id="todo-list-container"></dl>
<script type="module" src="/_dist_/main.js"></script>
</body>
</html>
Pretty standard HTML file, right? The things to notice are the <link>
and <script>
tags I have. The link
is pulling the styles from /styles.main.css
, because the static
directory was mounted to the root of our development server.
/* styles.main.css */
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
input[type="text"] {
padding: 4px 8px;
border-radius: 2px;
border: 1px solid lightgray;
}
input[type="text"]:focus {
outline: none;
border: 1px solid gray;
}
input.block + input.block {
margin-top: 4px;
}
.block {
display: block;
}
.block + .block {
margin-top: 2;
}
It's some pretty straight forward CSS, just to modify the elements not created by Javascript.
In the src
directory first we'll talk about the styles.module.css
file:
/* styles.module.css */
dd {
margin-left: 0;
}
dt {
font-weight: 600;
}
.todo-list-item {
font-size: 1em;
color: #333;
font-family: sans-serif;
}
.todo-list-item span[role="delete"] {
margin-left: 5px;
color: pink;
}
span[role="delete"]:hover {
color: red;
}
.todo-list-item.todo-list-complete,
.todo-list-item.todo-list-complete + dd {
text-decoration: line-through;
color: #989898;
}
There isn't anything spectacular here because honestly the UI isn't spectacular; it is functional, which is where a lot of people start ("make it work, then make it pretty"). As a base style we are removing the left margin from our <dd>
tags, and setting our dt
tag to be sort-of bold. We add some margin to our delete indicator, and we add some styles to visually differentiate completed vs. in progress todos.
Now, I would post the entirety of the main.js
file, but it's 137 lines, and honestly in one go that would be too much. So, let's look at it a few lines at a time so that we can discuss what is occurring.
// main.js, lines 1 thru 28
import styles from './styles.module.css';
/**
* @typedef todo
* @property {string} title
* @property {boolean} done
* @property {string} description
*/
/**
* @type {todo[]}
*/
const todos = [
{
title: 'Example Todo, no details',
done: false,
description: ''
},
{
title: 'Example todo, with details',
done: false,
description: 'Many details'
},
{
title: 'Completed Todo',
done: true,
description: 'with details'
}
];
Alright, so if you are brand new you'll likely notice something weird almost immediately. There's this long comment with weird notations such as "@typedef" and "@property." It's a notation system called JSDoc and when you are using raw JS it's very helpful if you have a code editor (or a plugin) that uses it to help give you information about the code in question.
That long comment is just me declaring a type that I will use throughout the rest of the code in this file called "todo." A todo will have 3 properties: title, description, and done. A todo's title and description are strings, and "done" is boolean (true/false).
With that out of the way let's talk about the actual Javascript seen here.
Line 1 is importing the styles from our CSS module. That's all it does so far.
the line that starts with const todos ...
is just assigning an array of todos with some initial data. Remember, the point of this branch is to reflect the workstate of "I got this done in a hurry and it works." Some todos are done, some are not.
Now let's talk about the next bit of code.
/** @type {Map<todo, Element | Element[]} */
const itemCache = new Map();
This is utilizing our todo
type that we defined previously in that big comment from the first section we talked about. The notation here is a bit weird, especially if you've never worked with a language that uses generics. The basics of it is that the Map
object in JS takes one type to use as a key, and stores another type. the line Map<todo, Element | Element[]>
in words says "We have a Map, using the type 'todo' as the key, which will store either a single Element object or an array of Element objects."
Now, I'm going to introduce this next bit of code out of order, just because it is needed to make sense of some of the event listeners we have setup. This code is the function that actually renders our todos, and it starts on line 102 and ends on line 137. The JSDoc dictates that the function has two parameters, the first is an HTMLDListElement and the second is an array of todo elements. It also handedly notes that this function doesn't return anything -- it just runs the code inside of it.
/**
* @param {HTMLDListElement} todo
* @param {todo[]} todos
* @returns {void}
*/
function renderTodos(todo, todos) {
const els = todos.flatMap((todo, index) => {
// either we can grab this item from the cache OR we create a new one.
const [dt, dd] = itemCache.get(todo) || [document.createElement('dt'), document.createElement('dd')];
if (!itemCache.has(todo)) {
// set the innerHTML, along with creating the delete span.
const span = document.createElement('span');
span.innerHTML = '×'
span.setAttribute('role', 'delete');
dt.append(
document.createTextNode(todo.title),
span
);
// Add necessary classes.
dt.classList.add(styles['todo-list-item']);
dt.classList.toggle(styles['todo-list-complete'], todo.done);
//update description element if we have a description
if (todo.description) {
dd.innerHTML = todo.description;
}
itemCache.set(todo, [dt, dd]);
}
dt.dataset.key = dd.dataset.key = index;
dt.classList.toggle(styles['todo-list-complete'], todo.done);
return [dt, dd];
});
todo.append(...els);
}
I think the easiest way to show what this does is to show what HTML results from it. Assume you call the following code
renderTodos(document.querySelector('#someDLElement'), [
{ title: 'Hello DevTo!', done: false, description: 'Remember to hit the heart button!'}
]);
... and subsequently check the HTML in your browser, you'd get this:
<dl id="someDLElement">
<dt class="_todo-list-item_8dbky_7" data-key="0">Hello DevTo!<span role="delete">×</span></dt>
<dd data-key="0">Remember to his the heart button!</dd>
</dl>
So, some notes about this:
- It creates a
<dt>
element, and a text node for the actual text of the given todo. - Inside the
<dt>
element there is a<span>
element that has arole
attribute on it, signifying that it deletes the current element. - it adds in
data-key
attributes equal to the current todo's index in the todos array on both the<dt>
and matching<dd>
tag.
These are important things to remember going forward, as the remaining portion of the "app" will assume this HTML structure when calling for events.
The next bit of code is quite long so I will attempt to break it up.
window.addEventListener('DOMContentLoaded', () => {
const todoList = document.querySelector('#todo-list-container');
// Whole mess of stuff here.
});
This function wraps the rest of the code in an event listener. the "DOMContentLoaded" event will fire after the DOM has finished parsing and you can call things like document.getElementById()
without worrying.
The next thing we are doing is querying the document in search of an element that has an id of "todo-list-container", which if we look back at our HTML is a <dl>
element that will hold our todos.
Now we get onto the meat of the project. The first up is going to be a lot to take in all at once, but I think it's better that we talk about it as one item, and not in parts.
todoList.addEventListener('click', e => {
const { target } = e;
// if our target matches a delete element
if (target.matches('span[role="delete"]')) {
e.stopPropagation();
// grab the key
const key = target.parentNode.dataset.key;
// grab the element
const todo = todos[key];
// grab the elements from our cahce
const els = itemCache.get(todo);
// sanity check
if (els) {
// for each element associated with this todo, delete it.
els.forEach(el => el && el.remove && el.remove());
// remove the todo from our cache
itemCache.delete(todo);
// remove the todo from the array
todos.splice(key, 1);
// re-render, mostly to fix key-indexes
renderTodos(todoList, todos);
}
}
// if our target matches just the regular dt element
if (target.matches('dt.' + styles['todo-list-item'])) {
e.stopPropagation();
// get the key
const key = target.dataset.key;
// if the todos does not have this key, stop execution.
if (!todos[key]) return;
// invert the value
todos[key].done = !todos[key].done;
// re-render
renderTodos(todoList, todos);
}
});
This event listener is added on to our todoList
, since the child elements will come/go quite frequently adding an event onto them doesn't particularly make a lot of sense. Instead, we add the events onto the container, which will not go away. Here we have two things to check
- Was the delete span that we created clicked?
- Was the actual
<dt>
element clicked?
The first scenario is handled by the first if()
statement, which I think is commented quite well, but I will go through the steps after we go into the if()
statement.
The first thing we do is stop the propagation of this click. In a real application with pure JS we have no idea what could be listening on click in the ancestor elements, so we stop the propagation upwards.
Next is that we grab a key out of the parent node's dataset. Remember, in this scenario the item being clicked is the <span role="delete">
element, and not the actual <dt>
element. The <span>
element doesn't have the data-key
property on it, so we look for the parent instead. If the todos array does not have an element at that index, we stop execution. Otherwise, we grab the corresponding elements from our cache variable, remove each of them, delete the key from our cache, remove the todo from our array (using .splice()
), and finally re-render the todos in the todos container.
The next if()
block is for toggling the "done" status of the clicked todo. Like the first one, it stops the propagation of the event; then we grab the key from the dataset. The same check is done to make sure we have the given key value in our todos, and then we toggle it to the opposite of whatever the current state is (i.e. if the current state is false, then we toggle it to true and vice versa). We then re-render the list.
The next section deals with handling the submission of new todos through the form on the HTML page.
// main.js, lines 74 to 95
// add an event handler to the form to prevent default stuff.
document.querySelector('#todo-form')
.addEventListener('submit', function (e) {
// prevent the default
e.preventDefault();
// get the elements from this form.
const { "add-input": addInput, "description-input": descriptionInput } = this.elements;
// break execution if we do not have strings.
if (addInput.value.trim().length === 0 || descriptionInput.value.trim().length === 0) return;
// push the results
todos.unshift({
title: addInput.value,
done: false,
description: descriptionInput.value
});
// Reset the values
addInput.value = descriptionInput.value = '';
// Render the todos
renderTodos(todoList, todos);
});
The Discussion
Q: Why didn't you use TypeScript?
A: I imagine someone coming into Javascript not particularly feeling the need of TypeScript -- If people would like I can create a TypeScript branch of each version, but unless someone asks I'm not likely to do so until a larger version difference.
Now, as I said in the original post, this is meant to be an exercise to show people why things like React have gotten popular. While this is definitely not the best way to setup an app, it's definitely a way that many people will do for their first few (speaking from past experience here).
The next branch we will talk about is the Version 1.1 branch, which changes the structure a bit so that we don't need one large file. Keep an eye out for that post!
Feel free to comment any questions / clarifications!
Top comments (0)