Add dark mode support with persisted OS-aware theme selection

This commit is contained in:
2026-04-21 19:30:29 +02:00
parent 4f91e8fdd3
commit fef8f60ee2
11 changed files with 293 additions and 51 deletions

View File

@@ -0,0 +1,98 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
type ThemeMode = "light" | "dark";
interface ThemeProviderContext {
colorMode: ThemeMode;
toggleColorMode: () => void;
setColorMode: (mode: ThemeMode) => void;
hasExplicitPreference: boolean;
}
const THEME_STORAGE_KEY = "bangui_theme";
const ThemeModeContext = createContext<ThemeProviderContext | null>(null);
function readSavedTheme(): ThemeMode | null {
try {
const value = localStorage.getItem(THEME_STORAGE_KEY);
if (value === "dark" || value === "light") {
return value;
}
} catch {
// Ignore localStorage failures.
}
return null;
}
function getSystemTheme(): ThemeMode {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
interface ThemeProviderProps {
children: ReactNode;
}
export function ThemeProvider({ children }: ThemeProviderProps): React.JSX.Element {
const savedTheme = readSavedTheme();
const [colorMode, setColorModeState] = useState<ThemeMode>(() => savedTheme ?? getSystemTheme());
const [hasExplicitPreference, setHasExplicitPreference] = useState<boolean>(() => savedTheme !== null);
const setColorMode = useCallback((mode: ThemeMode): void => {
try {
localStorage.setItem(THEME_STORAGE_KEY, mode);
} catch {
// Ignore storage failures.
}
setHasExplicitPreference(true);
setColorModeState(mode);
}, []);
const toggleColorMode = useCallback((): void => {
setColorMode(colorMode === "dark" ? "light" : "dark");
}, [colorMode, setColorMode]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (event: MediaQueryListEvent): void => {
if (!hasExplicitPreference) {
setColorModeState(event.matches ? "dark" : "light");
}
};
if (typeof mediaQuery.addEventListener === "function") {
mediaQuery.addEventListener("change", handleChange);
function cleanup(): void {
mediaQuery.removeEventListener("change", handleChange);
}
return cleanup;
}
return undefined;
}, [hasExplicitPreference]);
const value = useMemo(
() => ({ colorMode, toggleColorMode, setColorMode, hasExplicitPreference }),
[colorMode, toggleColorMode, setColorMode, hasExplicitPreference],
);
return <ThemeModeContext.Provider value={value}>{children}</ThemeModeContext.Provider>;
}
export function useThemeMode(): ThemeProviderContext {
const context = useContext(ThemeModeContext);
if (context != null) {
return context;
}
return {
colorMode: "light",
toggleColorMode: (): void => {
/* no-op when provider is missing */
},
setColorMode: (): void => {
/* no-op when provider is missing */
},
hasExplicitPreference: false,
};
}

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ThemeProvider, useThemeMode } from "../ThemeProvider";
function ThemeConsumer(): React.JSX.Element {
const { colorMode, toggleColorMode } = useThemeMode();
return (
<div>
<span data-testid="color-mode">{colorMode}</span>
<button type="button" onClick={toggleColorMode}>
Toggle
</button>
</div>
);
}
function renderWithProvider(): void {
render(
<ThemeProvider>
<ThemeConsumer />
</ThemeProvider>,
);
}
describe("ThemeProvider", () => {
beforeEach(() => {
vi.restoreAllMocks();
localStorage.clear();
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
});
it("uses the saved theme from localStorage when present", () => {
localStorage.setItem("bangui_theme", "dark");
renderWithProvider();
expect(screen.getByTestId("color-mode")).toHaveTextContent("dark");
});
it("falls back to the OS preference when no explicit theme is saved", () => {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
renderWithProvider();
expect(screen.getByTestId("color-mode")).toHaveTextContent("dark");
});
it("persists the user selection to localStorage when toggled", async () => {
renderWithProvider();
expect(screen.getByTestId("color-mode")).toHaveTextContent("light");
await screen.getByRole("button", { name: /toggle/i }).click();
expect(screen.getByTestId("color-mode")).toHaveTextContent("dark");
expect(localStorage.getItem("bangui_theme")).toBe("dark");
});
});