refactor(shared): re-skin ThemeToggle onto SegmentedControl (§6 tokens)

Adds theme_segment_light/dark/label i18n keys (de/en/es).

Refs #857
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-16 19:02:44 +02:00
parent a3343f898f
commit 94b8117c17
5 changed files with 71 additions and 60 deletions

View File

@@ -32,6 +32,9 @@
"layout_menu_close": "Menü schließen",
"theme_toggle_to_light": "Zu hellem Design wechseln",
"theme_toggle_to_dark": "Zu dunklem Design wechseln",
"theme_toggle_label": "Farbschema",
"theme_segment_light": "Hell",
"theme_segment_dark": "Dunkel",
"btn_save": "Speichern",
"btn_cancel": "Abbrechen",
"btn_confirm": "Bestätigen",

View File

@@ -32,6 +32,9 @@
"layout_menu_close": "Close menu",
"theme_toggle_to_light": "Switch to light mode",
"theme_toggle_to_dark": "Switch to dark mode",
"theme_toggle_label": "Color scheme",
"theme_segment_light": "Light",
"theme_segment_dark": "Dark",
"btn_save": "Save",
"btn_cancel": "Cancel",
"btn_confirm": "Confirm",

View File

@@ -32,6 +32,9 @@
"layout_menu_close": "Cerrar menú",
"theme_toggle_to_light": "Cambiar a modo claro",
"theme_toggle_to_dark": "Cambiar a modo oscuro",
"theme_toggle_label": "Esquema de color",
"theme_segment_light": "Claro",
"theme_segment_dark": "Oscuro",
"btn_save": "Guardar",
"btn_cancel": "Cancelar",
"btn_confirm": "Confirmar",

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import SegmentedControl from './SegmentedControl.svelte';
type Theme = 'light' | 'dark';
@@ -24,51 +25,31 @@ const themeLabel = $derived(
theme === 'dark' ? m.theme_toggle_to_light() : m.theme_toggle_to_dark()
);
function toggle() {
theme = theme === 'dark' ? 'light' : 'dark';
const themeOptions = $derived([
{ value: 'light', label: m.theme_segment_light() },
{ value: 'dark', label: m.theme_segment_dark() }
]);
function handleChange(next: string) {
if (next !== 'light' && next !== 'dark') return;
theme = next as Theme;
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>
<!--
ThemeToggle — binary light/dark segmented control.
File kept at $lib/shared/primitives/ThemeToggle.svelte (required by #862).
Boot FOUC prevention: a tiny inline script in <head> reads localStorage['theme']
and sets data-theme before paint — that script is unchanged by this refactor.
The aria-label on the group communicates the toggle purpose to screen readers.
-->
<div aria-label={themeLabel} title={themeLabel}>
<SegmentedControl
options={themeOptions}
value={theme}
onChange={handleChange}
label={m.theme_toggle_label()}
/>
</div>

View File

@@ -8,38 +8,59 @@ afterEach(() => {
localStorage.removeItem('theme');
});
describe('ThemeToggle — label derivation (light mode)', () => {
describe('ThemeToggle — renders segments (light mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('aria-label invites switching to dark mode when theme is light', async () => {
it('renders a radiogroup with Hell and Dunkel segments', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu dunklem Design wechseln');
expect(document.querySelector('[role="radiogroup"]')).not.toBeNull();
await expect.element(page.getByRole('radio', { name: 'Hell' })).toBeVisible();
await expect.element(page.getByRole('radio', { name: 'Dunkel' })).toBeVisible();
});
it('title equals aria-label in light mode', async () => {
it('Hell segment is aria-checked="true" in light mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
await expect
.element(page.getByRole('radio', { name: 'Hell' }))
.toHaveAttribute('aria-checked', 'true');
await expect
.element(page.getByRole('radio', { name: 'Dunkel' }))
.toHaveAttribute('aria-checked', 'false');
});
});
describe('ThemeToggle — label derivation (dark mode)', () => {
describe('ThemeToggle — renders segments (dark mode)', () => {
beforeEach(() => {
localStorage.setItem('theme', 'dark');
});
it('aria-label invites switching to light mode when theme is dark', async () => {
it('Dunkel segment is aria-checked="true" in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('aria-label')).toBe('Zu hellem Design wechseln');
});
it('title equals aria-label in dark mode', async () => {
render(ThemeToggle);
const btn = await page.getByRole('button').element();
expect(btn.getAttribute('title')).toBe(btn.getAttribute('aria-label'));
await expect
.element(page.getByRole('radio', { name: 'Dunkel' }))
.toHaveAttribute('aria-checked', 'true');
await expect
.element(page.getByRole('radio', { name: 'Hell' }))
.toHaveAttribute('aria-checked', 'false');
});
});
describe('ThemeToggle — theme switching', () => {
beforeEach(() => {
localStorage.setItem('theme', 'light');
});
it('clicking Dunkel sets data-theme=dark on documentElement', async () => {
render(ThemeToggle);
await page.getByRole('radio', { name: 'Dunkel' }).click();
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
});
it('clicking Dunkel persists theme in localStorage', async () => {
render(ThemeToggle);
await page.getByRole('radio', { name: 'Dunkel' }).click();
expect(localStorage.getItem('theme')).toBe('dark');
});
});