Skip to content

Commit

Permalink
Add toggle for dark mode, using local storage
Browse files Browse the repository at this point in the history
  • Loading branch information
holly-cummins committed May 16, 2024
1 parent f76bf62 commit 48bd6c1
Show file tree
Hide file tree
Showing 11 changed files with 232 additions and 28 deletions.
13 changes: 13 additions & 0 deletions __mocks__/match-media.js
Original file line number Diff line number Diff line change
@@ -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(),
})),
})
63 changes: 63 additions & 0 deletions src/components/headers/dark-mode-toggle.js
Original file line number Diff line number Diff line change
@@ -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 }) => <FontAwesomeIcon {...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 (
<Toggle title={mode.title}
onClick={toggleMode}
>
<ModeIcon aria-label={mode.theme} icon={mode.icon} color="var(--navbar-text-color)" title={mode.alt} />
</Toggle>
)
}

export { DarkModeToggle }
71 changes: 71 additions & 0 deletions src/components/headers/dark-mode-toggle.test.js
Original file line number Diff line number Diff line change
@@ -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(
<DarkModeToggle />
)
})


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)

})
})

5 changes: 5 additions & 0 deletions src/components/headers/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -286,6 +287,9 @@ const Navigation = () => {
</Submenu>
)

const darkModeToggle = <DarkModeToggle />


const menus = (
<>
{about}
Expand All @@ -294,6 +298,7 @@ const Navigation = () => {
{community}
{callToAction}
{translations}
{darkModeToggle}
</>
)

Expand Down
5 changes: 5 additions & 0 deletions src/components/headers/navigation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
})
Expand Down
5 changes: 4 additions & 1 deletion src/components/seo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -43,6 +44,8 @@ const Seo = ({ description, lang, title, children }) => {
/>
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={metaDescription} />
<meta name="theme-color" content={getDisplayModeFromLocalStorage()} />

{children}
</>
)
Expand Down
36 changes: 36 additions & 0 deletions src/components/util/dark-mode-helper.js
Original file line number Diff line number Diff line change
@@ -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
}
6 changes: 5 additions & 1 deletion src/pages/404.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,7 +16,10 @@ const NotFoundPage = ({ data, location }) => {
)
}

export const Head = () => <Seo title="404: Not Found" />
export const Head = () => {
initialiseDisplayModeFromLocalStorage()
return <Seo title="404: Not Found" />
}

export default NotFoundPage

Expand Down
6 changes: 5 additions & 1 deletion src/pages/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -33,7 +34,10 @@ export default Index
*
* See: https://www.gatsbyjs.com/docs/reference/built-in-components/gatsby-head/
*/
export const Head = () => <Seo title="All extensions" />
export const Head = () => {
initialiseDisplayModeFromLocalStorage()
return <Seo title="All extensions" />
}

export const pageQuery = graphql`
query {
Expand Down
48 changes: 23 additions & 25 deletions src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions src/templates/extension-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -513,6 +514,7 @@ const ExtensionDetailTemplate = ({

// TODO how is this used?
export const Head = ({ data: { extension } }) => {
initialiseDisplayModeFromLocalStorage()
return <Seo title={extension.name} description={extension.description} />
}

Expand Down

0 comments on commit 48bd6c1

Please sign in to comment.