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. Go Live Button

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.

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.)

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.

To identify the link pointing to the current page, we need three things:

  1. The array.find() method

    This 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!

  2. The location object

    This 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")
  3. The anchor (<a>) element that stores an absolute URL

    Even 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)

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

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 or color-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)) but in contrast to the almost-black background, it looks very harsh.

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)) with 40% opacity:

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.

The menu border color before and after the change (animated gif).

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.

  1. First, we apply position: absolute to our switcher (the <label> element with the color-scheme class) Notice that this took the element out of the normal document flow, and it’s now on top of the content.
  2. We specify offsets from the top and right edges of the document with top and right properties. I used 1rem for both but you may want to use a different value (experiment with the dev tools and see what looks good to you).
  3. 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 a display: inline-flex and adding a gap between the elements (I used 4px 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 localStoragepersists 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:

  1. In the Application tab of the dev tools, under the Storage section, you can see the localStorage object and its contents.
  2. 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

Videos

Books