-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add toggle for dark mode, using local storage
- Loading branch information
1 parent
f76bf62
commit 48bd6c1
Showing
11 changed files
with
232 additions
and
28 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(), | ||
})), | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
|
||
}) | ||
}) | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters