From 48bd6c13b63dc31bed303645d07dcbc695d8454a Mon Sep 17 00:00:00 2001 From: Holly Cummins Date: Tue, 14 May 2024 22:23:38 +0100 Subject: [PATCH] Add toggle for dark mode, using local storage --- __mocks__/match-media.js | 13 ++++ src/components/headers/dark-mode-toggle.js | 63 ++++++++++++++++ .../headers/dark-mode-toggle.test.js | 71 +++++++++++++++++++ src/components/headers/navigation.js | 5 ++ src/components/headers/navigation.test.js | 5 ++ src/components/seo.js | 5 +- src/components/util/dark-mode-helper.js | 36 ++++++++++ src/pages/404.js | 6 +- src/pages/index.js | 6 +- src/style.css | 48 ++++++------- src/templates/extension-detail.js | 2 + 11 files changed, 232 insertions(+), 28 deletions(-) create mode 100644 __mocks__/match-media.js create mode 100644 src/components/headers/dark-mode-toggle.js create mode 100644 src/components/headers/dark-mode-toggle.test.js create mode 100644 src/components/util/dark-mode-helper.js diff --git a/__mocks__/match-media.js b/__mocks__/match-media.js new file mode 100644 index 000000000000..f8a175ee4f2d --- /dev/null +++ b/__mocks__/match-media.js @@ -0,0 +1,13 @@ +Object.defineProperty(window, "matchMedia", { + writable: true, + value: jest.fn().mockImplementation(query => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}) \ No newline at end of file diff --git a/src/components/headers/dark-mode-toggle.js b/src/components/headers/dark-mode-toggle.js new file mode 100644 index 000000000000..b28f68301de1 --- /dev/null +++ b/src/components/headers/dark-mode-toggle.js @@ -0,0 +1,63 @@ +import * as React from "react" +import styled from "styled-components" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faCog, faMoon, faSun } from "@fortawesome/free-solid-svg-icons" +import { getDisplayModeFromLocalStorageNoDefault, setDisplayMode } from "../util/dark-mode-helper" + +const ModeIcon = styled(({ ...props }) => )` + padding-left: 0; + display: inline-block; + width: 1em; +` + +const Toggle = styled.li` + display: block; + padding: 16px 15px; + padding-left: 10px; /* hacky override to match spacing (more or less) to parent site */ +` + +const modes = [{ + alt: "sun", + icon: faSun, + theme: "light", + title: "'light': 'Color scheme: light; next: system preferences" +}, { + alt: "moon", + icon: faMoon, + theme: "dark", + title: "'dark': 'Color scheme: dark; next: light" +}, + { + alt: "cog", + icon: faCog, + theme: "system", + title: "'system': 'Color scheme: system preferences; next: dark'" + }] + +const DarkModeToggle = () => { + + const startMode = getDisplayModeFromLocalStorageNoDefault() + const startIndex = startMode ? modes.findIndex(e => e.theme === startMode) : 0 + + const [modeIndex, setModeIndex] = React.useState(startIndex) + const mode = modes[modeIndex] + + const toggleMode = () => { + const newIndex = (modeIndex + 1) % modes.length + const newTheme = modes[newIndex].theme + setDisplayMode(newTheme) + + setModeIndex(newIndex) + } + + + return ( + + + + ) +} + +export { DarkModeToggle } diff --git a/src/components/headers/dark-mode-toggle.test.js b/src/components/headers/dark-mode-toggle.test.js new file mode 100644 index 000000000000..fd156f6c97f9 --- /dev/null +++ b/src/components/headers/dark-mode-toggle.test.js @@ -0,0 +1,71 @@ +import React from "react" +// eslint-disable-next-line jest/no-mocks-import +import "../../../__mocks__/match-media" +import { render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { DarkModeToggle } from "./dark-mode-toggle" + +describe("dark mode toggle", () => { + + const sunIconTitle = "sun" + const moonIconTitle = "moon" + const cogIconTitle = "cog" + + + let user + beforeEach(() => { + user = userEvent.setup() + render( + + ) + }) + + + it("renders a dark light mode icon", () => { + expect(screen.getByTitle(sunIconTitle)).toBeInTheDocument() + }) + + it("shows the menu as light before any click", async () => { + expect(screen.getByTitle(sunIconTitle)).toBeInTheDocument() + }) + + it("clicking on the menu flips the icon", async () => { + await user.click(screen.getByTitle(sunIconTitle)) + expect(screen.getByTitle(moonIconTitle)).toBeInTheDocument() + + await user.click(screen.getByTitle(moonIconTitle)) + expect(screen.getByTitle(cogIconTitle)).toBeInTheDocument() + + await user.click(screen.getByTitle(cogIconTitle)) + expect(screen.getByTitle(sunIconTitle)).toBeInTheDocument() + }) + + it("updates local storage", async () => { + await user.click(screen.getByTitle(sunIconTitle)) + expect(localStorage.getItem("color-theme")).toBe("dark") + + await user.click(screen.getByTitle(moonIconTitle)) + expect(localStorage.getItem("color-theme")).toBe("system") + + await user.click(screen.getByTitle(cogIconTitle)) + expect(localStorage.getItem("color-theme")).toBe("light") + }) + + it("updates document classes", async () => { + expect(document.documentElement.classList.value).toBe("") + expect(document.documentElement.classList.length).toBe(0) + + await user.click(screen.getByTitle(sunIconTitle)) + expect(document.documentElement.classList.item(0)).toBe("dark") + + await user.click(screen.getByTitle(moonIconTitle)) + // Our mock media preference is set to light mode + expect(document.documentElement.classList.length).toBe(0) + + await user.click(screen.getByTitle(cogIconTitle)) + expect(document.documentElement.classList.value).toBe("") + expect(document.documentElement.classList.length).toBe(0) + + }) +}) + diff --git a/src/components/headers/navigation.js b/src/components/headers/navigation.js index 29bd0c030d10..df7a64897238 100644 --- a/src/components/headers/navigation.js +++ b/src/components/headers/navigation.js @@ -8,6 +8,7 @@ import { faBars, faGlobe } from "@fortawesome/free-solid-svg-icons" import { NavEntry, Submenu } from "./submenu" import config from "../../../gatsby-config.js" +import { DarkModeToggle } from "./dark-mode-toggle" const NavToggle = styled.label` font-size: 27.2px; @@ -286,6 +287,9 @@ const Navigation = () => { ) + const darkModeToggle = + + const menus = ( <> {about} @@ -294,6 +298,7 @@ const Navigation = () => { {community} {callToAction} {translations} + {darkModeToggle} ) diff --git a/src/components/headers/navigation.test.js b/src/components/headers/navigation.test.js index 876d42290472..3627fa591690 100644 --- a/src/components/headers/navigation.test.js +++ b/src/components/headers/navigation.test.js @@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event" const barsIconTitle = "bars" const globeIconTitle = "globe" +const sunIconTitle = "sun" describe("navigation bar", () => { const linkTitle = "Community" @@ -28,6 +29,10 @@ describe("navigation bar", () => { expect(screen.getByTitle(globeIconTitle)).toBeInTheDocument() }) + it("renders a dark light mode icon", () => { + expect(screen.getByTitle(sunIconTitle)).toBeInTheDocument() + }) + it("does not render a hamburger menu", () => { expect(screen.queryByTitle(barsIconTitle)).toBeNull() }) diff --git a/src/components/seo.js b/src/components/seo.js index 0f1cfe10096a..87fe1db07670 100644 --- a/src/components/seo.js +++ b/src/components/seo.js @@ -7,7 +7,8 @@ import * as React from "react" import PropTypes from "prop-types" -import { useStaticQuery, graphql } from "gatsby" +import { graphql, useStaticQuery } from "gatsby" +import { getDisplayModeFromLocalStorage } from "./util/dark-mode-helper" const Seo = ({ description, lang, title, children }) => { const { site } = useStaticQuery( @@ -43,6 +44,8 @@ const Seo = ({ description, lang, title, children }) => { /> + + {children} ) diff --git a/src/components/util/dark-mode-helper.js b/src/components/util/dark-mode-helper.js new file mode 100644 index 000000000000..a6a839514d9d --- /dev/null +++ b/src/components/util/dark-mode-helper.js @@ -0,0 +1,36 @@ +function prefersDarkMode() { + return window.matchMedia("(prefers-color-scheme: dark)").matches +} + +const localStorageKey = "color-theme" +const light = "light" +const dark = "dark" +const system = "system" + +export function getDisplayModeFromLocalStorageNoDefault() { + return localStorage.getItem(localStorageKey) +} + +export const getDisplayModeFromLocalStorage = () => { + return getDisplayModeFromLocalStorageNoDefault() || light +} + +function adjustCssClasses(storedTheme) { + if (storedTheme === dark || (storedTheme === system && prefersDarkMode())) { + document.documentElement.classList.add(dark) + } else { + document.documentElement.classList.remove(dark) + } +} + +export const initialiseDisplayModeFromLocalStorage = () => { + const theme = getDisplayModeFromLocalStorage() + adjustCssClasses(theme) +} + +export const setDisplayMode = (newTheme) => { + localStorage.setItem(localStorageKey, newTheme) + adjustCssClasses(newTheme) + const themeMetadata = document.querySelector("meta[name=\"theme-color\"]") + if (themeMetadata) themeMetadata.content = newTheme +} \ No newline at end of file diff --git a/src/pages/404.js b/src/pages/404.js index 4eeb14ce4c8f..c0fea010d199 100644 --- a/src/pages/404.js +++ b/src/pages/404.js @@ -3,6 +3,7 @@ import { graphql } from "gatsby" import Layout from "../components/layout" import Seo from "../components/seo" +import { initialiseDisplayModeFromLocalStorage } from "../components/util/dark-mode-helper" const NotFoundPage = ({ data, location }) => { const siteTitle = data.site.siteMetadata.title @@ -15,7 +16,10 @@ const NotFoundPage = ({ data, location }) => { ) } -export const Head = () => +export const Head = () => { + initialiseDisplayModeFromLocalStorage() + return +} export default NotFoundPage diff --git a/src/pages/index.js b/src/pages/index.js index 82dd18d5127d..b12ed76419fe 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -4,6 +4,7 @@ import { graphql } from "gatsby" import Layout from "../components/layout" import Seo from "../components/seo" import ExtensionsList from "../components/extensions-list" +import { initialiseDisplayModeFromLocalStorage } from "../components/util/dark-mode-helper" const Index = ({ data, location }) => { const siteTitle = data.site.siteMetadata?.title || `Title` @@ -33,7 +34,10 @@ export default Index * * See: https://www.gatsbyjs.com/docs/reference/built-in-components/gatsby-head/ */ -export const Head = () => +export const Head = () => { + initialiseDisplayModeFromLocalStorage() + return +} export const pageQuery = graphql` query { diff --git a/src/style.css b/src/style.css index 24834c2c0a62..9fad0ed695d2 100644 --- a/src/style.css +++ b/src/style.css @@ -44,37 +44,35 @@ html { } -/*@media (prefers-color-scheme: dark) {*/ -@media screen and (max-width: 1px) { - html { - --link-color: #9BCAFA; - --link-color-hover: #CC0000; - --link-color-visited: #AA4494; - --main-background-color: #121212; - --main-text-color: #B5B5B5; - --main-code-color: #F59B00; - --sec-background-color: #333333; - --sec-text-color: #B5B5B5; - --title-background-color: #0e0e0e; - --breadcrumb-background-color: #0d1c2c; +html.dark { + --link-color: #9BCAFA; + --link-color-hover: #CC0000; + --link-color-visited: #AA4494; + --main-background-color: #121212; + --main-text-color: #B5B5B5; + --main-code-color: #F59B00; + --sec-background-color: #333333; + --sec-text-color: #B5B5B5; + --title-background-color: #0e0e0e; + --breadcrumb-background-color: #0d1c2c; - --code-text-color: #B5B5B5; + --code-text-color: #B5B5B5; - --card-outline: #555555; - --unlisted-outline-color: #DCDCDC; - --unlisted-text-color: #CCCCCC; - --card-background-color-hover: #333333; - --card-background-color: #0f0f0f; + --card-outline: #555555; + --unlisted-outline-color: #DCDCDC; + --unlisted-text-color: #CCCCCC; + --card-background-color-hover: #333333; + --card-background-color: #0f0f0f; - /* not in the main site additions */ - --gentle-alert-background-color: #292929; + /* not in the main site additions */ + --gentle-alert-background-color: #292929; - --code-background-color: #333333; - --code-border-color: #222222; + --code-background-color: #333333; + --code-border-color: #222222; + + --cta-background-color: #0B58AB; - --cta-background-color: #0B58AB; - } /* code highlighting overrides */ .hljs-built_in, .hljs-selector-tag, .hljs-section, .hljs-link, .hljs-function, .hljs-params { diff --git a/src/templates/extension-detail.js b/src/templates/extension-detail.js index 0a2fe786b26b..98dc39892197 100644 --- a/src/templates/extension-detail.js +++ b/src/templates/extension-detail.js @@ -18,6 +18,7 @@ import { getQueryParams, useQueryParamString } from "react-use-query-param-strin // This caching is important to allow our styles to take precedence over the default ones // See https://github.com/JedWatson/react-select/issues/4230 import createCache from "@emotion/cache" +import { initialiseDisplayModeFromLocalStorage } from "../components/util/dark-mode-helper" createCache({ key: "my-select-cache", @@ -513,6 +514,7 @@ const ExtensionDetailTemplate = ({ // TODO how is this used? export const Head = ({ data: { extension } }) => { + initialiseDisplayModeFromLocalStorage() return }