mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 15:23:27 +00:00
feat(i18n): add English and French navigation foundation (#62)
This commit is contained in:
1 parent
0f0a6ca51c
commit
6d3edbb61b
17 files changed
+552
-32
No files matched your search
@@ -82,6 +82,9 @@ because the root `tsconfig.json` has `files: []`.
|
||||
|
||||
## Tests
|
||||
|
||||
For UI text, follow the [translation guide](docs/translations.md). Add English
|
||||
catalog keys and translate display labels without changing route/data identifiers.
|
||||
|
||||
- **Practice TDD for bugfixes and features** — write the failing test first,
|
||||
watch it fail, then implement.
|
||||
- Tests live in a top-level `tests/` tree that mirrors `src/` (e.g.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Translating Beacon
|
||||
|
||||
The header language picker starts in English and saves the selected language in
|
||||
this browser. Its trigger and panel use the same dropdown styling as the region
|
||||
control. Open it with Enter/Space, Tab to a language and activate that button;
|
||||
Escape closes it and returns focus to the trigger. French is the first
|
||||
additional language. This first slice covers
|
||||
desktop/mobile navigation, the region and theme controls, connection/retry
|
||||
labels and lazy-page loading text. Detailed feature pages, chart labels, general
|
||||
dialogs and data formatting remain follow-up work; selecting French does not
|
||||
mean every page is translated yet.
|
||||
|
||||
## Add a language
|
||||
|
||||
1. Copy `src/i18n/locales/en.json` to a language-tag filename such as `de.json`.
|
||||
2. Set `name` to its native name, such as `Deutsch`. Translate string values
|
||||
under `translation`; keep their keys and interpolation placeholders intact.
|
||||
3. Build and test. Vite discovers every JSON catalog in that directory, and its
|
||||
native name appears in the language picker automatically. No central language
|
||||
list, plugin or translation server is needed.
|
||||
|
||||
Missing or empty translations fall back to English. Catalogs are bundled with the
|
||||
application, so a new language requires a web build/deployment. Keep English as
|
||||
the complete source catalog. Every selectable language must have a native name
|
||||
and at least one translated string. Use plain text, not HTML, in translations.
|
||||
|
||||
## Add translated UI text
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function LoadingMessage() {
|
||||
const { t } = useTranslation();
|
||||
return <span>{t("common.loading")}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
Add the English key/value and translate it in other catalogs where possible.
|
||||
Use `t("region.count", { count })` with i18next's language-specific plural
|
||||
suffixes (`_one`, `_other`, and `_many` where applicable). Preserve placeholders
|
||||
such as `{{seconds}}`; translate the whole phrase rather than joining words.
|
||||
React escapes interpolated text; do not use `dangerouslySetInnerHTML` for it.
|
||||
|
||||
Translate display labels, never the tab IDs (`Packets`, `Analytics`, etc.), URL
|
||||
parameters, region slugs/IATA codes, query keys or WebSocket subscriptions.
|
||||
Names and packet contents come from the network and remain unchanged. This slice
|
||||
does not change date/time/number formatting or measurement units. HTML language
|
||||
and direction follow the selected catalog; a future right-to-left translation
|
||||
also needs layout review before being offered to users.
|
||||
|
||||
Run `npm run build`, `npm run lint` and `npm test`. Test saved/unknown language
|
||||
preferences, English fallback and switching without losing the selected view.
|
||||
Check the picker and menus at narrow widths, including translated status text.
|
||||
The language preference tolerates unavailable browser storage; the choice then
|
||||
lasts only for that visit. Tests use the real i18next resources, reset to English
|
||||
after each test, and keep route/state assertions on canonical identifiers.
|
||||
Generated
+76
-2
@@ -13,9 +13,11 @@
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"echarts": "6.1.0",
|
||||
"i18next": "26.4.2",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "17.0.15",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -293,7 +295,6 @@
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2961,6 +2962,43 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://locize.com"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -3851,6 +3889,33 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.15",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.15.tgz",
|
||||
"integrity": "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"html-parse-stringify": "^4.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
@@ -4225,7 +4290,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -4317,6 +4382,15 @@
|
||||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
|
||||
@@ -21,9 +21,11 @@
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"echarts": "6.1.0",
|
||||
"i18next": "26.4.2",
|
||||
"maplibre-gl": "^6.9.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "17.0.15",
|
||||
"react-router-dom": "^7.18.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react";
|
||||
import { BrowserRouter, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion";
|
||||
import {
|
||||
@@ -142,6 +143,11 @@ export function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: ()
|
||||
|
||||
// tab state and region init
|
||||
|
||||
function TabLoading({ tab }: { tab: string }) {
|
||||
const { t } = useTranslation();
|
||||
return <EmptyState title={t(`tabs.${tab}`, { defaultValue: tab })} subtitle={t("common.loading")} />;
|
||||
}
|
||||
|
||||
function AppInner() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -309,7 +315,7 @@ function AppInner() {
|
||||
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
|
||||
<div className="relative flex flex-1 min-h-0">
|
||||
<div key={activeTab} className="flex flex-1 min-h-0 min-w-0 fade-in">
|
||||
<Suspense fallback={<EmptyState title={activeTab} subtitle="Loading…" />}>
|
||||
<Suspense fallback={<TabLoading tab={activeTab} />}>
|
||||
{tabContent[activeTab]}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
+32
-20
@@ -1,4 +1,6 @@
|
||||
import { type ReactNode, useState, useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRegionSelection, useRegions } from "../hooks/useRegion";
|
||||
@@ -8,6 +10,7 @@ import { useRateLimit } from "../hooks/useRateLimit";
|
||||
import { useTheme } from "../hooks/useTheme";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import { BottomNav } from "./BottomNav";
|
||||
import { LanguagePicker } from "./LanguagePicker";
|
||||
import { BeaconWordmark } from "./BeaconWordmark";
|
||||
import { getIatas } from "../api/client";
|
||||
import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants";
|
||||
@@ -16,6 +19,7 @@ import type { WsManager } from "../api/ws-manager";
|
||||
// header widgets: WS status, region picker, theme picker
|
||||
|
||||
function LiveBadge({ wsManager }: { wsManager: WsManager }) {
|
||||
const { t } = useTranslation();
|
||||
const { status } = useWsStatus(wsManager);
|
||||
const [staleStr, setStaleStr] = useState("");
|
||||
|
||||
@@ -34,7 +38,7 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-green bg-green/8 border border-green/15 px-2 py-0.5 rounded-sm">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green animate-pulse" />
|
||||
LIVE
|
||||
{t("connection.live")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,20 +46,21 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) {
|
||||
if (status === "connecting") {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-warn bg-warn/7 border border-warn/15 px-2 py-0.5 rounded-sm">
|
||||
STALE {staleStr}
|
||||
{t("connection.stale", { age: staleStr })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-danger bg-danger/8 border border-danger/15 px-2 py-0.5 rounded-sm">
|
||||
OFFLINE
|
||||
{t("connection.offline")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shown while the API is throttling us; the countdown tells users the blank tables are temporary.
|
||||
function RateLimitBadge() {
|
||||
const { t } = useTranslation();
|
||||
const until = useRateLimit();
|
||||
const [remaining, setRemaining] = useState(0);
|
||||
|
||||
@@ -74,7 +79,7 @@ function RateLimitBadge() {
|
||||
|
||||
return (
|
||||
<div role="status" className="flex items-center gap-1.5 font-mono text-[11px] text-warn bg-warn/7 border border-warn/15 px-2 py-0.5 rounded-sm">
|
||||
RATE LIMITED {remaining}s
|
||||
{t("connection.rateLimited", { seconds: remaining })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,14 +100,14 @@ function CheckBox({ checked }: { checked: boolean }) {
|
||||
}
|
||||
|
||||
// Compact header summary of the active selection, e.g. "ALL", "YVR, YYJ", "2 regions", "1 region · 3 IATA".
|
||||
function regionSummaryLabel(selection: RegionSelection): string {
|
||||
if (isAllRegions(selection)) return "ALL";
|
||||
function regionSummaryLabel(selection: RegionSelection, t: TFunction): string {
|
||||
if (isAllRegions(selection)) return t("region.allShort");
|
||||
const parts: string[] = [];
|
||||
if (selection.regions.length > 0) {
|
||||
parts.push(`${selection.regions.length} region${selection.regions.length > 1 ? "s" : ""}`);
|
||||
parts.push(t("region.count", { count: selection.regions.length }));
|
||||
}
|
||||
if (selection.iatas.length > 0) {
|
||||
parts.push(selection.iatas.length <= 2 ? selection.iatas.join(", ") : `${selection.iatas.length} IATA`);
|
||||
parts.push(selection.iatas.length <= 2 ? selection.iatas.join(", ") : t("region.iataCount", { count: selection.iatas.length }));
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
@@ -110,6 +115,7 @@ function regionSummaryLabel(selection: RegionSelection): string {
|
||||
// Grouped multi-select: regions (each expands to its member IATAs) on top, then individual IATAs.
|
||||
// Toggling keeps the dropdown open so several can be picked; "All Regions" clears the selection.
|
||||
function RegionSelector() {
|
||||
const { t } = useTranslation();
|
||||
const { selection } = useRegionSelection();
|
||||
|
||||
return (
|
||||
@@ -122,8 +128,8 @@ function RegionSelector() {
|
||||
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors"
|
||||
onClick={toggle}
|
||||
>
|
||||
<span className="text-text-muted font-normal text-[11px]">REGION</span>
|
||||
{regionSummaryLabel(selection)}
|
||||
<span className="text-text-muted font-normal text-[11px]">{t("region.label")}</span>
|
||||
{regionSummaryLabel(selection, t)}
|
||||
<span className="text-text-dim text-[11px]">▾</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -135,6 +141,7 @@ function RegionSelector() {
|
||||
|
||||
// Split out from RegionSelector so the filter query lives and dies with the open panel.
|
||||
function RegionSelectorPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { selection, setSelection } = useRegionSelection();
|
||||
const { regions } = useRegions();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -193,7 +200,7 @@ function RegionSelectorPanel() {
|
||||
);
|
||||
}, [iatas, q]);
|
||||
|
||||
const showAll = !q || "all regions".includes(q);
|
||||
const showAll = !q || t("region.allRegions").toLowerCase().includes(q);
|
||||
const showIataGroup = !iatas || shownIatas.length > 0; // keep the group while loading/failed
|
||||
const hasRowsAbove = showAll || shownRegions.length > 0;
|
||||
|
||||
@@ -213,7 +220,8 @@ function RegionSelectorPanel() {
|
||||
setQuery("");
|
||||
}
|
||||
}}
|
||||
placeholder="Filter IATA or name…"
|
||||
aria-label={t("region.filter")}
|
||||
placeholder={t("region.filter")}
|
||||
className="w-full text-[16px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim"
|
||||
/>
|
||||
</div>
|
||||
@@ -230,14 +238,14 @@ function RegionSelectorPanel() {
|
||||
>
|
||||
{/* spacer matching the checkbox column so ALL/code/name align with the rows below */}
|
||||
<span className="w-3 shrink-0" aria-hidden="true" />
|
||||
<span className="font-semibold text-primary w-8 shrink-0">ALL</span>
|
||||
<span className="text-text-dim">All Regions</span>
|
||||
<span className="font-semibold text-primary min-w-8 shrink-0">{t("region.allShort")}</span>
|
||||
<span className="text-text-dim">{t("region.allRegions")}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{shownRegions.length > 0 && (
|
||||
<>
|
||||
<div className="px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim">Regions</div>
|
||||
<div className="px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim">{t("region.group")}</div>
|
||||
{shownRegions.map(({ region, matched }) => {
|
||||
const checked = selection.regions.includes(region.slug);
|
||||
return (
|
||||
@@ -282,21 +290,22 @@ function RegionSelectorPanel() {
|
||||
);
|
||||
})
|
||||
) : iatasError ? (
|
||||
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">Failed to load</div>
|
||||
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">{t("region.failed")}</div>
|
||||
) : (
|
||||
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">Loading…</div>
|
||||
<div className="px-3 py-1.5 text-[11px] font-mono text-text-dim">{t("common.loading")}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!hasRowsAbove && !showIataGroup && (
|
||||
<div className="px-3 py-2 text-[11px] font-mono text-text-dim">No matches</div>
|
||||
<div className="px-3 py-2 text-[11px] font-mono text-text-dim">{t("region.noMatches")}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePicker() {
|
||||
const { t } = useTranslation();
|
||||
const { themeId, themes, setThemeId } = useTheme();
|
||||
const current = themes.find((t) => t.id === themeId);
|
||||
const list = selectableThemes(themes, ENABLED_THEME_IDS);
|
||||
@@ -306,6 +315,7 @@ function ThemePicker() {
|
||||
renderTrigger={({ toggle }) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("theme.label")}
|
||||
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-2 py-1 text-text-muted font-mono text-[11px] hover:text-text-normal hover:border-text-dim transition-colors"
|
||||
onClick={toggle}
|
||||
>
|
||||
@@ -356,13 +366,15 @@ interface AppShellProps {
|
||||
}
|
||||
|
||||
export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShellProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col h-dvh">
|
||||
<header className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-3 py-1.5 min-h-[42px] md:flex md:gap-3 md:px-4 bg-bg-surface border-b border-border shrink-0">
|
||||
<BeaconWordmark iconSize={22} textClassName="text-sm truncate" className="min-w-0" />
|
||||
<div className="col-span-2 row-start-2 flex items-center justify-between gap-1.5 min-w-0 md:ml-auto md:justify-start md:gap-3">
|
||||
<div className="col-span-2 row-start-2 flex flex-wrap items-center justify-between gap-1.5 min-w-0 md:ml-auto md:flex-nowrap md:justify-start md:gap-3">
|
||||
<RegionSelector />
|
||||
<ThemePicker />
|
||||
<LanguagePicker />
|
||||
</div>
|
||||
<div className="col-start-2 row-start-1 flex items-center gap-1.5 md:gap-3">
|
||||
<LiveBadge wsManager={wsManager} />
|
||||
@@ -395,7 +407,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe
|
||||
}`}
|
||||
onClick={() => onTabChange(tab)}
|
||||
>
|
||||
{tab}
|
||||
{t(`tabs.${tab}`, { defaultValue: tab })}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BottomSheet } from "./BottomSheet";
|
||||
import { ENABLED_TABS } from "../lib/constants";
|
||||
|
||||
@@ -81,8 +82,9 @@ function NavButton({ label, icon, active, onClick, role, ariaSelected, ariaHasPo
|
||||
|
||||
// Bottom sheet listing the overflow tabs.
|
||||
function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activeTab: string; onPick: (tab: string) => void; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<BottomSheet onClose={onClose} role="menu" label="More tabs">
|
||||
<BottomSheet onClose={onClose} role="menu" label={t("navigation.moreTabs")}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
@@ -93,7 +95,7 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ
|
||||
activeTab === tab ? "text-primary" : "text-text-normal hover:bg-text-normal/3"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
{t(`tabs.${tab}`, { defaultValue: tab })}
|
||||
</button>
|
||||
))}
|
||||
</BottomSheet>
|
||||
@@ -101,6 +103,7 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ
|
||||
}
|
||||
|
||||
export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const enabled = new Set<string>(ENABLED_TABS);
|
||||
const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t));
|
||||
@@ -114,11 +117,11 @@ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTab
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="flex md:hidden shrink-0 bg-bg-surface border-t border-border" role="tablist" aria-label="Primary">
|
||||
<nav className="flex md:hidden shrink-0 bg-bg-surface border-t border-border" role="tablist" aria-label={t("navigation.primary")}>
|
||||
{primary.map((tab) => (
|
||||
<NavButton
|
||||
key={tab}
|
||||
label={tab}
|
||||
label={t(`tabs.${tab}`, { defaultValue: tab })}
|
||||
icon={<Icon name={tab} />}
|
||||
active={activeTab === tab}
|
||||
onClick={() => onTabChange(tab)}
|
||||
@@ -128,7 +131,7 @@ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTab
|
||||
))}
|
||||
{overflow.length > 0 && (
|
||||
<NavButton
|
||||
label="More"
|
||||
label={t("navigation.more")}
|
||||
icon={<Icon name="More" />}
|
||||
active={overflowActive || sheetOpen}
|
||||
onClick={() => setSheetOpen((v) => !v)}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useId, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { languages } from "../i18n";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
|
||||
export function LanguagePicker() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const panelId = useId();
|
||||
const language = i18n.resolvedLanguage ?? "en";
|
||||
const selectedName = languages.find(({ code }) => code === language)?.name ?? language;
|
||||
return (
|
||||
<Dropdown
|
||||
width="w-48"
|
||||
renderTrigger={({ open, toggle }) => (
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
aria-label={`${t("language.label")}: ${selectedName}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelId : undefined}
|
||||
title={t("language.help")}
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors"
|
||||
>
|
||||
<span className="text-text-muted font-normal text-[11px] uppercase">{t("language.label")}</span>
|
||||
{language.toUpperCase()}
|
||||
<span className="text-text-dim text-[11px]">▾</span>
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{(close) => (
|
||||
<div
|
||||
id={panelId}
|
||||
role="group"
|
||||
aria-label={t("language.label")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{languages.map(({ code, name }) => (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
aria-pressed={language === code}
|
||||
onClick={() => {
|
||||
void i18n.changeLanguage(code);
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${
|
||||
language === code ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true" className="font-semibold text-primary min-w-8 shrink-0">{code.toUpperCase()}</span>
|
||||
<span lang={code}>{name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createInstance } from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
|
||||
interface Catalog {
|
||||
name: string;
|
||||
translation: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const catalogs = import.meta.glob<Catalog>("./locales/*.json", { eager: true, import: "default" });
|
||||
const resources = Object.fromEntries(Object.entries(catalogs).map(([path, catalog]) => [
|
||||
path.slice("./locales/".length, -".json".length), { translation: catalog.translation },
|
||||
]));
|
||||
|
||||
export const languages = Object.entries(catalogs).map(([path, catalog]) => ({
|
||||
code: path.slice("./locales/".length, -".json".length),
|
||||
name: catalog.name,
|
||||
}));
|
||||
|
||||
export function readLanguagePreference(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem("beacon-language");
|
||||
if (saved && languages.some(({ code }) => code === saved)) return saved;
|
||||
} catch {
|
||||
// Storage can be unavailable; language selection still works for this visit.
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
const i18n = createInstance();
|
||||
i18n.on("languageChanged", () => {
|
||||
const language = i18n.resolvedLanguage ?? "en";
|
||||
document.documentElement.lang = language;
|
||||
document.documentElement.dir = i18n.dir(language);
|
||||
try {
|
||||
localStorage.setItem("beacon-language", language);
|
||||
} catch {
|
||||
// Keep the in-memory choice even when persistence is blocked.
|
||||
}
|
||||
});
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
resources,
|
||||
lng: readLanguagePreference(),
|
||||
supportedLngs: languages.map(({ code }) => code),
|
||||
fallbackLng: "en",
|
||||
initAsync: false,
|
||||
returnEmptyString: false,
|
||||
interpolation: { escapeValue: false }, // React escapes text content.
|
||||
react: { useSuspense: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "English",
|
||||
"translation": {
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"help": "Choose a language. Some pages still use English."
|
||||
},
|
||||
"tabs": {
|
||||
"Packets": "Packets",
|
||||
"Channels": "Channels",
|
||||
"Map": "Map",
|
||||
"Nodes": "Nodes",
|
||||
"Observers": "Observers",
|
||||
"Routes": "Routes",
|
||||
"Traces": "Traces",
|
||||
"Analytics": "Analytics"
|
||||
},
|
||||
"navigation": {
|
||||
"primary": "Primary",
|
||||
"more": "More",
|
||||
"moreTabs": "More tabs"
|
||||
},
|
||||
"connection": {
|
||||
"live": "LIVE",
|
||||
"stale": "STALE {{age}}",
|
||||
"offline": "OFFLINE",
|
||||
"rateLimited": "RATE LIMITED {{seconds}}s"
|
||||
},
|
||||
"region": {
|
||||
"label": "REGION",
|
||||
"allShort": "ALL",
|
||||
"allRegions": "All Regions",
|
||||
"group": "Regions",
|
||||
"count_one": "{{count}} region",
|
||||
"count_other": "{{count}} regions",
|
||||
"iataCount": "{{count}} IATA",
|
||||
"filter": "Filter IATA or name…",
|
||||
"failed": "Failed to load",
|
||||
"noMatches": "No matches"
|
||||
},
|
||||
"theme": { "label": "Theme" },
|
||||
"common": { "loading": "Loading…" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "Français",
|
||||
"translation": {
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"help": "Choisissez une langue. Certaines pages restent en anglais."
|
||||
},
|
||||
"tabs": {
|
||||
"Packets": "Paquets",
|
||||
"Channels": "Canaux",
|
||||
"Map": "Carte",
|
||||
"Nodes": "Nœuds",
|
||||
"Observers": "Observateurs",
|
||||
"Routes": "Routes",
|
||||
"Traces": "Traces",
|
||||
"Analytics": "Analyses"
|
||||
},
|
||||
"navigation": {
|
||||
"primary": "Navigation principale",
|
||||
"more": "Plus",
|
||||
"moreTabs": "Autres onglets"
|
||||
},
|
||||
"connection": {
|
||||
"live": "EN DIRECT",
|
||||
"stale": "EN RETARD {{age}}",
|
||||
"offline": "HORS LIGNE",
|
||||
"rateLimited": "RÉESSAI DANS {{seconds}} s"
|
||||
},
|
||||
"region": {
|
||||
"label": "RÉGION",
|
||||
"allShort": "TOUT",
|
||||
"allRegions": "Toutes les régions",
|
||||
"group": "Régions",
|
||||
"count_one": "{{count}} région",
|
||||
"count_many": "{{count}} régions",
|
||||
"count_other": "{{count}} régions",
|
||||
"iataCount": "{{count}} IATA",
|
||||
"filter": "Filtrer par IATA ou nom…",
|
||||
"failed": "Échec du chargement",
|
||||
"noMatches": "Aucun résultat"
|
||||
},
|
||||
"theme": { "label": "Thème" },
|
||||
"common": { "loading": "Chargement…" }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./i18n";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@ const wsManager = {
|
||||
getLastEventTimestamp: () => Date.now(),
|
||||
} as unknown as WsManager;
|
||||
|
||||
function renderShell() {
|
||||
function renderShell(onTabChange: (tab: string) => void = () => {}) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<RegionProvider defaultSelection={ALL_REGIONS}>
|
||||
<AppShell activeTab="Packets" onTabChange={() => {}} wsManager={wsManager}>
|
||||
<AppShell activeTab="Packets" onTabChange={onTabChange} wsManager={wsManager}>
|
||||
<div />
|
||||
</AppShell>
|
||||
</RegionProvider>
|
||||
@@ -41,6 +41,24 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("AppShell", () => {
|
||||
it("switches navigation to French while preserving tab identifiers and the active view", async () => {
|
||||
vi.mocked(getIatas).mockResolvedValue([]);
|
||||
const onTabChange = vi.fn();
|
||||
renderShell(onTabChange);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Français", exact: true }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveAttribute("aria-expanded", "false"));
|
||||
expect(screen.getAllByRole("tab", { name: "Paquets" })[0]).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByText("EN DIRECT")).toBeInTheDocument();
|
||||
expect(document.documentElement.lang).toBe("fr");
|
||||
expect(localStorage.getItem("beacon-language")).toBe("fr");
|
||||
expect(onTabChange).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getAllByRole("tab", { name: "Carte" })[0]!);
|
||||
expect(onTabChange).toHaveBeenCalledWith("Map");
|
||||
});
|
||||
|
||||
it("footer shows the package.json version", () => {
|
||||
vi.mocked(getIatas).mockResolvedValue([]);
|
||||
renderShell();
|
||||
@@ -88,6 +106,22 @@ describe("region picker filter", () => {
|
||||
vi.mocked(getRegion).mockImplementation(async (id: number) => REGIONS.find((r) => r.id === id)!);
|
||||
});
|
||||
|
||||
it("searches the translated all-regions label and retains raw IATA values", async () => {
|
||||
renderShell();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Language: English" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Français", exact: true }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /RÉGION/ }));
|
||||
await screen.findByText("Western Canada");
|
||||
const input = screen.getByRole("textbox", { name: "Filtrer par IATA ou nom…" });
|
||||
fireEvent.change(input, { target: { value: "toutes" } });
|
||||
expect(screen.getByText("Toutes les régions")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(input, { target: { value: "yvr" } });
|
||||
expect(screen.queryByText("Toutes les régions")).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Vancouver International/ }));
|
||||
expect(screen.getByRole("button", { name: /RÉGION\s*YVR/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("focuses the filter input when the picker opens", async () => {
|
||||
renderShell();
|
||||
const input = await openPicker();
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { BottomNav } from "../../src/components/BottomNav";
|
||||
import i18n from "../../src/i18n";
|
||||
|
||||
describe("BottomNav", () => {
|
||||
it("translates an open More sheet without changing the selected tab identifier", async () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<BottomNav activeTab="Analytics" onTabChange={onTabChange} />);
|
||||
fireEvent.click(screen.getByText("More"));
|
||||
await act(() => i18n.changeLanguage("fr"));
|
||||
expect(screen.getByRole("menu", { name: "Autres onglets" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Plus").closest("button")).toHaveAttribute("aria-expanded", "true");
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Analyses" }));
|
||||
expect(onTabChange).toHaveBeenCalledWith("Analytics");
|
||||
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the active primary tab with aria-selected", () => {
|
||||
render(<BottomNav activeTab="Map" onTabChange={() => {}} />);
|
||||
expect(screen.getByRole("tab", { name: "Map" })).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { LanguagePicker } from "../../src/components/LanguagePicker";
|
||||
|
||||
describe("LanguagePicker", () => {
|
||||
it("opens the language choices, selects French and returns focus to the trigger", async () => {
|
||||
render(<LanguagePicker />);
|
||||
const trigger = screen.getByRole("button", { name: "Language: English" });
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByRole("group", { name: "Language" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "English", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Français", exact: true }));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveFocus());
|
||||
expect(screen.getByRole("button", { name: "Langue: Français" })).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
||||
expect(localStorage.getItem("beacon-language")).toBe("fr");
|
||||
});
|
||||
|
||||
it("closes with Escape from an option without changing language or losing focus", () => {
|
||||
render(<LanguagePicker />);
|
||||
const trigger = screen.getByRole("button", { name: "Language: English" });
|
||||
fireEvent.click(trigger);
|
||||
const french = screen.getByRole("button", { name: "Français", exact: true });
|
||||
french.focus();
|
||||
fireEvent.keyDown(french, { key: "Escape" });
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes on an outside click without changing the choice", () => {
|
||||
render(<LanguagePicker />);
|
||||
const trigger = screen.getByRole("button", { name: "Language: English" });
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(screen.queryByRole("group")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n, { languages, readLanguagePreference } from "../../src/i18n";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.removeItem("beacon-language");
|
||||
});
|
||||
|
||||
describe("language preferences and catalogs", () => {
|
||||
it("offers the bundled English and French catalogs by their native names", () => {
|
||||
expect(languages).toEqual(expect.arrayContaining([
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "fr", name: "Français" },
|
||||
]));
|
||||
});
|
||||
|
||||
it("defaults to English and restores a supported saved choice", () => {
|
||||
localStorage.removeItem("beacon-language");
|
||||
expect(readLanguagePreference()).toBe("en");
|
||||
localStorage.setItem("beacon-language", "fr");
|
||||
expect(readLanguagePreference()).toBe("fr");
|
||||
});
|
||||
|
||||
it.each(["unknown", "constructor", "", "../fr"])("ignores unsupported saved language %j", (value) => {
|
||||
localStorage.setItem("beacon-language", value);
|
||||
expect(readLanguagePreference()).toBe("en");
|
||||
});
|
||||
|
||||
it("still changes language when browser storage is unavailable", async () => {
|
||||
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
expect(readLanguagePreference()).toBe("en");
|
||||
await i18n.changeLanguage("fr");
|
||||
expect(i18n.t("tabs.Packets")).toBe("Paquets");
|
||||
expect(document.documentElement.lang).toBe("fr");
|
||||
expect(document.documentElement.dir).toBe("ltr");
|
||||
});
|
||||
|
||||
it("persists only the language preference and restores English document metadata", async () => {
|
||||
localStorage.setItem("beacon-region", "YVR");
|
||||
await i18n.changeLanguage("fr");
|
||||
expect(localStorage.getItem("beacon-language")).toBe("fr");
|
||||
expect(localStorage.getItem("beacon-region")).toBe("YVR");
|
||||
await i18n.changeLanguage("en");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
localStorage.removeItem("beacon-region");
|
||||
});
|
||||
|
||||
it("falls back to English for missing and empty translated strings", async () => {
|
||||
i18n.addResource("en", "translation", "fallbackTest", "English fallback");
|
||||
await i18n.changeLanguage("fr");
|
||||
expect(i18n.t("fallbackTest")).toBe("English fallback");
|
||||
i18n.addResource("fr", "translation", "fallbackTest", "");
|
||||
expect(i18n.t("fallbackTest")).toBe("English fallback");
|
||||
});
|
||||
|
||||
it("uses each language's plural forms and interpolates the retry duration", () => {
|
||||
expect(i18n.t("region.count", { lng: "en", count: 1 })).toBe("1 region");
|
||||
expect(i18n.t("region.count", { lng: "en", count: 2 })).toBe("2 regions");
|
||||
expect(i18n.t("region.count", { lng: "fr", count: 1 })).toBe("1 région");
|
||||
expect(i18n.t("region.count", { lng: "fr", count: 2 })).toBe("2 régions");
|
||||
expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("RÉESSAI DANS 5 s");
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, vi } from "vitest";
|
||||
import i18n from "../src/i18n";
|
||||
|
||||
// jsdom doesn't implement matchMedia. Provide a default stub so components that read media queries
|
||||
// mount as "desktop" by default — a hover-capable pointer, not below the mobile width. Individual
|
||||
@@ -20,6 +21,7 @@ if (!window.matchMedia) {
|
||||
}));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
cleanup();
|
||||
await i18n.changeLanguage("en");
|
||||
});
|
||||
Reference in new issue
Block a user