#182: top-bar theme toggle (cycle light/dark/system) #183
@ -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');
|
||||
});
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -63,7 +63,8 @@
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System setting",
|
||||
"hint": "“System setting” follows the device’s light/dark mode. Applies to this device."
|
||||
"hint": "“System setting” follows the device’s light/dark mode. Applies to this device.",
|
||||
"cycle": "Switch color scheme (current: {{mode}})"
|
||||
},
|
||||
"interaction": {
|
||||
"title": "Interaction",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user