Your First Web Apps
In the previous class we looked at how to begin changing the DOM and elements on a webpage using Javascript. However, we haven't yet created anything like a modern web app. In order to make something more modern, we'll need to look into how to add, remove, and find elements in the DOM.
There are also several techniques in modern Javascript which can save us a lot of time when writing code, and we'll cover those in this class.
Make sure you have a copy of the todo list demo handy this class, as it'll be helpful to read along with and for the first exercise.
Some More Ways to Get Elements
In our previous class we used document.getElementById
to locate key nodes in the DOM and attach events to them. We still use this method in window.onload
:
window.onload = () => {
newTaskName = document.getElementById("newTaskName")
newTaskButton = document.getElementById("newTaskButton")
taskWrapper = document.getElementById("task-wrapper")
newTaskButton.addEventListener("click", createNewTask)
newTaskName.addEventListener("keypress", (ev) => {
if ((ev.key) == "Enter") createNewTask(ev);
})
}
However, while document.getElementById
is fast and convenient, it's not helpful for locating elements which are nested more deeply in the DOM. We use two other methods here. The first is getElementsByClassName
, which we use in completeTask
:
let parent = document.getElementById(`task-${id}`)
let title = parent.getElementsByClassName("task-title")[0]
let checkbox = parent.getElementsByClassName("task-complete")[0]
We've reduced the scope of getElementsByClassName
to the children of the specific task we're interested in. This is helpful because we don't want all the task titles in the document, just the one in the task we're completing.
It's often more convenient and concise to use CSS syntax to locate specific elements in the DOM. For this we can use querySelector
(to get one element) or querySelectorAll
(to get all elements that match). Note that these can be slower than getElementById
as they have to traverse the DOM tree.
We use this in createNewTask
extensively.
let $checkBox = document.querySelector(`#task-${myId} > span > .task-complete`)
$checkBox.addEventListener("click", (ev) => completeTask(ev, myId))
innerText and innerHTML
Last time we looked at input.value
for setting the value of a form text input. While value
is helpful for input elements, it doesn't work if we want to set the value of a text element. For this we usually want to use .innerText
:
let $title = document.querySelector(`#task-${myId} > .task-title`)
$title.innerText = newTaskName.value
$title.addEventListener("click", (ev) => completeTask(ev, myId))
However, what if we want to create some new elements inside an existing element. For example, if we want to style a paragraph? For this we can use innerHTML
.
let $p = document.querySelector(`#paragraph-1`)
$p.innerHTML = `<strong>This is a bold text.</strong> This is not. <em>This is emphasis.</em>.`
Note that while innerHTML is widely used in tutorials, in real code it's usually best avoided.
This is because changes to innerHTML
will replace the DOM nodes currently inside an element. This means that any events tied to DOM nodes that already exist will be lost -- and can be challenging to recover.
In some situations using innerHTML
can also lead to security holes. Since you're directly manipulating the DOM, untrusted content (for example, script tags pointing to malicious sites) can be placed in a user's browser. Therefore directly manipulating HTML is usually best done on input that you already trust.
Exercise
Add an edit button to each todo list element with the label [Edit]
. When the edit button is clicked, it should do the following:
- Change the edit button text to read
[Save]
. - Change the title text into an input box with the same text as the title. You can either manipulate the DOM for this or simply use a CSS class with
display: none
to control the visibility of the input and title elements.
When the save button is clicked:
- The button should change its text back to
[Edit]
. - The title of the task should change to match the value of the input. The input box should be replaced with the title text.
Creation and Destruction
For most web apps it's not enough to be able to show and hide elements with display: none
; we need to be able to actually add elements to the DOM without breaking our events.
The simplest method for this is to use insertAdjacentHTML
. We use this when we create a new task:
let taskHTML = `
<div id="task-${curId}" class="task-box flex-row">
<span class="button-spacing"><input class="task-complete" type="checkbox"></input></span>
<span class="task-title">Hello</span>
<span class="spacer"></span>
<span class="delete button-spacing">[Delete]</span>
</div>
`
taskWrapper.insertAdjacentHTML("beforeend",taskHTML)
insertAdjacentHTML
lets us insert new HTML sections into the DOM, including complex template strings, in a variety of different positions. Usually we want to use beforeend
or afterbegin
to create elements inside a parent element. We can also use beforebegin
and afterend
to create them outside our parent; however this is slower.
Like manipulating innerHTML
we need to be careful with insertAdjacentHTML
, as malicious user input could create security concerns.
What if we want to remove a DOM element? We can simply call .remove()
on that element.
const deleteTask = function (id) {
let parent = document.getElementById(`task-${id}`)
parent.remove()
}
Note that DOM manipulation is much more expensive than adding and removing classes, and can sometimes break styling. Therefore, in real use cases, we often prefer to manipulate the visibility of elements with CSS instead of changing the DOM itself.
The Old Ways
In the past, it was much more common to use DOM functions directly to get, create and manipulate nodes. Part of this was because the Javascript APIs didn't support methods like querySelector
.
However, sometimes we might prefer to get access to DOM elements directly, for example if we want to set up a complex structure before attaching it to the DOM.
Let's rewrite a section of our code using only Javascript and not rendering HTML.
let $checkBox = document.querySelector(`#task-${myId} > span > .task-complete`)
$checkBox.addEventListener("click", (ev) => completeTask(ev, myId))
If we use Javascript only to navigate the DOM this becomes:
let $task = document.getElementById(`task-${myId}`)
// different method to avoid checking multiple objects
let $span = $task.getElementsByClassName("button-spacing")[0]
let $input = $span.getElementsByTagName("input")[0]
$input.addEventListener("click", (ev) => completeTask(ev, myId))
While this is very verbose, it does have one major advantage over using querySelector
: it forces us to engage with errors and edge cases that are hidden by more modern methods. There are two main situations this might be helpful:
- Where we want to make our code more robust and generic - for example if we're writing framework or library code.
- Where we need to deal with edge cases in detail - for example, for security purposes.
In practice a lot of the details of creating and destroying elements in the DOM are handled by modern frontend frameworks like React or Angular. We'll take a look at some paradigms for this next time.
Exercise
You are given a list of objects in the following format:
[
{"name": "Joe Bloggs", "email": "joe@bloggs.com", "phone": "1234567890"},
{"name": "Jane Doe", "email": "jane@doe.com", "phone": "1234567890"},
]
Write a Javascript function which takes this data and presents it as a table. The table must include:
- A
<thead>
section with each column name listed. - A
<tbody>
section. Each object must be one row in the
Extension 1: Add pagination to your table. Your table should show a limited number of results. At the bottom of the table add an indicator which shows the current page, and the total number of pages. It should also have a previous page button and a next page button.
Extension 2 (challenging): Add an arrow icon to your table. When the user clicks the table header, it should toggle between 3 different modes. The first mode is unsorted. The second mode is sorted ascending. The third mode is sorted descending. It should be possible to sort your table by any column. You can only sort by one column at a time, and if another column is toggled to sort, all other columns should be reset to unsorted.
You can use Array.sort()
to get a sorted list of elements.
For arrow icons you can use ↑↓→
.
Assignment
Create a basic reading journal app. This should have two parts: an area where you can read the details of books added to the journal so far, and a form where you can add new books (if you'd like to get fancy, you can make this a modal).
The form should take the name of the book, the name of the author, a date of completion, and an extended comment about the book. When the form is submitted, a new entry should be placed on the page with the information about the book.
On an entry about a book there should be an option to remove that entry in the reading journal.
Extension: Add an edit button to each entry in the journal. When pressed, it should allow you to change the fields on the journal entry as text input fields, and become a save button. When the save button is pressed, the text input fields should revert to regular text fields.
Extension 2: Add a read field attached to a checkbox in entries of the journal. This field should be able to be toggled whether or not the entry is in edit mode. When a book is read, change the background color of the entry to light green. When it's unread, make it light yellow.