Add dark mode support with persisted OS-aware theme selection
This commit is contained in:
98
frontend/src/providers/ThemeProvider.tsx
Normal file
98
frontend/src/providers/ThemeProvider.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
68
frontend/src/providers/__tests__/ThemeProvider.test.tsx
Normal file
68
frontend/src/providers/__tests__/ThemeProvider.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user