Back to Freecodecamp

Build a Theme Switcher

curriculum/challenges/english/blocks/lab-theme-switcher/687ad7be1ff45032ccf1d92f.md

latest14.8 KB
Original Source

--description--

In this lab, you will build an app that switches between different themes. When you switch to a different theme, the background color will change and a different message will display.

Objective: Fulfill the user stories below and get all the tests to pass to complete the lab.

User Stories:

  1. You should have a button element with the text Switch Theme.
  2. Your button element should have the following attributes:
    • An id attribute set to "theme-switcher-button".
    • An aria-haspopup attribute set to "true".
    • An aria-expanded attribute set to "false".
    • An aria-controls attribute set to "theme-dropdown".
  3. You should have a ul element with the following attributes:
    • An id attribute set to "theme-dropdown".
    • A role attribute set to "menu".
    • An aria-labelledby attribute set to "theme-switcher-button".
    • A hidden attribute.
  4. Your ul element should have at least two li elements with a role attribute set to "menuitem" and text of your choice representing a different theme.
  5. Each of your li elements should have an id attribute that starts with theme- and ends with the theme you set for the li element text. For example, if one of your themes is Light then your id should be theme-light.
  6. You should have an element with an aria-live attribute set to "polite".
  7. You should have a themes array with at least two objects that each contain a name and message property. The name will represent a different theme and the message will display when the theme switches. You are free to come up with name and message values of your choice but the name values should match the text content of your li elements.
  8. When a user clicks on the #theme-switcher-button, the #theme-dropdown element should display the available themes.
  9. When the #theme-dropdown element is displayed, the aria-expanded attribute should be set to "true" for the button element.
  10. When the users clicks on the #theme-switcher-button while the #theme-dropdown element is displayed, the menu should be closed and the hidden attribute should be added and the aria-expanded attribute should be set to "false".
  11. When a user clicks on the #theme-switcher-button and selects a theme, the corresponding theme-<name> class should be added to the body element and the related theme message you set in the themes array should display in the aria-live="polite" element.

--before-all--

js
const resetThemeSwitch = () => {
  const themeDropdownBtn = document.getElementById("theme-switcher-button");
  const themeDropdownEl = document.getElementById("theme-dropdown");

  // Reset attributes due to earlier button clicks interfering with these tests
  themeDropdownEl.setAttribute("hidden", "");
  themeDropdownBtn.setAttribute("aria-expanded", "false");
}

--hints--

You should have a button element.

js
assert.exists(document.querySelector("button"));

Your button element should have the text Switch Theme.

js
const btn = document.querySelector("button");
assert.equal(btn?.textContent.trim(), "Switch Theme");

Your button element should have an id attribute set to "theme-switcher-button".

js
const btn = document.querySelector("button");
assert.equal(btn?.getAttribute("id"), "theme-switcher-button");

Your button element should have an aria-haspopup attribute set to "true".

js
const btn = document.querySelector("button");
assert.equal(btn?.getAttribute("aria-haspopup"), "true");

Your button element should have an aria-expanded attribute set to "false".

js
const btn = document.querySelector("button");
assert.equal(btn?.getAttribute("aria-expanded"), "false");

Your button element should have an aria-controls attribute set to "theme-dropdown".

js
const btn = document.querySelector("button");
assert.equal(btn?.getAttribute("aria-controls"), "theme-dropdown");

You should have an ul element.

js
assert.exists(document.querySelector("ul"));

Your ul element should have an id attribute set to "theme-dropdown".

js
const ul = document.querySelector("ul");
assert.equal(ul?.getAttribute("id"), "theme-dropdown");

Your ul element should have an role attribute set to "menu".

js
const ul = document.querySelector("ul");
assert.equal(ul?.getAttribute("role"), "menu");

Your ul element should have an aria-labelledby attribute set to "theme-switcher-button".

js
const ul = document.querySelector("ul");
assert.equal(ul?.getAttribute("aria-labelledby"), "theme-switcher-button");

Your ul element should have a hidden attribute.

js
const ul = document.querySelector("ul");
assert.isTrue(ul?.hasAttribute("hidden"));

Your ul element should have at least two li elements.

js
const listItems = document.querySelectorAll("ul li");
assert.isAtLeast(listItems?.length, 2);

Each of your li elements should have a role attribute set to "menuitem".

js
const listItems = document.querySelectorAll("ul li");
assert.isNotEmpty(listItems);
listItems.forEach(li => {
  assert.equal(li?.getAttribute("role"), "menuitem");
})

Each of your li elements should have a piece of text that represents a theme.

js
const listItems = document.querySelectorAll("ul li");
assert.isNotEmpty(listItems);
listItems.forEach(li => {
  const text = li?.textContent.trim();
  assert.isTrue(text.length > 0);
})

