feat(web): i18n with react-i18next (sv/en)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"app": { "name": "Collection" },
|
||||
"nav": { "objects": "Objects", "vocabularies": "Vocabularies", "authorities": "Authorities", "fields": "Fields", "search": "Search", "soon": "Coming soon" },
|
||||
"auth": { "email": "Email", "password": "Password", "signIn": "Sign in", "signOut": "Sign out", "invalid": "Invalid email or password", "networkError": "Could not reach the server" },
|
||||
"objects": { "title": "Objects", "empty": "No objects yet", "loadError": "Could not load objects", "selectPrompt": "Select an object to view its details", "notFound": "Object not found", "prev": "Previous", "next": "Next", "of": "of" },
|
||||
"fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" },
|
||||
"visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test, beforeEach } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n from "./index";
|
||||
import { useLocale } from "./use-locale";
|
||||
|
||||
beforeEach(async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { t } = useTranslation();
|
||||
const { setLocale } = useLocale();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="title">{t("objects.title")}</span>
|
||||
<button onClick={() => setLocale("sv")}>sv</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test("switches language at runtime", async () => {
|
||||
render(<Probe />);
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("Objects");
|
||||
await act(async () => {
|
||||
screen.getByRole("button", { name: "sv" }).click();
|
||||
});
|
||||
expect(screen.getByTestId("title")).toHaveTextContent("Föremål");
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import en from "./en.json";
|
||||
import sv from "./sv.json";
|
||||
|
||||
export const LOCALE_KEY = "locale";
|
||||
const stored =
|
||||
typeof localStorage !== "undefined" ? localStorage.getItem(LOCALE_KEY) : null;
|
||||
const fallback = "en";
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources: { en: { translation: en }, sv: { translation: sv } },
|
||||
lng: stored ?? fallback,
|
||||
fallbackLng: fallback,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"app": { "name": "Samling" },
|
||||
"nav": { "objects": "Föremål", "vocabularies": "Vokabulär", "authorities": "Auktoriteter", "fields": "Fält", "search": "Sök", "soon": "Kommer snart" },
|
||||
"auth": { "email": "E-post", "password": "Lösenord", "signIn": "Logga in", "signOut": "Logga ut", "invalid": "Fel e-post eller lösenord", "networkError": "Kunde inte nå servern" },
|
||||
"objects": { "title": "Föremål", "empty": "Inga föremål ännu", "loadError": "Kunde inte ladda föremål", "selectPrompt": "Välj ett föremål för att se detaljer", "notFound": "Föremålet hittades inte", "prev": "Föregående", "next": "Nästa", "of": "av" },
|
||||
"fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" },
|
||||
"visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import i18n, { LOCALE_KEY } from "./index";
|
||||
|
||||
export function useLocale() {
|
||||
const { i18n: instance } = useTranslation();
|
||||
|
||||
const setLocale = (lng: "en" | "sv") => {
|
||||
localStorage.setItem(LOCALE_KEY, lng);
|
||||
void i18n.changeLanguage(lng);
|
||||
};
|
||||
|
||||
return { locale: instance.language, setLocale };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import "./index.css";
|
||||
import "./i18n";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./app";
|
||||
|
||||
@@ -3,6 +3,24 @@ import { afterAll, afterEach } from "vitest";
|
||||
|
||||
import { server } from "./server";
|
||||
|
||||
// Node v26 does not expose localStorage as a global unless --localstorage-file
|
||||
// is passed. Provide a minimal in-memory shim so i18n and other modules that
|
||||
// call localStorage.getItem/setItem work in jsdom tests.
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
const store: Record<string, string> = {};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { Object.keys(store).forEach((k) => { delete store[k]; }); },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Start MSW at module level so its fetch patch is in place before any test
|
||||
// module captures globalThis.fetch via openapi-fetch's createClient().
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
|
||||
Reference in New Issue
Block a user