Add dark mode support with persisted OS-aware theme selection
This commit is contained in:
@@ -433,7 +433,9 @@ const source = timeRange === "24h" ? "fail2ban" : "archive";
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### TASK-022 — Add dark mode support and OS preference detection
|
### TASK-022 — Add dark mode support and OS preference detection (done)
|
||||||
|
|
||||||
|
**Where fixed:** `frontend/src/App.tsx`, `frontend/src/providers/ThemeProvider.tsx`, `frontend/src/layouts/MainLayout.tsx`, `frontend/src/theme/customTheme.ts`, `frontend/src/components/BanTrendChart.tsx`, `frontend/src/components/TopCountriesPieChart.tsx`, `frontend/src/components/TopCountriesBarChart.tsx`, `frontend/src/components/JailDistributionChart.tsx`, `Docs/Web-Design.md`
|
||||||
|
|
||||||
**Where found:** `frontend/src/App.tsx` line 25 — `<FluentProvider theme={lightTheme}>` is hardcoded. `frontend/src/theme/customTheme.ts` already exports `darkTheme` but it is never used.
|
**Where found:** `frontend/src/App.tsx` line 25 — `<FluentProvider theme={lightTheme}>` is hardcoded. `frontend/src/theme/customTheme.ts` already exports `darkTheme` but it is never used.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ BanGUI uses a **single custom theme** generated with the [Fluent UI Theme Design
|
|||||||
|
|
||||||
- The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements.
|
- The primary colour must have a **contrast ratio of at least 4.5 : 1** against `white` for text and **3 : 1** for large text and UI elements.
|
||||||
- Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ.
|
- Provide a **dark theme variant** alongside the default light theme. Both must share the same semantic slot names — only the palette values differ.
|
||||||
|
- Persist the user's explicit theme choice in `localStorage` and otherwise follow the operating system's `prefers-color-scheme` setting.
|
||||||
- Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly.
|
- Never reference Fluent UI palette slots (`themeDarker`, `neutralLight`, etc.) directly in components. Always go through semantic slots so theme switching works seamlessly.
|
||||||
|
|
||||||
### Colour Rules
|
### Colour Rules
|
||||||
|
|||||||
@@ -22,8 +22,9 @@
|
|||||||
import { lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { FluentProvider, Spinner } from "@fluentui/react-components";
|
import { FluentProvider, Spinner } from "@fluentui/react-components";
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { lightTheme } from "./theme/customTheme";
|
import { darkTheme, lightTheme } from "./theme/customTheme";
|
||||||
import { AuthProvider } from "./providers/AuthProvider";
|
import { AuthProvider } from "./providers/AuthProvider";
|
||||||
|
import { ThemeProvider, useThemeMode } from "./providers/ThemeProvider";
|
||||||
import { TimezoneProvider } from "./providers/TimezoneProvider";
|
import { TimezoneProvider } from "./providers/TimezoneProvider";
|
||||||
import { RequireAuth } from "./components/RequireAuth";
|
import { RequireAuth } from "./components/RequireAuth";
|
||||||
import { SetupGuard } from "./components/SetupGuard";
|
import { SetupGuard } from "./components/SetupGuard";
|
||||||
@@ -43,9 +44,12 @@ const BlocklistsPage = lazy(() => import("./pages/BlocklistsPage").then((m) => (
|
|||||||
/**
|
/**
|
||||||
* Root application component — mounts providers and top-level routes.
|
* Root application component — mounts providers and top-level routes.
|
||||||
*/
|
*/
|
||||||
function App(): React.JSX.Element {
|
function AppContents(): React.JSX.Element {
|
||||||
|
const { colorMode } = useThemeMode();
|
||||||
|
const theme = colorMode === "dark" ? darkTheme : lightTheme;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FluentProvider theme={lightTheme}>
|
<FluentProvider theme={theme}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<Suspense fallback={<Spinner size="large" label="Loading…" />}>
|
<Suspense fallback={<Spinner size="large" label="Loading…" />}>
|
||||||
@@ -96,4 +100,12 @@ function App(): React.JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function App(): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<ThemeProvider>
|
||||||
|
<AppContents />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
CHART_PALETTE,
|
CHART_PALETTE,
|
||||||
resolveFluentToken,
|
resolveFluentToken,
|
||||||
} from "../utils/chartTheme";
|
} from "../utils/chartTheme";
|
||||||
|
import { useThemeMode } from "../providers/ThemeProvider";
|
||||||
import { ChartStateWrapper } from "./ChartStateWrapper";
|
import { ChartStateWrapper } from "./ChartStateWrapper";
|
||||||
import { useBanTrend } from "../hooks/useBanTrend";
|
import { useBanTrend } from "../hooks/useBanTrend";
|
||||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||||
@@ -200,29 +201,36 @@ export const BanTrendChart = memo(function BanTrendChart({
|
|||||||
source = "fail2ban",
|
source = "fail2ban",
|
||||||
}: BanTrendChartProps): React.JSX.Element {
|
}: BanTrendChartProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const { colorMode } = useThemeMode();
|
||||||
const { buckets, loading, error, reload } = useBanTrend(timeRange, origin, source);
|
const { buckets, loading, error, reload } = useBanTrend(timeRange, origin, source);
|
||||||
|
|
||||||
const isEmpty = buckets.every((b) => b.count === 0);
|
const isEmpty = buckets.every((b) => b.count === 0);
|
||||||
const entries = buildEntries(buckets, timeRange);
|
const entries = buildEntries(buckets, timeRange);
|
||||||
const { primaryColour, axisColour, gridColour } = useMemo(
|
const { primaryColour, axisColour, gridColour } = useMemo(
|
||||||
() => ({
|
() => {
|
||||||
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
|
void colorMode;
|
||||||
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
|
return {
|
||||||
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
|
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
|
||||||
}),
|
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
|
||||||
[],
|
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[colorMode],
|
||||||
);
|
);
|
||||||
const tickInterval = TICK_INTERVAL[timeRange];
|
const tickInterval = TICK_INTERVAL[timeRange];
|
||||||
|
|
||||||
const tooltipContent = useMemo(
|
const tooltipContent = useMemo(
|
||||||
() => (
|
() => {
|
||||||
<TrendTooltip
|
void colorMode;
|
||||||
backgroundColor={resolveFluentToken(tokens.colorNeutralBackground1)}
|
return (
|
||||||
borderColor={resolveFluentToken(tokens.colorNeutralStroke2)}
|
<TrendTooltip
|
||||||
textColor={resolveFluentToken(tokens.colorNeutralForeground1)}
|
backgroundColor={resolveFluentToken(tokens.colorNeutralBackground1)}
|
||||||
/>
|
borderColor={resolveFluentToken(tokens.colorNeutralStroke2)}
|
||||||
),
|
textColor={resolveFluentToken(tokens.colorNeutralForeground1)}
|
||||||
[],
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[colorMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
CHART_PALETTE,
|
CHART_PALETTE,
|
||||||
resolveFluentToken,
|
resolveFluentToken,
|
||||||
} from "../utils/chartTheme";
|
} from "../utils/chartTheme";
|
||||||
|
import { useThemeMode } from "../providers/ThemeProvider";
|
||||||
import { ChartStateWrapper } from "./ChartStateWrapper";
|
import { ChartStateWrapper } from "./ChartStateWrapper";
|
||||||
import { ChartTooltip } from "./ChartTooltip";
|
import { ChartTooltip } from "./ChartTooltip";
|
||||||
import { useJailDistribution } from "../hooks/useJailDistribution";
|
import { useJailDistribution } from "../hooks/useJailDistribution";
|
||||||
@@ -134,17 +135,21 @@ export const JailDistributionChart = memo(function JailDistributionChart({
|
|||||||
origin,
|
origin,
|
||||||
}: JailDistributionChartProps): React.JSX.Element {
|
}: JailDistributionChartProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const { colorMode } = useThemeMode();
|
||||||
const { jails, loading, error, reload } = useJailDistribution(timeRange, origin);
|
const { jails, loading, error, reload } = useJailDistribution(timeRange, origin);
|
||||||
|
|
||||||
const entries = buildEntries(jails);
|
const entries = buildEntries(jails);
|
||||||
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
|
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
|
||||||
const { primaryColour, axisColour, gridColour } = useMemo(
|
const { primaryColour, axisColour, gridColour } = useMemo(
|
||||||
() => ({
|
() => {
|
||||||
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
|
void colorMode;
|
||||||
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
|
return {
|
||||||
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
|
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
|
||||||
}),
|
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
|
||||||
[],
|
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[colorMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
CHART_GRID_LINE_TOKEN,
|
CHART_GRID_LINE_TOKEN,
|
||||||
resolveFluentToken,
|
resolveFluentToken,
|
||||||
} from "../utils/chartTheme";
|
} from "../utils/chartTheme";
|
||||||
|
import { useThemeMode } from "../providers/ThemeProvider";
|
||||||
import { ChartTooltip } from "./ChartTooltip";
|
import { ChartTooltip } from "./ChartTooltip";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -142,16 +143,20 @@ export const TopCountriesBarChart = memo(function TopCountriesBarChart({
|
|||||||
countryNames,
|
countryNames,
|
||||||
}: TopCountriesBarChartProps): React.JSX.Element {
|
}: TopCountriesBarChartProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const { colorMode } = useThemeMode();
|
||||||
|
|
||||||
const entries = buildEntries(countries, countryNames);
|
const entries = buildEntries(countries, countryNames);
|
||||||
|
|
||||||
const { primaryColour, axisColour, gridColour } = useMemo(
|
const { primaryColour, axisColour, gridColour } = useMemo(
|
||||||
() => ({
|
() => {
|
||||||
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
|
void colorMode;
|
||||||
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
|
return {
|
||||||
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
|
primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""),
|
||||||
}),
|
axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN),
|
||||||
[],
|
gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[colorMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { LegendPayload } from "recharts/types/component/DefaultLegendConten
|
|||||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||||
import { tokens, makeStyles, Text } from "@fluentui/react-components";
|
import { tokens, makeStyles, Text } from "@fluentui/react-components";
|
||||||
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
|
||||||
|
import { useThemeMode } from "../providers/ThemeProvider";
|
||||||
import { ChartTooltip } from "./ChartTooltip";
|
import { ChartTooltip } from "./ChartTooltip";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -134,10 +135,14 @@ export const TopCountriesPieChart = memo(function TopCountriesPieChart({
|
|||||||
countryNames,
|
countryNames,
|
||||||
}: TopCountriesPieChartProps): React.JSX.Element {
|
}: TopCountriesPieChartProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
|
const { colorMode } = useThemeMode();
|
||||||
|
|
||||||
const resolvedPalette = useMemo(
|
const resolvedPalette = useMemo(
|
||||||
() => CHART_PALETTE.map(resolveFluentToken),
|
() => {
|
||||||
[],
|
void colorMode;
|
||||||
|
return CHART_PALETTE.map(resolveFluentToken);
|
||||||
|
},
|
||||||
|
[colorMode],
|
||||||
);
|
);
|
||||||
const slices = buildSlices(countries, countryNames, resolvedPalette);
|
const slices = buildSlices(countries, countryNames, resolvedPalette);
|
||||||
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
||||||
|
|||||||
@@ -28,11 +28,14 @@ import {
|
|||||||
ListRegular,
|
ListRegular,
|
||||||
SignOutRegular,
|
SignOutRegular,
|
||||||
NavigationRegular,
|
NavigationRegular,
|
||||||
|
WeatherMoonRegular,
|
||||||
|
WeatherSunnyRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useServerStatus } from "../hooks/useServerStatus";
|
import { useServerStatus } from "../hooks/useServerStatus";
|
||||||
import { useBlocklistStatus } from "../hooks/useBlocklist";
|
import { useBlocklistStatus } from "../hooks/useBlocklist";
|
||||||
|
import { useThemeMode } from "../providers/ThemeProvider";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Styles
|
// Styles
|
||||||
@@ -146,11 +149,20 @@ const useStyles = makeStyles({
|
|||||||
padding: tokens.spacingVerticalS,
|
padding: tokens.spacingVerticalS,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
footerActions: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: tokens.spacingVerticalXS,
|
||||||
|
},
|
||||||
|
themeButton: {
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
},
|
||||||
logoutButton: {
|
logoutButton: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
},
|
},
|
||||||
logoutButtonCollapsed: {
|
sidebarButtonCollapsed: {
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
versionText: {
|
versionText: {
|
||||||
@@ -220,6 +232,7 @@ const NAV_ITEMS: NavItem[] = [
|
|||||||
export function MainLayout(): React.JSX.Element {
|
export function MainLayout(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { logout } = useAuth();
|
const { logout } = useAuth();
|
||||||
|
const { colorMode, toggleColorMode } = useThemeMode();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const readSavedCollapsed = (): boolean => {
|
const readSavedCollapsed = (): boolean => {
|
||||||
@@ -346,31 +359,51 @@ export function MainLayout(): React.JSX.Element {
|
|||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Footer — Logout */}
|
{/* Footer — Theme toggle and logout */}
|
||||||
<div className={styles.sidebarFooter}>
|
<div className={styles.sidebarFooter}>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<Text className={styles.versionText}>
|
<Text className={styles.versionText}>
|
||||||
BanGUI
|
BanGUI
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Tooltip
|
<div className={styles.footerActions}>
|
||||||
content={collapsed ? "Sign out" : ""}
|
<Tooltip
|
||||||
relationship="label"
|
content={collapsed ? (colorMode === "dark" ? "Switch to light mode" : "Switch to dark mode") : ""}
|
||||||
positioning="after"
|
relationship="label"
|
||||||
>
|
positioning="after"
|
||||||
<Button
|
|
||||||
appearance="subtle"
|
|
||||||
icon={<SignOutRegular />}
|
|
||||||
onClick={() => void handleLogout()}
|
|
||||||
aria-label="Sign out"
|
|
||||||
className={mergeClasses(
|
|
||||||
styles.logoutButton,
|
|
||||||
collapsed && styles.logoutButtonCollapsed,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{!collapsed && "Sign out"}
|
<Button
|
||||||
</Button>
|
appearance="subtle"
|
||||||
</Tooltip>
|
icon={colorMode === "dark" ? <WeatherSunnyRegular /> : <WeatherMoonRegular />}
|
||||||
|
onClick={toggleColorMode}
|
||||||
|
aria-label={colorMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||||
|
className={mergeClasses(
|
||||||
|
styles.themeButton,
|
||||||
|
collapsed && styles.sidebarButtonCollapsed,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!collapsed && (colorMode === "dark" ? "Light mode" : "Dark mode")}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content={collapsed ? "Sign out" : ""}
|
||||||
|
relationship="label"
|
||||||
|
positioning="after"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
appearance="subtle"
|
||||||
|
icon={<SignOutRegular />}
|
||||||
|
onClick={() => void handleLogout()}
|
||||||
|
aria-label="Sign out"
|
||||||
|
className={mergeClasses(
|
||||||
|
styles.logoutButton,
|
||||||
|
collapsed && styles.sidebarButtonCollapsed,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!collapsed && "Sign out"}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -89,4 +89,9 @@ describe("MainLayout", () => {
|
|||||||
await userEvent.click(toggleButton);
|
await userEvent.click(toggleButton);
|
||||||
expect(localStorage.getItem("bangui_sidebar_collapsed")).toBe("true");
|
expect(localStorage.getItem("bangui_sidebar_collapsed")).toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a theme toggle button in the sidebar footer", () => {
|
||||||
|
renderLayout();
|
||||||
|
expect(screen.getByRole("button", { name: /switch to dark mode/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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