Files
kjol/go/jsruntime/uikit/Theme.tsx

147 lines
5.7 KiB
TypeScript

// The theme controller.
//
// The kit is themed by TOKENS, not by a `dark:` variant on every component (see
// styles/theme.css). Components say bg-surface / text-ink / border-line and never
// name a colour; a `.dark` class on <html> re-points what those tokens mean. So all
// this module does is put a class on an element — the four hundred class strings in
// the kit are none the wiser.
//
// The storage key is deliberately the SAME one the Go/WASM kit uses
// (webui.ThemeBootScript, webui.themeStorageKey). Both layers of kjol-web are served
// from one origin, so they share a localStorage: choose dark in the /wasm section,
// walk over to /js, and it is still dark. Two front-ends, one preference.
import { createSignal, onCleanup } from "solid-js";
import { Icon } from "./Icons.tsx";
export type ThemeMode = "system" | "light" | "dark";
export const THEME_STORAGE_KEY = "kjol-theme";
const prefersDark = (): boolean => {
try {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
} catch {
return false; // SSR: no window. The boot script settles it in the browser.
}
};
const readMode = (): ThemeMode => {
try {
const m = localStorage.getItem(THEME_STORAGE_KEY);
if (m === "light" || m === "dark") return m;
} catch {
// Private mode, or SSR. "system" is the right answer in both.
}
return "system";
};
const [mode, setModeSignal] = createSignal<ThemeMode>(readMode());
// isDark is the RESOLVED answer — what "system" actually means right now — as opposed
// to `mode`, which is what the user asked for. The toggle needs the former; the
// three-way picker needs the latter. They are not the same question.
const [isDark, setIsDark] = createSignal(false);
const resolve = (m: ThemeMode): boolean => m === "dark" || (m === "system" && prefersDark());
function apply(m: ThemeMode) {
const dark = resolve(m);
setIsDark(dark);
try {
document.documentElement.classList.toggle("dark", dark);
} catch {
// SSR. Nothing to toggle; the boot script has already done it for real.
}
}
/** Set the theme and remember it. "system" forgets, rather than storing the word. */
export function setTheme(m: ThemeMode) {
setModeSignal(m);
try {
if (m === "system") localStorage.removeItem(THEME_STORAGE_KEY);
else localStorage.setItem(THEME_STORAGE_KEY, m);
} catch {
// Not fatal — the theme still applies for this page.
}
apply(m);
}
/** Flip between light and dark, resolving "system" to whatever it currently means. */
export function toggleTheme() {
setTheme(isDark() ? "light" : "dark");
}
// initTheme syncs this module's signals with the class the BOOT SCRIPT already put on
// <html>, and starts following the OS while the mode is "system".
//
// It does not cause the first paint — that already happened, correctly, before any of
// this code existed. Calling it late is therefore harmless; not calling it at all just
// means the toggle button starts out showing the wrong icon.
//
// Call it once, from a component (it registers a cleanup).
export function initTheme() {
apply(mode());
try {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
const onChange = () => {
// Only while the user has expressed no preference. Once they have picked a
// side, the OS changing its mind is not an instruction.
if (mode() === "system") apply("system");
};
mq.addEventListener("change", onChange);
onCleanup(() => mq.removeEventListener("change", onChange));
} catch {
// SSR, or a browser too old for matchMedia events. Neither is worth a crash.
}
}
export function useTheme() {
return { mode, isDark, setTheme, toggleTheme };
}
interface ThemeToggleProps {
small?: boolean;
class?: string;
}
export function ThemeToggle(props: ThemeToggleProps) {
const size = () => (props.small ? 14 : 16);
return (
<button
type="button"
onclick={toggleTheme}
title={isDark() ? "Switch to light" : "Switch to dark"}
aria-label={isDark() ? "Switch to light theme" : "Switch to dark theme"}
class={
"inline-flex cursor-pointer items-center justify-center rounded-default border border-line bg-surface text-ink-soft transition hover:bg-surface-raised hover:text-ink " +
(props.small ? "h-7 w-7 " : "h-9 w-9 ") +
(props.class || "")
}
>
{/* Show the destination, not the current state: a moon means "go dark". A
button that displays what you already have gives you nothing to press. */}
<Icon icon={isDark() ? "sun" : "moon"} size={size()} />
</button>
);
}
// THEME_BOOT_SCRIPT is the inline script an app puts in its <head>, BEFORE any
// stylesheet or markup.
//
// It exists to prevent the flash. The server cannot read localStorage, so it cannot
// know which theme to render; if the class were applied by this module after the
// bundle loads, every dark-mode user would be shown a white page for as long as the
// JavaScript takes to arrive, and then have it yanked out from under them. This runs
// first, synchronously, and so the first paint is already correct.
//
// It is byte-for-byte equivalent to the Go/WASM kit's webui.ThemeBootScript, and reads
// the same key. A server that already emits that one does not need this.
export const THEME_BOOT_SCRIPT = `<script>(function(){try{
var m = localStorage.getItem("kjol-theme");
var dark = m === "dark" || (!m && matchMedia("(prefers-color-scheme: dark)").matches);
if (dark) document.documentElement.classList.add("dark");
}catch(e){}})();</script>`;