Ben Senescu 3081bc624a feat: add theme switcher with system/light/dark segmented control (#75)
* feat: add theme switcher with system/light/dark segmented control

Add a three-way theme toggle (system, light, dark) as a segmented
icon control in the account dropdown menu. Rename placeholder daisyUI
theme names from "todo" to "openseo".

* refactor: always resolve data-theme explicitly, remove duplicated dark CSS

Instead of removing data-theme in "system" mode and relying on
@media (prefers-color-scheme: dark) CSS, always resolve to an
explicit theme name via matchMedia. This eliminates the duplicated
dark-mode CSS overrides.
2026-04-08 14:09:01 -04:00

134 lines
3.7 KiB
TypeScript

import * as React from "react";
export type ThemePreference = "system" | "light" | "dark";
const LIGHT_THEME_NAME = "openseo";
const DARK_THEME_NAME = "openseo-dark";
const THEME_STORAGE_KEY = "theme-preference";
const THEME_CHANGE_EVENT = "theme-preference-change";
function readThemePreference(): ThemePreference {
if (typeof window === "undefined") {
return "system";
}
try {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "light" || stored === "dark") {
return stored;
}
return "system";
} catch {
return "system";
}
}
function writeThemePreference(themePreference: ThemePreference) {
try {
if (themePreference === "system") {
window.localStorage.removeItem(THEME_STORAGE_KEY);
} else {
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}
} catch {
// localStorage can be unavailable in private browsing or strict browser modes.
}
}
function resolveThemeName(themePreference: ThemePreference): string {
if (themePreference === "light") return LIGHT_THEME_NAME;
if (themePreference === "dark") return DARK_THEME_NAME;
// "system" — resolve from OS preference
if (
typeof window !== "undefined" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
return DARK_THEME_NAME;
}
return LIGHT_THEME_NAME;
}
function applyThemePreference(themePreference: ThemePreference) {
if (typeof document === "undefined") {
return;
}
document.documentElement.setAttribute(
"data-theme",
resolveThemeName(themePreference),
);
}
function subscribeToThemePreference(onStoreChange: () => void) {
if (typeof window === "undefined") {
return () => {};
}
const handleThemeChange = () => {
onStoreChange();
};
const handleStorage = (event: StorageEvent) => {
if (event.key && event.key !== THEME_STORAGE_KEY) {
return;
}
onStoreChange();
};
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleMediaChange = () => {
// Re-apply when OS preference changes so "system" mode stays in sync
applyThemePreference(readThemePreference());
onStoreChange();
};
window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange);
window.addEventListener("storage", handleStorage);
mediaQuery.addEventListener("change", handleMediaChange);
return () => {
window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange);
window.removeEventListener("storage", handleStorage);
mediaQuery.removeEventListener("change", handleMediaChange);
};
}
export function useThemePreference() {
const themePreference = React.useSyncExternalStore<ThemePreference>(
subscribeToThemePreference,
readThemePreference,
() => "system",
);
React.useEffect(() => {
applyThemePreference(themePreference);
}, [themePreference]);
const setThemePreference = React.useCallback(
(nextThemePreference: ThemePreference) => {
writeThemePreference(nextThemePreference);
applyThemePreference(nextThemePreference);
window.dispatchEvent(new Event(THEME_CHANGE_EVENT));
},
[],
);
return { themePreference, setThemePreference };
}
export const themePreferenceInitScript = `(() => {
try {
var p = window.localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
var t;
if (p === "light") t = ${JSON.stringify(LIGHT_THEME_NAME)};
else if (p === "dark") t = ${JSON.stringify(DARK_THEME_NAME)};
else t = window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(DARK_THEME_NAME)} : ${JSON.stringify(LIGHT_THEME_NAME)};
document.documentElement.setAttribute("data-theme", t);
} catch {
document.documentElement.setAttribute("data-theme", ${JSON.stringify(LIGHT_THEME_NAME)});
}
})();`;