#182: top-bar theme toggle (cycle light/dark/system) #183

Merged
stwaidele merged 1 commits from feat/182-theme-toggle into main 2026-07-29 10:11:19 +02:00
7 changed files with 153 additions and 11 deletions

View File

@ -64,3 +64,53 @@ test('theme choice applies instantly, persists, and system mode follows the OS',
await context.close();
});
test('the top-bar toggle cycles the mode and stays in sync with the radios', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE, 'fixture-user');
const page = await context.newPage();
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/settings');
await page.locator('.settings-fieldset').waitFor();
const toggle = page.locator('.topbar__theme');
// Default System → ein Klick zykelt in Radio-Reihenfolge weiter zu Hell,
// dann Dunkel, dann zurück zu System; die Radios folgen (issue #182).
await toggle.click();
await expect(radio(page, 'light')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light');
await toggle.click();
await expect(radio(page, 'dark')).toBeChecked();
expect(await effectiveTheme(page)).toBe('dark');
await toggle.click();
await expect(radio(page, 'system')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light'); // helles OS
// Auch andersherum: eine Radio-Wahl versetzt den Zyklus des Toggles.
await radio(page, 'dark').check();
await toggle.click();
await expect(radio(page, 'system')).toBeChecked();
// Persistenz wie bei den Radios (gleicher localStorage-Key).
await toggle.click(); // → light
await page.reload();
await page.locator('.settings-fieldset').waitFor();
await expect(radio(page, 'light')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light');
await context.close();
});
test('the theme toggle works for signed-out visitors', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto(`${BASE}/login`);
const toggle = page.locator('.topbar__theme');
await toggle.waitFor();
await toggle.click(); // System → Hell
await toggle.click(); // Hell → Dunkel
expect(await effectiveTheme(page)).toBe('dark');
});

View File

