diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index f853041..ae7c008 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -9,6 +9,7 @@ "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" }, "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, "labels": { "label": "Label", "externalUri": "External URI (optional)", "otherLanguages": "This entry also has labels in other languages, which are kept." }, + "theme": { "light": "Light", "dark": "Dark", "system": "System" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", "create": "Create", "selectPrompt": "Select a vocabulary to manage its terms", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index e160c7e..fdfac42 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -9,6 +9,7 @@ "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" }, "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)", "otherLanguages": "Denna post har även etiketter på andra språk, som behålls." }, + "theme": { "light": "Ljust", "dark": "Mörkt", "system": "System" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", "create": "Skapa", "selectPrompt": "Välj en vokabulär för att hantera dess termer", diff --git a/web/src/shell/theme-switch.stories.tsx b/web/src/shell/theme-switch.stories.tsx new file mode 100644 index 0000000..ae227c5 --- /dev/null +++ b/web/src/shell/theme-switch.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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() + }, +} diff --git a/web/src/shell/theme-switch.test.tsx b/web/src/shell/theme-switch.test.tsx new file mode 100644 index 0000000..deaa226 --- /dev/null +++ b/web/src/shell/theme-switch.test.tsx @@ -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(); + 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(); + 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(); + await userEvent.click(screen.getByRole("button", { name: /system/i })); + expect(localStorage.getItem("theme")).toBe("system"); + expect(document.documentElement.classList.contains("dark")).toBe(true); +}); diff --git a/web/src/shell/theme-switch.tsx b/web/src/shell/theme-switch.tsx new file mode 100644 index 0000000..e366376 --- /dev/null +++ b/web/src/shell/theme-switch.tsx @@ -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 ( +
+ {OPTIONS.map(({ value, Icon }) => { + const active = theme === value; + return ( + + ); + })} +
+ ); +}