Lab 3: Introduction to JS
In this lab, we will learn:
- What is JS and what is it good for?
- Basic programming concepts and how they relate to JS
- JS and JSON data values: primitives (numbers, strings, booleans,
null
,undefined
), objects, arrays- How to use JS to get references to elements in the DOM and manipulate them on the fly
- How to use JS to create new elements on the fly and add them to the DOM
- How to run JS code as a response to user actions (Events)
Table of contents
Check-off
You need to come to TA Office Hours to get checked off for this lab (any of them, no appointment needed), OR submit your work asynchronously by filling out this form.
If you choose to submit your work asynchronously and have an incorrect or incomplete part of the lab, you will not receive any credit for the lab (we do not offer partial credit on labs). You may not resubmit this form nor ask for a synchronous check off for the same lab.
Lab 3 Rubric
To successfully complete this lab check-off, ensure your work meets all of the following requirements:
Lab 1+2 Functionality + Style
- All Lab 1+2 functionality and styling (light mode) remains.
Automatic Dark Mode
- Color scheme appropriately changes with theme switcher dropdown options.
- Navbar border color is properly adjusted for dark mode.
- Navbar hover background color is properly adjusted for dark mode.
- Theme switcher is at the top right corner.
- User’s theme preference persists across webpage refreshes.
- Switcher should have the correct theme value after a refresh and not the default “Auto” value.
Prerequisites
This lab assumes you have already completed Lab 1 and Lab 2, as we will use the same website as a starting point.
You will need to use a local server for this lab, as some features will not work under the file:
protocol (i.e. when opening the files directly in the browser). If you are using Live Preview, we recommend you switch to Live Server as Live Preview seems to have issues with newer CSS features.
To use Live Server, first ensure you have Live Server installed from Lab 0. Then, open the folder containing your portfolio in VS Code (Ctrl/Command + K + O). On the bottom bar of VS Code, click on the Go Live
button to start your server. A new tab in your web browser should automatically open with your home page on it! To end your server session, simply click on the Go Live
button again.
Slides
If you are a first-time coder, you are welcome to use this Skeleton that we created to help you go through Lab 3. To use this, download this file into your Lab 2 folder so that it is at the same level as your home page (i.e. it is not under any subfolders). Everything you must fill out and do has a TODO
as part of the comment or string. Some steps will require you to edit your html and css files as well.
At the end of this lab, you should have a webpage that follows this example as a reference. While your design doesn’t need to be identical, it should maintain a similar structure and styling. Note that this example does the optional Step 5: Better contact form step.
Step 1: Adding a JS file to all pages
For this lab, we will add a new file to our website: global.js
. It will go in your website root, next to index.html
and style.css
, and should be added to all pages using the <script>
tag (make sure to use the type="module"
attribute inside the tag). So, within our ‘head’ section in the ‘html’ files, we should now have a line like: <script type="module" src="[INSERT RELATIVE PATH TO global.js HERE]"></script>
Put the following code in global.js
:
console.log("IT’S ALIVE!");
function $$ (selector, context = document) {
return Array.from(context.querySelectorAll(selector));
}
For the rest of this, unless specified otherwise, you can assume that all code snippets will go into global.js
.
Visit all your pages, open the dev tools console (Ctrl/Command + Shift/Option + I), and make sure you see the message printed there.
Step 2: Automatic current page link
In Lab 2, we manually added a current class to the navigation link for the current page. That was tedious—let’s automate it with JavaScript!
First, remove the manual current
class from all your links. (Hint: check that you have deleted the ‘current’ keyword from all your html files.)
Step 2.1: Get an array of all nav links into a variable
We will use the $$
function (defined earlier) to select all elements inside a <nav>:
let navLinks = $$("nav a");
This gives us an array of all navigation links.
Step 2.2: Find the link to the current page
To identify the link pointing to the current page, we need three things:
-
The
array.find()
methodThis method returns the first array element that matches a condition. Example:
[1, 2, 3, 4].find(n => n > 2); // Returns 3
Try it in the console!
-
The
location
objectThis JavaScript object contains details about the current page’s URL, including:
location.host
→ The domain (e.g.,"example.com"
)location.pathname
→ The path after the domain (e.g.,"/about"
)
-
The anchor (
<a>
) element that stores an absolute URLEven if an
<a>
link uses a relative path, its.host
and.pathname
properties will be resolved to an absolute URL.
Putting it together, we can get the link to the current page via:
let currentLink = navLinks.find(a => a.host === location.host && a.pathname === location.pathname)
Step 2.3: Add the current
class to the current page link
Now that we have a reference to the current page link, we can add the current
class to it using element.classList.add(class1, class2, ...)
:
currentLink.classList.add("current");
But what if no link to the current page was found? In that case, navLinks.find()
will return undefined
and trying to access any property on currentLink
will throw an error.
There are two ways to fix this problem:
The first way is to use a conditional to only add the class if currentLink
is truthy:
if (currentLink) { // or if (currentLink !== undefined)
currentLink.classList.add("current");
}
This is more flexible, as we can run any amount of code inside the if
block, and we could even add an else
block to run code when no link is found, if needed.
However, if all we need is to prevent errors, we can use the optional chaining operator, i.e. ?.
instead of .
:
currentLink?.classList.add("current");
Step 3: Automatic navigation menu
But why stop here? Wasn’t it tedious to need to copy-pasta the navigation to all pages? And imagine the horror of adding a new page to our site: we’d have to update every single page!
We can automate this too!
Client-side JS is not the best way to handle site-wide templating, but it’s good as a temporary fix, and as a learning exercise.
First, remove the navigation menu from all pages, since we’re going to be adding it with JS. Also, comment out your code from Step 2 in global.js
by selecting it and pressing Cmd/Ctrl + /
, since we’ll now be adding the current
class at the same time as we add the links.
You can also comment out the code by selecting the code, going to ‘Edit’ at the top right of VS Code and clicking “Toggle block comment”.
Step 3.1: Adding the navigation menu
As we saw in the slides, there are many ways to design a data structure to hold the association of URLs (relative or absolute) and page titles. Let’s go with an array of objects for now, but if you want to use a different one (and handle the code differences) you’re welcome to!
let pages = [
{url: "", title: "Home"},
{url: "projects/", title: "Projects"},
// add the rest of your pages here
];
Then, use document.createElement()
and element.prepend()
to create a new <nav>
element at the beginning of the <body>
element. Congrats, we just used JS to create new HTML elements without writing any HTML code!
let nav = document.createElement("nav");
document.body.prepend(nav);
Then, we will use a for ... of
loop to iterate over the pages on our site and add <a>
elements in the <nav>
for each of them. To create each link and add it to the nav, you can use element.insertAdjacentHTML()
where element will be our nav element. Overall, it will look like this:
for (let p of pages) {
let url = p.url;
let title = p.title;
// Create link and add it to nav
nav.insertAdjacentHTML("beforeend", `<a href="${ url }">${ title }</a>` );
}
Save and preview: you should now have a navigation menu on every page that is added automatically!
However, there is a bit of a wart. Try your menu on different pages. Oh noes, the links only work properly on the home page! That is because we had previously used different relative URls for different pages, but now we are trying to use the same one across the entire website.
Let’s try to do with JS what we previously did manually (sensing a theme here?). What we previously did was that for any page that was not the home page, we added ../
to the URL, right? So what if we could detect if we’re not on the home page and add that ../
to the URL conditionally?
But how could we possibly detect if we’re on the home page in a way that works both locally and on our github.io
site? Sadly, there is no way (that will not bite us in the future) to tell entirely by looking at the URL.
We can however help the JS along by adding a class of home
to the root element (the <html>
element) of our home page (index.html),
<!DOCTYPE html>
<html lang="en" class="home">
...
</html>
and then using JS to check if that class is present and storing it in a constant variable since that is not likely to change:
const ARE_WE_HOME = document.documentElement.classList.contains("home");
Be sure to initialize this before the for
loop so that we can use the loop to programmatically check if we are home for each link.
The const
keyword is similar to let
, but it makes the variable immutable, i.e. it cannot be reassigned. Variables that should never change are called constants, and their names are UPPERCASE by convention.
Then, when creating the links inside the for
loop, use a conditional to add a ../
to the URL if we are not on the home page and the URL is not absolute. We can use an if statement for that:
if (!ARE_WE_HOME && !url.startsWith("http")) {
url = "../" + url;
}
Be sure to place the if statement before inserting the <a>
tag into the nav.
In general, you should always make all your modifications to an object before inserting them into the page. Otherwise, the modifications will not be visible since you will be inserting the unmodified version!
We can also do the same thing more concisely with a ternary operator instead: url = !ARE_WE_HOME && !url.startsWith("http") ? "../" + url : url
Step 3.2: Highlighting the current page and opening external links in a new tab
Our automatically added navigation menu works, but is missing all the bells and whistles of our original one:
- The current page is not highlighted anymore
- The link to your GitHub profile does not have
target="_blank"
to make it open in a new tab.
How can we add those back?
Let’s switch to a different method of creating these links, that is more verbose, but gives us more flexibility. Instead of appending HTML strings, we will create element objects in JS and set their attributes in JS, via properties (or setAttribute()
calls).
So, this line of JS:
nav.insertAdjacentHTML("beforeend", `<a href="${ url }">${ title }</a>` );
now becomes four lines:
let a = document.createElement("a");
a.href = url;
a.textContent = title;
nav.append(a);
Save and preview, and make sure nothing changed and that there are no errors.
We can now use a conditional to add the current
class, in a similar way to how we added it in step 2. We don’t need array.find()
this time, because we are already iterating over the links to create them. We can just add a conditional to check if the link is to the current page using exactly the same check as Step 2.2 and add the class if so.
All we need is to compare a.host
and a.pathname
to location.host
and location.pathname
and then use a.classList.add()
to add the class. The class should be added before we append each link to the nav in order for the property to be present.
if (a.host === location.host && a.pathname === location.pathname) {
a.classList.add("current");
}
You can even use a.className.toggle()
to do the checking and the class adding in one (very long) line: a.classList.toggle("current", a.host === location.host && a.pathname === location.pathname);
!
Similarly, we can add target="_blank"
to external links (such as the GitHub link) by setting a.target = "_blank"
to those links for which a.host
is not the same as location.host
(i.e. `if (a.host !== location.host)).
Just like class names, you can either use conditionals or do the checking and the attribute setting in one step, by using element.toggleAttribute()
.
Step 4: Dark mode!
Let’s add a dark mode switch to our site!
Step 4.1 Automatic dark mode.
In this step we will write CSS that will automatically adapt to the OS color scheme and tweak our style so that everything looks good on dark mode.
CSS provides a color-scheme
property that can be used to switch between light and dark mode.
color-scheme: light dark
tells the browser that the site can be rendered on either light or dark mode, depending on what the OS color scheme is. (Think of this like an automatic color scheme detector based on your computer’s settings)color-scheme: light
orcolor-scheme: dark
forces a specific color scheme.
When color-scheme
is dark
(or light dark
and the OS is in dark mode), you will notice several changes: The default background color will be a very dark gray instead of white, and the default text color will be white instead of black. We can actually access these special colors and use them in our CSS by using the canvas
and canvastext
system colors.
When applied to the root element (<html>
) this property will already get your site very close to a proper dark mode. Try it now! Add color-scheme: dark
or color-scheme: light
to the html
selector on your stylesheet so that it’s applied on every page.
Adjusting the border color
While it doesn’t look too bad, in light mode we picked a border color that looked very subtle (oklch(80% 3% 200)
This is because its lightness is fixed to 80%, which means it does not adapt to the color scheme. One way to fix that would be to make it a semi-transparent version of a darker, slightly more vibrant color (e.g. oklch(50% 10% 200)
border-bottom-color: oklch(50% 10% 200 / 40%);
Since we’re using it in two places, you may want to define a CSS variable for it on <nav>
:
nav {
--border-color: oklch(50% 10% 200 / 40%);
/* ... other styles and nested rules ... */
}
and then use that instead:
border-bottom-color: var(--border-color);
That should look about the same in light mode, and much better in dark mode.
Fixing the hover background color (optional)
If you did the optional hover background color in Lab 2, you may have noticed that it looks very bad in dark mode now.
That is because we defined that color with a fixed lightness (95%):
background-color: oklch(from var(--color-accent) 95% 5% h);
Just like our border color, it was very subtle in light mode, but sticks out like a sore thumb in dark mode. Worse, since the text color has changed, it is now illegible. Yikes!
We could fix that too by setting its lightness to 50% and increasing its chroma, then adding transparency. But here is another method.
We can make it a mix of the background color and the special canvas
color:
background-color: color-mix(in oklch, var(--color-accent), canvas 85%);
This should now look a lot better:
.
Step 4.2: Adding the dark mode switch
We want our switch to have three states: Automatic (the default, adapts to the OS color scheme), Light, and Dark. The heavy lifting is already done in the CSS, so all the switch needs to do is set the color-scheme
property appropriately on the root element.
Since this control doesn’t do anything without JS, you should create it with JS as well, similarly to what we did with the <nav>
menu in step 3. That way, you also don’t have to add it manually on every single page.
First we need to add the form controls for selecting the color scheme. A good option is a <select>
element (plus <option>
elements inside it), which creates a dropdown menu. We wrap a <label>
around it with a class (I used color-scheme
) so we can style it in our CSS. Putting it all together, we can use something like this to add our dropdown switch to the start of the <body>
element:
document.body.insertAdjacentHTML("afterbegin", `
<label class="color-scheme">
Theme:
<select>
<!-- TODO add <option> elements here -->
</select>
</label>`
);
For the value
attribute of your <option>
elements, use the actual values each option should set the color-scheme
property to (light dark
, light
, dark
), so that you don’t need to do any conversion in JS.
Now we have a nice dropdown to switch between light and dark mode, but it looks a little awkward and nothing happens when we click on it right now. In the next few steps, we will position the switcher at the top right and add JS to make the theme actually change!
You might wonder if it’s possible to detect the OS color scheme and somehow use that in the dropdown (e.g. instead of showing “Automatic” we could show “Automatic (Dark)”). It is! You can do this via matchMedia("(prefers-color-scheme: dark)").matches
. In fact, this is a media query and specifically the prefers-color-scheme
media feature. You can use media queries in your CSS too!
Step 4.3: Placing the switcher at the top right corner
Rather than laying out with the rest of the content, we want the switcher to always be at the top right corner. We will do that via CSS absolute positioning.
- First, we apply
position: absolute
to our switcher (the<label>
element with thecolor-scheme
class) Notice that this took the element out of the normal document flow, and it’s now on top of the content. - We specify offsets from the top and right edges of the document with
top
andright
properties. I used1rem
for both but you may want to use a different value (experiment with the dev tools and see what looks good to you). - Lastly, we want to ensure the label and switcher are next to each other on one line rather than vertically stacked. We can do this by setting our
.color-scheme
class to have adisplay: inline-flex
and adding agap
between the elements (I used4px
but feel free to use any amount that looks nice to you).
You’d probably also want to make the font a little smaller (I used 80%
) to make it less prominent and inherit the font-family
from the parent, otherwise the browser applies a different font to form controls (you can just add select
to your existing input, textarea, button
rule about that from Lab 2)
Step 4.4: Actually making it work
We now have all the UI in place, but nothing happens when we change the theme. Time to bring it to life with JavaScript!
The first step is to attach an input
event listener to our <select>
element so we can run JS code when the user changes it. To do that, we first need to get a reference to the <select>
element via document.querySelector(selector)
where selector
is the CSS selector you would use in your stylesheet to reference the <select>
element (Hint: What HTML element are we using for the switcher?). We should assign this to a variable (I called it select
) so that we can use it later.
let select = document.querySelector("select");
Then, we’d use the addEventListener()
function to add a listener for the input
event:
select.addEventListener("input", function (event) {
console.log("color scheme changed to", event.target.value);
});
Try it now: do you get the logged message in the console when you change the select element?
Okay, now we need to actually change the color scheme. As we’ve seen earlier, we can get a reference to the root element with document.documentElement
and we can set arbitrary CSS properties on any element via element.style.setProperty(property, value)
. Putting these together, we can set the color-scheme
property on the root element via:
document.documentElement.style.setProperty("color-scheme", event.target.value);
element.style
is an object that allows us to read and modify the inline style (i.e. the style
attribute) of an element.
Step 4.5: Saving the user’s preference
Right now, every time the page reloads, the theme resets. We need a way to save the user’s choice and apply it automatically when they return.
We’ll use the localStorage
object, which allows us to save key-value pairs in the browser. Unlike session storage, data in localStorage
persists even after the page is refreshed or the browser is closed.
There are two parts to this solution:
1) Storing the User’s Preference Whenever the user selects a new color scheme, we save it to localStorage
inside the event listener:
localStorage.colorScheme = event.target.value;
This ensures that the last selected theme is stored in the browser.
2) Applying the Saved Preference on Page Load When the page loads, we check if a colorScheme
value is stored in localStorage
. If it exists, we apply it:
if (localStorage.colorScheme) {
document.documentElement.style.setProperty("color-scheme", localStorage.colorScheme);
select.value = localStorage.colorScheme;
}
Why Do We Also Update select.value? If we don’t update the <select>
dropdown to match the stored value, the page will show one theme, but the dropdown will still display “Auto” (or the default). This would be confusing for the user! So we set select.value to the stored theme.
That should work! Try it now: change the color scheme, refresh the page, and see if it sticks.
Beyond trying out the functionality, there are two ways to verify that your data has been successfully saved in the browser’s local storage:
- In the Application tab of the dev tools, under the Storage section, you can see the
localStorage
object and its contents. - In the Console tab, you can type
localStorage
and see its contents.
If you look at our code, we’re setting the color scheme in two places: 1) inside the event listener when the user changes the theme and 2) when the page loads, if a saved theme exists. To make our code cleaner and avoid repetition, we could define a setColorScheme(colorScheme)
function that does that and call it in both places.
Step 5: Better contact form (Optional)
You may have noticed that our contact form from Lab 1 did not have the best usability. While it does open an email client and prefills our email address in the To field, everything else is encoded with this weird format in the body of the email:
We could actually make it work better, but it will need a little JS.
First, we’d start by removing enctype="text/plain"
and method="POST"
(or changing it to method="GET"
). An HTTP GET request encodes all submitted information in the URL, so our form basically helps us build (and visits) a URL like mailto:leaverou@mit.edu?subject=Hello&body=Sup?
. Try pasting that URL in your browser, and notice how it opens exactly the same window, you’d get by submitting your form!
Let’s also remove the “Email:” field that represented the sender’s email. Since this form opens a window in our mail client, and the email is sent from us anyway, it’s redundant.
So far it looks like this is quite an improvement already. Why didn’t we do it this way in Lab 1 then? Because without JS, this has a fatal flaw. Try writing a longer subject and/or message that includes spaces and submit the form again:
All our spaces have been replaced by +
signs!
Why did this happen? When it comes to URLs, spaces are considered special characters and are not technically allowed. There are two ways to encode them: as +
or as %20
. The former is older and non-standard, but still widely used. However, not all clients recognize it (e.g. our mail client doesn’t!) whereas percent encoding (%
plus a two-digit hexadecimal number, 20
for a space character) is the standard way and is always recognized.
Don’t see any +
signs? Some mail clients (e.g. Gmail) automatically decode the +
signs to spaces. However, you cannot have a form that only works in some mail clients and not others, so we need to fix this.
There is no way to fix this with HTML alone, which is why we did not use this method in Lab 1. But now that we know JS, we have superpowers! We will intercept the form submission, and build the URL ourselves.
To recap, our goal is to build the same URL, but with proper escaping this time. We don’t need to handle the escaping ourselves, that’s what the encodeURIComponent()
function is for.
First, get a reference to the <form>
element, and add an event listener to it for the submit
event (This is similar to how we attached an event listener in step 4.4 using a classname and document.querySelector(selector)
except now you are using a different HTML element inside the parentheses and waiting for “submit” instead of “click”).
Write this line of code and assign it a variable so that we can use it later!
let form = document.querySelector("INSERT CSS SELECTOR HERE FOR FORM ELEMENT");
form.addEventListener("submit", function (event) {
...
})
Since this is code that runs on every page, and not every page has a form, to avoid errors either use a conditional to check that the reference is non-empty, or use optional chaining, i.e. replace form.addEventListener()
above with form?.addEventListener()
instead.
In the event handler, call the event object’s method preventDefault()
to prevent the default form submission from happening. Remember that you can access the methods for an event object with the syntax object_name.method_name()
.
Save and preview your work. Make sure that the form does not open your email client anymore when you click submit.
form?.addEventListener("submit", function (event) {
event.preventDefault();
})
Then, in the event handler, we create a new variable (let’s call it data
) and assign it to be a new FormData
object from our HTML form.
form?.addEventListener("submit", function (event) {
event.preventDefault();
let data = new FormData(form);
})
The form
inside new FormData(form)
here refers to the variable name we used when we got a reference to our HTML <form>
element using document.querySelector
. If we had named the variable something else, let’s say let contactFormRef = document.querySelector("form")
, then when we create the FormData object, we would do let data = new FormData(contactFormRef)
instead.
We can then iterate over submitted fields using a for .. of
loop inside the event listener like this:
for (let [name, value] of data) {
// TODO build URL parameters here
console.log(name, value);
}
Save your work and submit the form. What do you see in the console? Now replace value
with encodeURIComponent(value)
and submit the form again. What changed? You should have seen your spaces in the message replaced with the encoded %20
instead!
We progressively build the URL by concatenating form.action
, "?"
and the names and encoded values (separated by =
) for each field. We also separate each field with &
.
The final url should look something like mailto:leaverou@mit.edu?subject=Hello&body=Sup?
where mailto:leaverou@mit.edu
is the form.action
, subject
is our first field name, Hello
is our first field value, body
is our second field name, and Sup?
is our second field value. Note that the field names and values are associated using =
and the name-value pairs are separated using &
.
You can do something like this inside your event listener:
let url = form.action + "?";
for (let [name, value] of data) {
url += (name + "=" + value + "&")
console.log(name, value);
}
Once we have our final URL, we can open it via:
location.href = url;
For any other URL, this would navigate to another page, but because this is a mailto:
URL, it will open our email client with the prefilled fields.
JS resources
- MDN: JavaScript Basics
- MDN: JavaScript First Steps
- MDN Tutorials on JS
- JS Garden for JS quirks and gotchas
- Learn JS Data, a series of Observable notebooks about the basics of manipulating data using JavaScript in the browser.
Videos
- JavaScript in 12 Minutes
- JS 1-Hour tutorial
- A series of interactive JavaScript Tutorials
- Udemy course
Books
- Eloquent Javascript by Marijn Haverbeke – free online book for programming beginners
- JavaScript: The Good Parts by Douglas Crockford (See also his YUI videos)
- Learning JavaScript Design Patterns by Addy Osmani