DEV Community

Jim Burbridge
Jim Burbridge

Posted on

A(nother) Todo List: The Mainline Branch

Note This article refers to this branch

The Code

Repository Layout, Mainline branch

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: {},
};

Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}

Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'
  }
];

Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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 = '&times;'
      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);
}
Enter fullscreen mode Exit fullscreen mode

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!'}
]);
Enter fullscreen mode Exit fullscreen mode

... 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>
Enter fullscreen mode Exit fullscreen mode

So, some notes about this:

  1. It creates a <dt> element, and a text node for the actual text of the given todo.
  2. Inside the <dt> element there is a <span> element that has a role attribute on it, signifying that it deletes the current element.
  3. 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.
});
Enter fullscreen mode Exit fullscreen mode

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);
    }
  });
Enter fullscreen mode Exit fullscreen mode

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

  1. Was the delete span that we created clicked?
  2. 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);
    });

Enter fullscreen mode Exit fullscreen mode

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)