Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ changes.
`sidebar.group_by_dir` report errors
([#290](https://github.com/feel-co/ndg/issues/290)).

- A theme toggle in the page header. A click selects the light or the dark
theme, and the page keeps the selection in `localStorage`. When the selected
theme is the same as the system theme, the page follows the system theme
again. The toggle also selects the matching `<source>` of a `<picture>` with a
`prefers-color-scheme` media query. The page sends an `ndg:themechange` event
on `document` when the theme changes.

### Changed

- `ndg init` now generates configuration from the config types and defaults,
Expand Down
318 changes: 272 additions & 46 deletions crates/ndg-templates/templates/default.css

Large diffs are not rendered by default.

39 changes: 38 additions & 1 deletion crates/ndg-templates/templates/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
<script>
document.documentElement.classList.remove("no-js");
document.documentElement.classList.add("js");
// Apply sidebar state immediately to prevent flash
// Apply sidebar state and theme immediately to prevent flash
(function () {
try {
if (localStorage.getItem("sidebar-collapsed") === "true") {
document.documentElement.classList.add("sidebar-collapsed");
}
// Apply a theme that the reader selected. Without one, the theme
// follows the color scheme of the system.
var theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") {
document.documentElement.dataset.theme = theme;
}
} catch (e) {
// localStorage unavailable
}
Expand Down Expand Up @@ -47,6 +53,37 @@ <h1 class="site-title">
<div id="search-results" class="search-results"></div>
</div>
{% endif %}
<button
type="button"
class="theme-toggle js-only"
data-theme-toggle
aria-label="Switch the theme"
title="Switch the theme"
>
<svg
class="theme-icon-moon"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
>
<path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z"></path>
</svg>
<svg
class="theme-icon-sun"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
>
<circle cx="12" cy="12" r="4"></circle>
<path
d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"
></path>
</svg>
</button>
</header>

<div class="layout">
Expand Down
39 changes: 38 additions & 1 deletion crates/ndg-templates/templates/lib.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
<script>
document.documentElement.classList.remove("no-js");
document.documentElement.classList.add("js");
// Apply sidebar state immediately to prevent flash
// Apply sidebar state and theme immediately to prevent flash
(function () {
try {
if (localStorage.getItem("sidebar-collapsed") === "true") {
Expand All @@ -21,6 +21,12 @@
if (btn) btn.setAttribute("aria-expanded", "false");
});
}
// Apply a theme that the reader selected. Without one, the theme
// follows the color scheme of the system.
var theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") {
document.documentElement.dataset.theme = theme;
}
} catch (e) {
// localStorage unavailable; proceed with default expanded state
}
Expand Down Expand Up @@ -51,6 +57,37 @@
<div id="search-results" class="search-results"></div>
</div>
{% endif %}
<button
type="button"
class="theme-toggle js-only"
data-theme-toggle
aria-label="Switch the theme"
title="Switch the theme"
>
<svg
class="theme-icon-moon"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
>
<path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z"></path>
</svg>
<svg
class="theme-icon-sun"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
>
<circle cx="12" cy="12" r="4"></circle>
<path
d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"
></path>
</svg>
</button>
</header>

<div class="layout">
Expand Down
110 changes: 110 additions & 0 deletions crates/ndg-templates/templates/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -1110,6 +1110,9 @@ function setupOptionsPage(signal, content) {
function initializePage() {
const signal = beginPageLifecycle();

// The images of a new page follow the selected theme too.
syncThemedImages(currentTheme());

// Highlight the active nav item before the mobile nav is cloned from it.
markActiveNav();

Expand Down Expand Up @@ -1338,13 +1341,120 @@ function initializePage() {
);
}

// Light and dark theme. The inline script of each page applies the stored
// theme to `<html data-theme>` before the first paint. Without `data-theme`,
// the theme is `auto` and follows the color scheme of the system. The
// stylesheet reads `data-theme`, so this code only changes it, stores it,
// updates the images and labels, and dispatches `ndg:themechange` on
// `document` with the `mode` and the `resolved` theme.
const THEME_STORAGE_KEY = "theme";
const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)";

function currentTheme() {
const theme = document.documentElement.dataset.theme;
return theme === "light" || theme === "dark" ? theme : "auto";
}

function systemTheme() {
return window.matchMedia(DARK_SCHEME_QUERY).matches ? "dark" : "light";
}

function resolveTheme(mode) {
return mode === "auto" ? systemTheme() : mode;
}

// A `<source media="(prefers-color-scheme: dark)">` of a `<picture>` follows
// only the system. For a selected theme, replace each color scheme feature
// with a feature that always matches or never matches. A browser shows the
// page on a bitmap screen, thus `(grid: 0)` always matches and `(grid: 1)`
// never matches. The `data-scheme-media` attribute keeps the original query.
function syncThemedImages(mode, root = document) {
const resolved = resolveTheme(mode);
root
.querySelectorAll(
"picture > source[media*='prefers-color-scheme'], picture > source[data-scheme-media]",
)
.forEach((source) => {
source.dataset.schemeMedia ??= source.media;
source.media =
mode === "auto"
? source.dataset.schemeMedia
: source.dataset.schemeMedia.replace(
/\(\s*prefers-color-scheme\s*:\s*(light|dark)\s*\)/gi,
(_, scheme) =>
scheme.toLowerCase() === resolved ? "(grid: 0)" : "(grid: 1)",
);
});
}

// The label tells what a click does, as the icon does.
function syncThemeToggles(mode) {
const label =
resolveTheme(mode) === "dark"
? "Switch to the light theme"
: "Switch to the dark theme";
document.querySelectorAll("[data-theme-toggle]").forEach((toggle) => {
toggle.setAttribute("aria-label", label);
toggle.setAttribute("title", label);
});
}

function applyTheme(mode) {
if (mode === "auto") {
delete document.documentElement.dataset.theme;
} else {
document.documentElement.dataset.theme = mode;
}
syncThemeToggles(mode);
syncThemedImages(mode);
document.dispatchEvent(
new CustomEvent("ndg:themechange", {
detail: { mode, resolved: resolveTheme(mode) },
}),
);
}

// The toggle selects the other theme. When the other theme is the theme of
// the system, the toggle removes the selection and goes back to `auto`.
function nextTheme(mode, system) {
const resolved = mode === "auto" ? system : mode;
const other = resolved === "dark" ? "light" : "dark";
return other === system ? "auto" : other;
}

function setupThemeToggle() {
syncThemeToggles(currentTheme());

document.addEventListener("click", (event) => {
if (!event.target.closest?.("[data-theme-toggle]")) return;
const mode = nextTheme(currentTheme(), systemTheme());
try {
if (mode === "auto") {
localStorage.removeItem(THEME_STORAGE_KEY);
} else {
localStorage.setItem(THEME_STORAGE_KEY, mode);
}
} catch {
// localStorage unavailable: the theme applies to this page only
}
applyTheme(mode);
});

// In `auto`, the stylesheet follows the system, but other scripts can need
// the event.
window.matchMedia(DARK_SCHEME_QUERY).addEventListener("change", () => {
if (currentTheme() === "auto") applyTheme("auto");
});
}

function initializeGlobalBehavior() {
if (!document.querySelector(".mobile-sidebar-toggle")) createMobileElements();
initMobileNavigation();
setupGlobalShortcuts();
setupNavbarKeyboardNavigation();
setupOptionTocNavigation();
setupClientNavigation();
setupThemeToggle();
}

document.addEventListener("DOMContentLoaded", () => {
Expand Down
56 changes: 55 additions & 1 deletion crates/ndg-templates/templates/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function loadMain(requestAnimationFrame, browser = {}) {
"fetch",
"DOMParser",
"IntersectionObserver",
`${source}; return { loadClientPage, scrollToOption, transitionClientPage, setupOptionChunkLoading };`,
`${source}; return { loadClientPage, scrollToOption, transitionClientPage, setupOptionChunkLoading, syncThemedImages, nextTheme };`,
)(
window,
document,
Expand Down Expand Up @@ -247,3 +247,57 @@ Deno.test("option scrolling reveals contained cards before aligning", () => {
frames.shift()();
assert(!classes.has("options-revealed"), "containment must resume afterward");
});

function themeBrowser(dark) {
return {
window: {
requestIdleCallback() {},
cancelIdleCallback() {},
matchMedia: () => ({ matches: dark }),
},
document: { addEventListener() {} },
};
}

Deno.test("a selected theme selects the matching picture source", () => {
const { syncThemedImages } = loadMain(() => {}, themeBrowser(false));
const dark = { dataset: {}, media: "(prefers-color-scheme: dark)" };
const wide = {
dataset: {},
media: "(min-width: 800px) and (prefers-color-scheme: light)",
};
const root = { querySelectorAll: () => [dark, wide] };

syncThemedImages("dark", root);
assert(dark.media === "(grid: 0)", "the dark source always matches");
assert(
wide.media === "(min-width: 800px) and (grid: 1)",
"the light source never matches, and keeps its other conditions",
);

syncThemedImages("light", root);
assert(dark.media === "(grid: 1)", "the dark source never matches");
assert(
wide.media === "(min-width: 800px) and (grid: 0)",
"the light source matches on a wide screen",
);

syncThemedImages("auto", root);
assert(
dark.media === "(prefers-color-scheme: dark)",
"the original query is restored",
);
assert(
wide.media === "(min-width: 800px) and (prefers-color-scheme: light)",
"the original query is restored",
);
});

Deno.test("the theme toggle goes back to the system theme", () => {
const { nextTheme } = loadMain(() => {}, themeBrowser(false));
assert(nextTheme("auto", "light") === "dark", "auto in light selects dark");
assert(nextTheme("dark", "light") === "auto", "dark goes back to auto");
assert(nextTheme("auto", "dark") === "light", "auto in dark selects light");
assert(nextTheme("light", "light") === "dark", "a pinned light selects dark");
assert(nextTheme("light", "dark") === "auto", "light goes back to auto");
});
39 changes: 38 additions & 1 deletion crates/ndg-templates/templates/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,18 @@
<script>
document.documentElement.classList.remove("no-js");
document.documentElement.classList.add("js");
// Apply sidebar state immediately to prevent flash
// Apply sidebar state and theme immediately to prevent flash
(function () {
try {
if (localStorage.getItem("sidebar-collapsed") === "true") {
document.documentElement.classList.add("sidebar-collapsed");
}
// Apply a theme that the reader selected. Without one, the theme
// follows the color scheme of the system.
var theme = localStorage.getItem("theme");
if (theme === "light" || theme === "dark") {
document.documentElement.dataset.theme = theme;
}
} catch (e) {
// localStorage unavailable
}
Expand Down Expand Up @@ -45,6 +51,37 @@ <h1 class="site-title">
<div id="search-results" class="search-results"></div>
</div>
{% endif %}
<button
type="button"
class="theme-toggle js-only"
data-theme-toggle
aria-label="Switch the theme"
title="Switch the theme"
>
<svg
class="theme-icon-moon"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
>
<path d="M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z"></path>
</svg>
<svg
class="theme-icon-sun"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
>
<circle cx="12" cy="12" r="4"></circle>
<path
d="M12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9l2.1 2.1M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1"
></path>
</svg>
</button>
</header>

<div class="layout">
Expand Down
Loading
Loading