feat(web): i18n with react-i18next (sv/en)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 22:42:47 +02:00
parent 66d0624279
commit 478b4ce44e
10 changed files with 156 additions and 0 deletions
+2
View File
@@ -18,10 +18,12 @@
"@fontsource-variable/geist": "^5.2.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.0",
"lucide-react": "^1.17.0",
"openapi-fetch": "^0.17.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^17.0.8",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
+58
View File
@@ -20,6 +20,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
i18next:
specifier: ^26.3.0
version: 26.3.0(typescript@5.8.3)
lucide-react:
specifier: ^1.17.0
version: 1.17.0(react@19.2.7)
@@ -32,6 +35,9 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.2.7(react@19.2.7)
react-i18next:
specifier: ^17.0.8
version: 17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -1760,6 +1766,9 @@ packages:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -1780,6 +1789,14 @@ packages:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
i18next@26.3.0:
resolution: {integrity: sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
typescript:
optional: true
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@@ -2389,6 +2406,22 @@ packages:
peerDependencies:
react: ^19.2.7
react-i18next@17.0.8:
resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5 || ^6
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -2837,6 +2870,10 @@ packages:
jsdom:
optional: true
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@@ -4572,6 +4609,10 @@ snapshots:
dependencies:
whatwg-encoding: 3.1.1
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -4598,6 +4639,10 @@ snapshots:
human-signals@8.0.1: {}
i18next@26.3.0(typescript@5.8.3):
optionalDependencies:
typescript: 5.8.3
iconv-lite@0.6.3:
dependencies:
safer-buffer: 2.1.2
@@ -5114,6 +5159,17 @@ snapshots:
react: 19.2.7
scheduler: 0.27.0
react-i18next@17.0.8(i18next@26.3.0(typescript@5.8.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1
i18next: 26.3.0(typescript@5.8.3)
react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies:
react-dom: 19.2.7(react@19.2.7)
typescript: 5.8.3
react-is@17.0.2: {}
react-refresh@0.17.0: {}
@@ -5595,6 +5651,8 @@ snapshots:
- tsx
- yaml
void-elements@3.1.0: {}
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
+8
View File
@@ -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" }
}
+29
View File
@@ -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");
});
+18
View File
@@ -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;
+8
View File
@@ -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" }
}
+13
View File
@@ -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
View File
@@ -1,4 +1,5 @@
import "./index.css";
import "./i18n";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app";
+18
View File
@@ -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" });
+1
View File
@@ -8,6 +8,7 @@
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",