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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user