feat(web): i18n with react-i18next (sv/en)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,10 +18,12 @@
|
|||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"i18next": "^26.3.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
|
"react-i18next": "^17.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+58
@@ -20,6 +20,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
i18next:
|
||||||
|
specifier: ^26.3.0
|
||||||
|
version: 26.3.0(typescript@5.8.3)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.17.0
|
specifier: ^1.17.0
|
||||||
version: 1.17.0(react@19.2.7)
|
version: 1.17.0(react@19.2.7)
|
||||||
@@ -32,6 +35,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.7(react@19.2.7)
|
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:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -1760,6 +1766,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -1780,6 +1789,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||||
engines: {node: '>=18.18.0'}
|
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:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2389,6 +2406,22 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.7
|
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:
|
react-is@17.0.2:
|
||||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||||
|
|
||||||
@@ -2837,6 +2870,10 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
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:
|
w3c-xmlserializer@5.0.0:
|
||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4572,6 +4609,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
whatwg-encoding: 3.1.1
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
|
html-parse-stringify@3.0.1:
|
||||||
|
dependencies:
|
||||||
|
void-elements: 3.1.0
|
||||||
|
|
||||||
http-errors@2.0.1:
|
http-errors@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
@@ -4598,6 +4639,10 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@8.0.1: {}
|
human-signals@8.0.1: {}
|
||||||
|
|
||||||
|
i18next@26.3.0(typescript@5.8.3):
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.8.3
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -5114,6 +5159,17 @@ snapshots:
|
|||||||
react: 19.2.7
|
react: 19.2.7
|
||||||
scheduler: 0.27.0
|
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-is@17.0.2: {}
|
||||||
|
|
||||||
react-refresh@0.17.0: {}
|
react-refresh@0.17.0: {}
|
||||||
@@ -5595,6 +5651,8 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
void-elements@3.1.0: {}
|
||||||
|
|
||||||
w3c-xmlserializer@5.0.0:
|
w3c-xmlserializer@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|||||||
@@ -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 "./index.css";
|
||||||
|
import "./i18n";
|
||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./app";
|
import { App } from "./app";
|
||||||
|
|||||||
@@ -3,6 +3,24 @@ import { afterAll, afterEach } from "vitest";
|
|||||||
|
|
||||||
import { server } from "./server";
|
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
|
// Start MSW at module level so its fetch patch is in place before any test
|
||||||
// module captures globalThis.fetch via openapi-fetch's createClient().
|
// module captures globalThis.fetch via openapi-fetch's createClient().
|
||||||
server.listen({ onUnhandledRequest: "error" });
|
server.listen({ onUnhandledRequest: "error" });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user