Each of your li elements should have an id attribute that starts with theme- and ends with the corresponding theme for that element. Make sure your id value is all lowercase.

js
const listItems = document.querySelectorAll("ul li");
assert.isNotEmpty(listItems);
const listTitles = [...listItems].map(li => li?.textContent.trim().toLowerCase());

listItems.forEach((li, index) =>{
  assert.equal(li.getAttribute("id"), `theme-${listTitles[index]}`);
})

You should have an element with an aria-live attribute set to "polite".

js
assert.exists(document.querySelector('[aria-live="polite"]'));

You should have a themes array.

js
assert.isArray(themes);

Your themes array should have at least two objects.

js
assert.isAtLeast(themes?.length, 2);
themes.forEach(theme => {
  assert.isObject(theme);
});

Your themes array should have at least two objects each with a name and message property.

js
assert.isNotEmpty(themes);
themes?.forEach(theme => {
  assert.isObject(theme);
  assert.property(theme, "name");
  assert.property(theme, "message");
})

Your name and message property values should be strings that are not empty.

js
assert.isNotEmpty(themes);
themes?.forEach(theme => {
  assert.isObject(theme);
  assert.isString(theme["name"]);
  assert.isNotEmpty(theme["name"]);
  assert.isString(theme["message"]);
  assert.isNotEmpty(theme["message"]);
})

Each of your name property values should match the text content of one of your li elements. Make sure these values are all in lowercase.

js
const listItems = [...document.querySelectorAll("ul li")]?.map(li => li.textContent.toLowerCase().trim());
const namesArr = themes?.map(({name}) => name);

assert.equal(listItems.length, namesArr.length);
assert.isTrue(namesArr?.every(name => listItems.includes(name)));

When a user clicks on the #theme-switcher-button to display the theme options, the hidden attribute should be removed from the #theme-dropdown element.

js
const themeDropdownBtn = document.getElementById("theme-switcher-button");
const themeDropdownEl = document.getElementById("theme-dropdown");

assert.isTrue(themeDropdownEl?.hasAttribute("hidden"));
themeDropdownBtn?.dispatchEvent(new Event("click"));
assert.isFalse(themeDropdownEl?.hasAttribute("hidden"));

When the #theme-dropdown element is displayed, the aria-expanded attribute should be set to "true" for the button element.

js
resetThemeSwitch()
const themeDropdownBtn = document.getElementById("theme-switcher-button");

themeDropdownBtn?.dispatchEvent(new Event("click"));
assert.equal(themeDropdownBtn?.getAttribute("aria-expanded"), "true");

When the users clicks on the #theme-switcher-button while the #theme-dropdown element is displayed, the dropdown menu should be set back to hidden.

js
resetThemeSwitch()
const themeDropdownBtn = document.getElementById("theme-switcher-button");
const themeDropdownEl = document.getElementById("theme-dropdown");

themeDropdownBtn?.dispatchEvent(new Event("click"));
assert.isFalse(themeDropdownEl?.hasAttribute("hidden"));
themeDropdownBtn?.dispatchEvent(new Event("click"));
assert.isTrue(themeDropdownEl?.hasAttribute("hidden"));

When the users clicks on the #theme-switcher-button while the #theme-dropdown element is displayed, the aria-expanded attribute should be set to "false".

js
resetThemeSwitch()
const themeDropdownBtn = document.getElementById("theme-switcher-button");

themeDropdownBtn?.dispatchEvent(new Event("click"));
assert.equal(themeDropdownBtn?.getAttribute("aria-expanded"), "true");
themeDropdownBtn?.dispatchEvent(new Event("click"));
assert.equal(themeDropdownBtn?.getAttribute("aria-expanded"), "false");

When a user clicks on the #theme-switcher-button and selects a theme, the corresponding theme-<name> class should be added to the body element.

js
const themeDropdownBtn = document.getElementById("theme-switcher-button");
const listItems = document.querySelectorAll("#theme-dropdown li");

themeDropdownBtn.dispatchEvent(new Event("click"));

const clickedItem = listItems[1];
clickedItem?.dispatchEvent(new Event("click", {bubbles: true}));

const expectedClass = clickedItem?.id;
const actualClass = document.body.className;

assert.equal(actualClass, expectedClass);

When a user clicks the #theme-switcher-button and selects a theme, a message related to the selected theme from the themes array should be displayed in the aria-live="polite" element.

js
const liveRegion = document.querySelector('[aria-live="polite"]');
const themeDropdownBtn = document.getElementById("theme-switcher-button");
const listItems = document.querySelectorAll("ul li");

themeDropdownBtn?.dispatchEvent(new Event("click"));
listItems[1]?.dispatchEvent(new Event("click", {bubbles: true}));

const selectedThemeName = listItems[1]?.textContent.toLowerCase().trim();
const selectedTheme = themes?.find(theme => theme.name === selectedThemeName);

