feat(web): ThemeSwitch icon segmented control + theme.* i18n (#59)

This commit is contained in:
2026-06-07 16:33:16 +02:00
parent d452dd9b35
commit 6d17e5f84d
5 changed files with 122 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { expect } from 'storybook/test'
import { ThemeSwitch } from './theme-switch'
const meta = {
component: ThemeSwitch,
tags: ['ai-generated'],
} satisfies Meta<typeof ThemeSwitch>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvas }) => {
await expect(canvas.getByRole('button', { name: /light/i })).toBeInTheDocument()
await expect(canvas.getByRole('button', { name: /dark/i })).toBeInTheDocument()
await expect(canvas.getByRole('button', { name: /system/i })).toBeInTheDocument()
},
}
+57
View File
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { ThemeSwitch } from "./theme-switch";
beforeEach(() => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
afterEach(() => {
vi.unstubAllGlobals();
localStorage.clear();
document.documentElement.classList.remove("dark");
});
test("selecting Dark applies the dark class and persists", async () => {
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /dark/i }));
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(localStorage.getItem("theme")).toBe("dark");
expect(screen.getByRole("button", { name: /dark/i })).toHaveAttribute("aria-pressed", "true");
});
test("selecting Light removes the dark class and persists", async () => {
localStorage.setItem("theme", "dark");
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /light/i }));
expect(document.documentElement.classList.contains("dark")).toBe(false);
expect(localStorage.getItem("theme")).toBe("light");
});
test("selecting System resolves via prefers-color-scheme", async () => {
vi.stubGlobal("matchMedia", (query: string) => ({
matches: true,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
renderApp(<ThemeSwitch />);
await userEvent.click(screen.getByRole("button", { name: /system/i }));
expect(localStorage.getItem("theme")).toBe("system");
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
+43
View File
@@ -0,0 +1,43 @@
import { Monitor, Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useTheme } from "../theme/use-theme";
import type { Theme } from "../theme/theme";
import { cn } from "@/lib/utils";
const OPTIONS: { value: Theme; Icon: typeof Sun }[] = [
{ value: "light", Icon: Sun },
{ value: "dark", Icon: Moon },
{ value: "system", Icon: Monitor },
];
export function ThemeSwitch() {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
return (
<div className="flex gap-1">
{OPTIONS.map(({ value, Icon }) => {
const active = theme === value;
return (
<button
key={value}
type="button"
onClick={() => setTheme(value)}
aria-pressed={active}
aria-label={t(`theme.${value}`)}
title={t(`theme.${value}`)}
className={cn(
"rounded-md p-1 transition-colors",
active
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="h-4 w-4" aria-hidden />
</button>
);
})}
</div>
);
}