@ -1,21 +1,49 @@
import type { PondView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { Menu, Search, Settings } from 'lucide-react';
import { Menu, Monitor, Moon, Search, Settings, Sun } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton';
import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard';
import { useDismissable } from '../lib/use-dismissable';
import { SearchPalette } from '../search/SearchPalette';
import { NotificationsBell } from '../notifications/NotificationsBell';
import { nextThemeMode, useThemeMode, type ThemeMode } from '../theme/theme';
import { usePageActionsSlot } from './page-actions';
import { PondSwitcher } from './PondSwitcher';
import { useCurrentPondRoute } from './use-pond-route';
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
/** Icon per CHOSEN mode (not per effective theme): the monitor tells the
* user "following the OS" apart from an explicit light/dark pick. */
const THEME_MODE_ICONS: Record<ThemeMode, typeof Sun> = {
light: Sun,
dark: Moon,
system: Monitor,
};
/** Three-way theme cycle (issue #182) sits between the bell and the user
* menu; also rendered for signed-out visitors (device-local preference). */
function ThemeToggle(): React.JSX.Element {
const { t } = useTranslation();
const [mode, setMode] = useThemeMode();
const Icon = THEME_MODE_ICONS[mode];
return (
<IconButton
className="topbar__theme"
label={t('settings:appearance.cycle', { mode: t(`settings:appearance.${mode}`) })}
onClick={() => setMode(nextThemeMode(mode))}
>
<Icon aria-hidden />
</IconButton>
);
}
interface TopBarProps {
sidebarCollapsed: boolean;
onToggleSidebar: () => void;
@ -108,6 +136,7 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
)}
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
{user && <NotificationsBell />}
<ThemeToggle />
{user ? (
<div className="user-menu" ref={userMenuRef}>
<button

View File

@ -17,7 +17,7 @@ import { WatchesSection } from '../watches/WatchesSection';
import { useDocumentTitle } from '../lib/use-document-title';
import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts';
import { usePersistentState } from '../lib/use-persistent-state';
import { applyTheme, THEME_MODE_KEY, type ThemeMode } from '../theme/theme';
import { useThemeMode, type ThemeMode } from '../theme/theme';
interface SessionView {
id: string;
createdAt: string;
@ -51,11 +51,9 @@ export function SettingsPage(): React.JSX.Element {
* InteractionSection eine lokale Geräte-Einstellung, kein Server-Zustand. */
function AppearanceSection(): React.JSX.Element {
const { t } = useTranslation();
const [mode, setMode] = usePersistentState<ThemeMode>(THEME_MODE_KEY, 'system');
const choose = (value: ThemeMode): void => {
setMode(value);
applyTheme(value);
};
// Shared hook instead of usePersistentState: keeps the radios and the
// top-bar theme toggle (issue #182) in sync within the same document.
const [mode, setMode] = useThemeMode();
const options: { value: ThemeMode; label: string }[] = [
{ value: 'light', label: t('settings:appearance.light') },
{ value: 'dark', label: t('settings:appearance.dark') },
@ -73,7 +71,7 @@ function AppearanceSection(): React.JSX.Element {
name="theme-mode"
value={option.value}
checked={mode === option.value}
onChange={() => choose(option.value)}
onChange={() => setMode(option.value)}
/>
{option.label}
</label>

View File

@ -1,7 +1,14 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { applyTheme, readStoredThemeMode, resolveTheme, THEME_MODE_KEY } from './theme';
import {
applyTheme,
nextThemeMode,
readStoredThemeMode,
resolveTheme,
setThemeMode,
THEME_MODE_KEY,
} from './theme';
/** Node 22 ships its own (unconfigured, undefined) localStorage global
* that shadows jsdom's give the tests a real in-memory one. */
@ -92,3 +99,25 @@ describe('applyTheme', () => {
for (const meta of metas) expect(meta.getAttribute('content')).toBe('#2f6f4f');
});
});
describe('nextThemeMode', () => {
it('cycles light -> dark -> system -> light (radio order)', () => {
expect(nextThemeMode('light')).toBe('dark');
expect(nextThemeMode('dark')).toBe('system');
expect(nextThemeMode('system')).toBe('light');
});
});
describe('setThemeMode', () => {
it('persists (JSON-encoded), applies, and notifies listeners', () => {
const notified = vi.fn();
window.addEventListener('dorfteich:theme-mode', notified);
setThemeMode('dark');
window.removeEventListener('dorfteich:theme-mode', notified);
expect(window.localStorage.getItem(THEME_MODE_KEY)).toBe('"dark"');
expect(readStoredThemeMode()).toBe('dark');
expect(document.documentElement.dataset.theme).toBe('dark');
expect(notified).toHaveBeenCalledTimes(1);
});
});

View File

@ -52,6 +52,40 @@ export function applyTheme(mode: ThemeMode): void {
.forEach((meta) => meta.setAttribute('content', THEME_COLOR[theme]));
}
/** Same-document change signal for useThemeMode(): usePersistentState keeps
* its state per hook instance, so the top-bar toggle and the settings radios
* would drift without a shared event (issue #182). */
const THEME_MODE_EVENT = 'dorfteich:theme-mode';
/** Cycle order of the top-bar toggle — mirrors the settings radio order. */
export function nextThemeMode(mode: ThemeMode): ThemeMode {
return mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light';
}
/** The single write path for the mode: persist (JSON, same encoding as
* usePersistentState), apply, and notify every useThemeMode() instance. */
export function setThemeMode(mode: ThemeMode): void {
try {
window.localStorage.setItem(THEME_MODE_KEY, JSON.stringify(mode));
} catch {
// Storage may be unavailable (private mode); still apply for this page.
}
applyTheme(mode);
window.dispatchEvent(new Event(THEME_MODE_EVENT));
}
/** Reactive view of the stored mode, shared across components: reads once,
* then follows THEME_MODE_EVENT from any setter instance. */
export function useThemeMode(): [ThemeMode, (mode: ThemeMode) => void] {
const [mode, setMode] = useState<ThemeMode>(readStoredThemeMode);
useEffect(() => {
const onChange = (): void => setMode(readStoredThemeMode());
window.addEventListener(THEME_MODE_EVENT, onChange);
return () => window.removeEventListener(THEME_MODE_EVENT, onChange);
}, []);
return [mode, setThemeMode];
}
/** Follow OS scheme changes live while the stored mode is 'system'.
* Called once at startup (main.tsx). */
export function initSystemThemeListener(): void {

View File

@ -63,7 +63,8 @@
"light": "Hell",
"dark": "Dunkel",
"system": "Systemeinstellung",
"hint": "„Systemeinstellung“ folgt dem Hell-/Dunkel-Modus des Geräts. Gilt für dieses Gerät."
"hint": "„Systemeinstellung“ folgt dem Hell-/Dunkel-Modus des Geräts. Gilt für dieses Gerät.",
"cycle": "Farbschema wechseln (aktuell: {{mode}})"
},
"interaction": {
"title": "Bedienung",

View File

@ -63,7 +63,8 @@
"light": "Light",
"dark": "Dark",
"system": "System setting",
"hint": "“System setting” follows the devices light/dark mode. Applies to this device."
"hint": "“System setting” follows the devices light/dark mode. Applies to this device.",
"cycle": "Switch color scheme (current: {{mode}})"
},
"interaction": {
"title": "Interaction",