assert.equal(liveRegion?.textContent, selectedTheme?.message);

--seed--

--seed-contents--

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Theme Switcher</title>
  <link rel="stylesheet" href="./styles.css">
</head>
<body>

  <script src="./script.js"></script>
</body>
</html>
css
body {
  margin: 0;
  font-family: sans-serif;
  transition: background 0.3s, color 0.3s;
}

ul {
  margin: 0;
  padding: 0;
}

li {
  list-style-type: none;
}

#status {
  text-align: center;
  min-height: 20px;
}
js

--solutions--

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Theme Switcher</title>
  <link rel="stylesheet" href="./styles.css">
</head>
<body class="theme-light">  
  <button
    class="dropdown-btn"
    id="theme-switcher-button"
    aria-haspopup="true"
    aria-expanded="false"
    aria-controls="theme-dropdown"
  >
    Switch Theme
  </button>

  <ul
    class="dropdown-content"
    id="theme-dropdown"
    role="menu"
    aria-labelledby="theme-switcher-button"
    hidden
  >
    <li class="dropdown-item" id="theme-light" role="menuitem">Light</li>
    <li class="dropdown-item" id="theme-dark" role="menuitem">Dark</li>
    <li class="dropdown-item" id="theme-ocean" role="menuitem">Ocean</li>
    <li class="dropdown-item" id="theme-nord" role="menuitem">Nord</li>
  </ul>
  
  <div id="status" aria-live="polite"></div>
  
  <script src="./script.js"></script>
</body>
</html>
css
body {
  margin: 0;
  font-family: sans-serif;
  transition: background 0.3s, color 0.3s;
}

ul {
  margin: 0;
  padding: 0;
}

li {
  list-style-type: none;
}

.dropdown-btn {
  position: relative;
  padding: 10px 20px;
  border: none;
  cursor: pointer;
}

.dropdown-content {
  position: absolute;
  background-color: #ffffff;
  min-width: 160px;
  box-shadow: 0px 4px 8px rgba(0,0,0,0.2);
  z-index: 1;
}

.dropdown-item {
  width: 100%;
  padding: 12px;
  color: #000000;
  background-color: #ffffff;
  cursor: pointer;
}

.dropdown-item:hover {
  background-color: #f1f1f1;
}

#status {
  text-align: center;
  min-height: 20px;
}

/* Themes */
.theme-light {
  background-color: #ffffff;
  color: #000000;
}

.theme-light .dropdown-btn {
  background-color: #d3b3aa;
  color: #000000;
}

.theme-dark {
  background-color: #121212;
  color: #ffffff;
}

.theme-dark .dropdown-btn {
  background-color: #4f4f4f;
  color: #ffffff;
}

.theme-ocean {
  background-color: #0077be;
  color: #e0f7fa;
}

.theme-ocean .dropdown-btn {
  background-color: #193e51;
  color: #ffffff;
}

.theme-nord {
  background-color: #2e3440;
  color: #d8dee9;
}

.theme-nord .dropdown-btn {
  background-color: #596680;
  color: #ffffff;
}
js
const themes = [
  {
    name: "light",
    message: "Hello sunshine — Light theme is on!"
  },
  {
    name: "dark",
    message: "The night is yours — Dark theme is on!"
  },
  {
    name: "nord",
    message: "The frost has settled - Nord theme is on!"
  },
  {
    name: "ocean",
    message: "Blue skies and high tides — Ocean theme is on!"
  },
];

const setTheme = (theme) => {
  document.body.className = "";
  document.body.classList.add(`theme-${theme.name}`);
};

const updateStatus = (theme) => {
  const status = document.getElementById("status");
  status.textContent = theme.message;
};

document.addEventListener("DOMContentLoaded", () => {
  themes.forEach((theme) => {
    const button = document.getElementById(`theme-${theme.name}`);
    
    if (button) {
      button.addEventListener("click", () => {
        setTheme(theme);
        updateStatus(theme);
        closeMenu();
      });
    }
  });

  const toggleButton = document.getElementById("theme-switcher-button");
  const menu = document.getElementById("theme-dropdown");

  toggleButton.addEventListener("click", () => {
    const isExpanded = toggleButton.getAttribute("aria-expanded") === "true";
    if (isExpanded) {
      closeMenu();
    } else {
      openMenu();
    }
  });

  function closeMenu() {
    menu.setAttribute("hidden", "");
    toggleButton.setAttribute("aria-expanded", "false");
  }

  function openMenu() {
    menu.removeAttribute("hidden");
    toggleButton.setAttribute("aria-expanded", "true");
  }

  // Close the menu when clicking outside
  document.addEventListener("click", (e) => {
    if (!toggleButton.contains(e.target) && !menu.contains(e.target)) {
      closeMenu();
    }
  });
});