Files
familienarchiv/frontend/src/lib/components/ThemeToggle.svelte
marcel e8d1835ae1
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / OCR Service Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
feat(nav): add tooltip and cursor:pointer to notification bell, fix ThemeToggle i18n (#344) (#351)
Closes #344

## What was implemented

### Commit 1 — `feat(nav): add cursor-pointer and tooltip to notification bell`
- Extracted `bellLabel` as `$derived` in `NotificationBell.svelte` — eliminates the duplicated inline ternary and keeps tooltip/label in sync reactively
- Added `title={bellLabel}` to the bell `<button>` — native tooltip mirrors `aria-label` in both zero and non-zero unread states
- Added `cursor-pointer` to the bell button's class list
- Added global `button { cursor: pointer; }` rule in `@layer base` of `layout.css` — prevents future regressions (global scope per Decision Queue)
- Added 3 component tests in `NotificationBell.svelte.spec.ts`: cursor-pointer class present, title equals aria-label when unread=0, title equals aria-label when unread=3

### Commit 2 — `fix(nav): replace hardcoded ThemeToggle title with Paraglide i18n keys`
- Added `theme_toggle_to_light` / `theme_toggle_to_dark` keys to `de/en/es` messages
- Extracted `themeLabel` as `$derived` in `ThemeToggle.svelte` and bound both `aria-label` and `title` to it
- Fixes the pre-existing hardcoded English strings (`'light mode'` / `'dark mode'`) per Decision Queue resolution

Touch target size was descoped per the Decision Queue.

## Decision Queue resolutions (from issue #344)
- **cursor-pointer scope**: global via `@layer base` 
- **ThemeToggle scope**: fixed in this issue 
- **Touch target**: descoped 

## Test results
All 5 `NotificationBell` tests pass.

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: http://heim-nas:3005/marcel/familienarchiv/pulls/351
2026-04-26 21:45:40 +02:00

75 lines
1.8 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
type Theme = 'light' | 'dark';
function systemPrefersDark(): boolean {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function resolveInitialTheme(): Theme {
const saved = localStorage.getItem('theme');
if (saved === 'light' || saved === 'dark') return saved;
return systemPrefersDark() ? 'dark' : 'light';
}
let theme = $state<Theme>('light');
onMount(() => {
theme = resolveInitialTheme();
});
const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('theme', theme);
document.documentElement.setAttribute('data-theme', theme);
}
</script>
<button
type="button"
onclick={toggle}
aria-label={themeLabel}
title={themeLabel}
class="rounded p-1.5 text-white/65 transition-colors hover:bg-white/10 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
{#if theme === 'dark'}
<!-- Sun icon — click to go light -->
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path
stroke-linecap="round"
d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
/>
</svg>
{:else}
<!-- Moon icon — click to go dark -->
<svg
class="h-5 w-5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"
/>
</svg>
{/if}
</button>