@terpjs/react-core 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/package.json +2 -2
- package/src/AppShell.test.tsx +52 -4
- package/src/AppShell.tsx +35 -17
- package/src/ConfirmDialog.tsx +4 -4
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +31 -8
- package/src/ErrorState.tsx +8 -4
- package/src/Field.test.tsx +20 -0
- package/src/Field.tsx +2 -2
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +33 -20
- package/src/UserMenu.test.tsx +2 -2
- package/src/bootstrap.tsx +5 -2
- package/src/format.test.tsx +3 -3
- package/src/index.ts +24 -3
- package/src/locale.test.tsx +195 -3
- package/src/locale.tsx +196 -11
- package/src/markers.test.ts +2 -0
- package/src/nav.ts +2 -2
- package/src/router.tsx +13 -3
- package/src/sso.ts +2 -2
- package/src/styles.test.ts +84 -34
- package/src/styles.ts +233 -31
- package/src/tokens.guard.test.ts +49 -2
- package/src/ui/Combobox.test.tsx +90 -0
- package/src/ui/Combobox.tsx +247 -41
- package/src/ui/DatePicker.tsx +16 -7
- package/src/ui/Tabs.test.tsx +28 -0
- package/src/ui/Tabs.tsx +14 -0
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.test.tsx +15 -1
- package/src/uiText.tsx +71 -2
package/src/format.test.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
useFormatDate,
|
|
14
14
|
useFormatNumber,
|
|
15
15
|
} from "./format";
|
|
16
|
-
import { LocaleProvider } from "./locale";
|
|
16
|
+
import { LOCALE_EN, LOCALE_NL, LocaleProvider } from "./locale";
|
|
17
17
|
|
|
18
18
|
afterEach(() => {
|
|
19
19
|
cleanup();
|
|
@@ -27,8 +27,8 @@ afterEach(() => {
|
|
|
27
27
|
// version asserted the literal digit 7 and would have failed in Kiritimati and nowhere else.
|
|
28
28
|
const WHEN = "2026-07-07T12:00:00Z";
|
|
29
29
|
|
|
30
|
-
const NL =
|
|
31
|
-
const EN =
|
|
30
|
+
const NL = LOCALE_NL;
|
|
31
|
+
const EN = LOCALE_EN;
|
|
32
32
|
|
|
33
33
|
describe("the locale-explicit formatters", () => {
|
|
34
34
|
it("actually varies with the locale it is given", () => {
|
package/src/index.ts
CHANGED
|
@@ -87,8 +87,23 @@ export type {
|
|
|
87
87
|
} from "./SplitPage";
|
|
88
88
|
export { HubPage, HubCard } from "./HubPage";
|
|
89
89
|
export type { HubPageProps, HubCardProps, RenderHubCardLink } from "./HubPage";
|
|
90
|
-
export {
|
|
91
|
-
|
|
90
|
+
export {
|
|
91
|
+
UiTextProvider,
|
|
92
|
+
Trans,
|
|
93
|
+
useStrings,
|
|
94
|
+
useUiText,
|
|
95
|
+
resolveUiText,
|
|
96
|
+
resolveUiTextNode,
|
|
97
|
+
DEFAULT_STRINGS,
|
|
98
|
+
} from "./uiText";
|
|
99
|
+
export type {
|
|
100
|
+
UiText,
|
|
101
|
+
UiTextNode,
|
|
102
|
+
ResolveUiText,
|
|
103
|
+
TerpStrings,
|
|
104
|
+
TransProps,
|
|
105
|
+
UiTextProviderProps,
|
|
106
|
+
} from "./uiText";
|
|
92
107
|
export { EmptyState } from "./EmptyState";
|
|
93
108
|
export type { EmptyStateProps } from "./EmptyState";
|
|
94
109
|
export { ErrorState, describeError } from "./ErrorState";
|
|
@@ -216,13 +231,19 @@ export { ThemeProvider, ThemeToggle, useTheme, THEME_STORAGE_KEY } from "./theme
|
|
|
216
231
|
export type { Theme, ThemeProviderProps, ThemeToggleProps } from "./theme";
|
|
217
232
|
export {
|
|
218
233
|
LocaleProvider,
|
|
234
|
+
defineAppLocales,
|
|
219
235
|
LanguageSwitcher,
|
|
220
236
|
useLocale,
|
|
221
237
|
LOCALE_EN,
|
|
222
238
|
LOCALE_NL,
|
|
223
239
|
LOCALE_STORAGE_KEY,
|
|
224
240
|
} from "./locale";
|
|
225
|
-
export type {
|
|
241
|
+
export type {
|
|
242
|
+
AppI18nDeclaration,
|
|
243
|
+
LocaleCatalog,
|
|
244
|
+
LocaleProviderProps,
|
|
245
|
+
LanguageSwitcherProps,
|
|
246
|
+
} from "./locale";
|
|
226
247
|
export { UserMenu, userInitials } from "./UserMenu";
|
|
227
248
|
export type { UserMenuProps } from "./UserMenu";
|
|
228
249
|
|
package/src/locale.test.tsx
CHANGED
|
@@ -2,8 +2,15 @@
|
|
|
2
2
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
LOCALE_EN,
|
|
7
|
+
LOCALE_NL,
|
|
8
|
+
LOCALE_STORAGE_KEY,
|
|
9
|
+
LanguageSwitcher,
|
|
10
|
+
LocaleProvider,
|
|
11
|
+
defineAppLocales,
|
|
12
|
+
} from "./locale";
|
|
13
|
+
import { DEFAULT_STRINGS, Trans, useStrings } from "./uiText";
|
|
7
14
|
|
|
8
15
|
afterEach(() => {
|
|
9
16
|
cleanup();
|
|
@@ -14,7 +21,7 @@ function SignOutLabel() {
|
|
|
14
21
|
return <p>{useStrings().signOut}</p>;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
const NL =
|
|
24
|
+
const NL = LOCALE_NL;
|
|
18
25
|
|
|
19
26
|
describe("LocaleProvider + LanguageSwitcher", () => {
|
|
20
27
|
it("feeds the active catalog's overrides through the UiText seam", () => {
|
|
@@ -84,6 +91,191 @@ describe("LocaleProvider + LanguageSwitcher", () => {
|
|
|
84
91
|
// No visible label text in the inline variant.
|
|
85
92
|
expect(screen.queryByText("Language")).not.toBeInTheDocument();
|
|
86
93
|
});
|
|
94
|
+
|
|
95
|
+
it("resolves app descriptors through the active locale catalog", () => {
|
|
96
|
+
render(
|
|
97
|
+
<LocaleProvider
|
|
98
|
+
locales={{ en: LOCALE_EN, nl: { ...NL, messages: { greeting: "Hallo" } } }}
|
|
99
|
+
defaultLocale="nl"
|
|
100
|
+
sourceLocale="en"
|
|
101
|
+
>
|
|
102
|
+
<Trans id="greeting" message="Hello" />
|
|
103
|
+
</LocaleProvider>,
|
|
104
|
+
);
|
|
105
|
+
expect(screen.getByText("Hallo")).toBeInTheDocument();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("refuses a missing target translation instead of silently using source copy", () => {
|
|
109
|
+
expect(() =>
|
|
110
|
+
render(
|
|
111
|
+
<LocaleProvider locales={{ en: LOCALE_EN, nl: NL }} defaultLocale="nl" sourceLocale="en">
|
|
112
|
+
<Trans id="greeting" message="Hello" />
|
|
113
|
+
</LocaleProvider>,
|
|
114
|
+
),
|
|
115
|
+
).toThrow(/Missing translation "greeting" for locale "nl"/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("refuses a copied source translation unless allowIdentical documents it", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
render(
|
|
121
|
+
<LocaleProvider
|
|
122
|
+
locales={{ en: {}, nl: { ...LOCALE_NL, messages: { greeting: "Hello" } } }}
|
|
123
|
+
defaultLocale="nl"
|
|
124
|
+
sourceLocale="en"
|
|
125
|
+
>
|
|
126
|
+
<Trans id="greeting" message="Hello" />
|
|
127
|
+
</LocaleProvider>,
|
|
128
|
+
),
|
|
129
|
+
).toThrow(/copies its source text/);
|
|
130
|
+
|
|
131
|
+
render(
|
|
132
|
+
<LocaleProvider
|
|
133
|
+
locales={{
|
|
134
|
+
en: {},
|
|
135
|
+
nl: {
|
|
136
|
+
...LOCALE_NL,
|
|
137
|
+
messages: { greeting: "Hello" },
|
|
138
|
+
allowIdentical: ["greeting"],
|
|
139
|
+
},
|
|
140
|
+
}}
|
|
141
|
+
defaultLocale="nl"
|
|
142
|
+
sourceLocale="en"
|
|
143
|
+
>
|
|
144
|
+
<Trans id="greeting" message="Hello" />
|
|
145
|
+
</LocaleProvider>,
|
|
146
|
+
);
|
|
147
|
+
expect(screen.getByText("Hello")).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("refuses malformed locale configuration and descriptors", () => {
|
|
151
|
+
expect(() =>
|
|
152
|
+
render(
|
|
153
|
+
<LocaleProvider locales={{ en: {} }} sourceLocale="nl">
|
|
154
|
+
<span />
|
|
155
|
+
</LocaleProvider>,
|
|
156
|
+
),
|
|
157
|
+
).toThrow(/Source locale "nl" is not present/);
|
|
158
|
+
expect(() =>
|
|
159
|
+
render(
|
|
160
|
+
<LocaleProvider locales={{ en: {} }} defaultLocale="nl">
|
|
161
|
+
<span />
|
|
162
|
+
</LocaleProvider>,
|
|
163
|
+
),
|
|
164
|
+
).toThrow(/Default locale "nl" is not present/);
|
|
165
|
+
expect(() =>
|
|
166
|
+
render(
|
|
167
|
+
<LocaleProvider locales={{ en: {} }}>
|
|
168
|
+
<Trans id="" message="Hello" />
|
|
169
|
+
</LocaleProvider>,
|
|
170
|
+
),
|
|
171
|
+
).toThrow(/non-empty id and message/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("merges checked-in app messages with framework catalogs", () => {
|
|
175
|
+
expect(
|
|
176
|
+
defineAppLocales(
|
|
177
|
+
{ sourceLocale: "nl", locales: { nl: {}, en: { messages: { greeting: "Hello" } } } },
|
|
178
|
+
{ en: LOCALE_EN, nl: LOCALE_NL },
|
|
179
|
+
).en.messages,
|
|
180
|
+
).toEqual({ greeting: "Hello" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("validates the checked-in declaration before merging it", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
defineAppLocales({ sourceLocale: "nl", locales: { en: {} } }),
|
|
186
|
+
).toThrow(/Source locale "nl" is not present/);
|
|
187
|
+
expect(() =>
|
|
188
|
+
defineAppLocales({
|
|
189
|
+
sourceLocale: "nl",
|
|
190
|
+
locales: { nl: {}, en: { messages: { greeting: "" } } },
|
|
191
|
+
}),
|
|
192
|
+
).toThrow(/empty or invalid message entry/);
|
|
193
|
+
expect(() =>
|
|
194
|
+
defineAppLocales(
|
|
195
|
+
{ sourceLocale: "en", locales: { en: {} } },
|
|
196
|
+
{ en: { strings: [] as never } },
|
|
197
|
+
),
|
|
198
|
+
).toThrow(/framework strings must be an object/);
|
|
199
|
+
expect(() =>
|
|
200
|
+
defineAppLocales(
|
|
201
|
+
{ sourceLocale: "en", locales: { en: {} } },
|
|
202
|
+
"invalid" as never,
|
|
203
|
+
),
|
|
204
|
+
).toThrow(/Framework locale catalogs must be an object/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("refuses incomplete framework catalogs through LocaleProvider itself", () => {
|
|
208
|
+
expect(() =>
|
|
209
|
+
render(
|
|
210
|
+
<LocaleProvider locales={{ en: LOCALE_EN, de: { messages: { greeting: "Hallo" } } }}>
|
|
211
|
+
<span />
|
|
212
|
+
</LocaleProvider>,
|
|
213
|
+
),
|
|
214
|
+
).toThrow(/missing .* framework string translation/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("refuses malformed labels and supplied framework strings", () => {
|
|
218
|
+
expect(() =>
|
|
219
|
+
render(
|
|
220
|
+
<LocaleProvider locales={{ en: { label: "" } }}>
|
|
221
|
+
<span />
|
|
222
|
+
</LocaleProvider>,
|
|
223
|
+
),
|
|
224
|
+
).toThrow(/label must be a non-empty string/);
|
|
225
|
+
expect(() =>
|
|
226
|
+
render(
|
|
227
|
+
<LocaleProvider
|
|
228
|
+
locales={{ en: { strings: { ...LOCALE_NL.strings, signOut: "" } } }}
|
|
229
|
+
>
|
|
230
|
+
<span />
|
|
231
|
+
</LocaleProvider>,
|
|
232
|
+
),
|
|
233
|
+
).toThrow(/empty or invalid framework string "signOut"/);
|
|
234
|
+
expect(() =>
|
|
235
|
+
render(
|
|
236
|
+
<LocaleProvider locales={{ en: { strings: "invalid" as never } }}>
|
|
237
|
+
<span />
|
|
238
|
+
</LocaleProvider>,
|
|
239
|
+
),
|
|
240
|
+
).toThrow(/framework strings must be an object/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("refuses a target locale whose app copy is translated but framework chrome is not", () => {
|
|
244
|
+
expect(() =>
|
|
245
|
+
defineAppLocales(
|
|
246
|
+
{
|
|
247
|
+
sourceLocale: "en",
|
|
248
|
+
locales: { en: {}, de: { messages: { greeting: "Hallo" } } },
|
|
249
|
+
},
|
|
250
|
+
{ en: LOCALE_EN },
|
|
251
|
+
),
|
|
252
|
+
).toThrow(/missing .* framework string translation/);
|
|
253
|
+
|
|
254
|
+
const germanStrings = Object.fromEntries(
|
|
255
|
+
Object.keys(DEFAULT_STRINGS).map((key) => [key, `de:${key}`]),
|
|
256
|
+
);
|
|
257
|
+
expect(
|
|
258
|
+
defineAppLocales(
|
|
259
|
+
{
|
|
260
|
+
sourceLocale: "en",
|
|
261
|
+
locales: { en: {}, de: { messages: { greeting: "Hallo" } } },
|
|
262
|
+
},
|
|
263
|
+
{ en: LOCALE_EN, de: { strings: germanStrings } },
|
|
264
|
+
).de.messages,
|
|
265
|
+
).toEqual({ greeting: "Hallo" });
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("always renders the descriptor fallback in the source locale", () => {
|
|
269
|
+
render(
|
|
270
|
+
<LocaleProvider
|
|
271
|
+
locales={{ en: { messages: { greeting: "stale catalog value" } } }}
|
|
272
|
+
sourceLocale="en"
|
|
273
|
+
>
|
|
274
|
+
<Trans id="greeting" message="Hello" />
|
|
275
|
+
</LocaleProvider>,
|
|
276
|
+
);
|
|
277
|
+
expect(screen.getByText("Hello")).toBeInTheDocument();
|
|
278
|
+
});
|
|
87
279
|
});
|
|
88
280
|
|
|
89
281
|
describe("LOCALE_NL", () => {
|
package/src/locale.tsx
CHANGED
|
@@ -1,21 +1,153 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
|
+
import type { UiText } from "@terpjs/contract";
|
|
3
4
|
|
|
4
5
|
import { Icon } from "./icons";
|
|
5
6
|
import { Menu, MenuItem } from "./ui/Menu";
|
|
6
|
-
import { UiTextProvider, useStrings } from "./uiText";
|
|
7
|
+
import { DEFAULT_STRINGS, UiTextProvider, useStrings } from "./uiText";
|
|
7
8
|
import type { TerpStrings } from "./uiText";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
|
-
* One locale's catalog:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* One locale's catalog: framework strings, app messages, and an optional native display
|
|
12
|
+
* name for language pickers. English locales may omit `strings` because react-core's
|
|
13
|
+
* bundled defaults are English; every declared non-English locale must supply the complete
|
|
14
|
+
* `TerpStrings` set so framework chrome cannot silently fall back to English.
|
|
13
15
|
*/
|
|
14
16
|
export interface LocaleCatalog {
|
|
15
17
|
/** Native display name shown by {@link LanguageSwitcher} (default: the locale code). */
|
|
16
18
|
label?: string;
|
|
17
19
|
/** Framework-string overrides for this locale. */
|
|
18
20
|
strings?: Partial<TerpStrings>;
|
|
21
|
+
/** App-authored messages, keyed by the stable id carried by a `UiText` descriptor. */
|
|
22
|
+
messages?: Record<string, string>;
|
|
23
|
+
/** Message ids intentionally identical to their source copy (catalog-gate documentation). */
|
|
24
|
+
allowIdentical?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Checked-in, JSON-compatible app declaration consumed by {@link defineAppLocales}. */
|
|
28
|
+
export interface AppI18nDeclaration {
|
|
29
|
+
sourceLocale: string;
|
|
30
|
+
locales: Record<string, LocaleCatalog>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function assertLocaleCatalogs(
|
|
38
|
+
locales: unknown,
|
|
39
|
+
sourceLocale?: string,
|
|
40
|
+
): asserts locales is Record<string, LocaleCatalog> {
|
|
41
|
+
if (!isRecord(locales) || Object.keys(locales).length === 0) {
|
|
42
|
+
throw new Error("Locale catalogs must declare at least one locale.");
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
sourceLocale !== undefined &&
|
|
46
|
+
(sourceLocale.trim() === "" || !Object.hasOwn(locales, sourceLocale))
|
|
47
|
+
) {
|
|
48
|
+
throw new Error(`Source locale "${sourceLocale}" is not present in the locale catalogs.`);
|
|
49
|
+
}
|
|
50
|
+
for (const [code, value] of Object.entries(locales)) {
|
|
51
|
+
if (code.trim() === "" || !isRecord(value)) {
|
|
52
|
+
throw new Error("Locale entries must be non-empty codes mapped to catalog objects.");
|
|
53
|
+
}
|
|
54
|
+
if (
|
|
55
|
+
value.label !== undefined &&
|
|
56
|
+
(typeof value.label !== "string" || value.label.trim() === "")
|
|
57
|
+
) {
|
|
58
|
+
throw new Error(`Locale "${code}" label must be a non-empty string.`);
|
|
59
|
+
}
|
|
60
|
+
if (value.strings !== undefined && !isRecord(value.strings)) {
|
|
61
|
+
throw new Error(`Locale "${code}" framework strings must be an object.`);
|
|
62
|
+
}
|
|
63
|
+
for (const [key, translated] of Object.entries(value.strings ?? {})) {
|
|
64
|
+
if (!Object.hasOwn(DEFAULT_STRINGS, key)) {
|
|
65
|
+
throw new Error(`Locale "${code}" has unknown framework string "${key}".`);
|
|
66
|
+
}
|
|
67
|
+
if (typeof translated !== "string" || translated.trim() === "") {
|
|
68
|
+
throw new Error(`Locale "${code}" has an empty or invalid framework string "${key}".`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const messages = value.messages;
|
|
72
|
+
if (messages !== undefined && !isRecord(messages)) {
|
|
73
|
+
throw new Error(`Locale "${code}" messages must be an object.`);
|
|
74
|
+
}
|
|
75
|
+
for (const [id, translated] of Object.entries(messages ?? {})) {
|
|
76
|
+
if (id.trim() === "" || typeof translated !== "string" || translated.trim() === "") {
|
|
77
|
+
throw new Error(`Locale "${code}" has an empty or invalid message entry.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const allowed = value.allowIdentical;
|
|
81
|
+
if (
|
|
82
|
+
allowed !== undefined &&
|
|
83
|
+
(!Array.isArray(allowed) ||
|
|
84
|
+
allowed.some((id) => typeof id !== "string" || id.trim() === ""))
|
|
85
|
+
) {
|
|
86
|
+
throw new Error(`Locale "${code}" allowIdentical must be an array of non-empty ids.`);
|
|
87
|
+
}
|
|
88
|
+
if (allowed !== undefined && new Set(allowed).size !== allowed.length) {
|
|
89
|
+
throw new Error(`Locale "${code}" allowIdentical contains duplicate ids.`);
|
|
90
|
+
}
|
|
91
|
+
const stale = allowed?.find((id) => typeof messages?.[id] !== "string");
|
|
92
|
+
if (stale !== undefined) {
|
|
93
|
+
throw new Error(`Locale "${code}" allowIdentical names missing message "${stale}".`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function assertFrameworkStringsComplete(locales: Record<string, LocaleCatalog>): void {
|
|
99
|
+
for (const [code, catalog] of Object.entries(locales)) {
|
|
100
|
+
if (code.split("-")[0].toLowerCase() === "en") continue;
|
|
101
|
+
const missing = Object.keys(DEFAULT_STRINGS).filter(
|
|
102
|
+
(key) =>
|
|
103
|
+
typeof catalog.strings?.[key as keyof TerpStrings] !== "string" ||
|
|
104
|
+
catalog.strings[key as keyof TerpStrings]?.trim() === "",
|
|
105
|
+
);
|
|
106
|
+
if (missing.length > 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Locale "${code}" is missing ${missing.length} framework string translation(s) ` +
|
|
109
|
+
`(for example: ${missing.slice(0, 3).join(", ")}). ` +
|
|
110
|
+
"Pass a complete framework catalog; app messages alone do not translate the shell.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Merge app message catalogs with react-core's framework-string catalogs. This keeps one
|
|
118
|
+
* checked-in `i18n.json` authoritative for app copy without duplicating LOCALE_EN/LOCALE_NL.
|
|
119
|
+
*/
|
|
120
|
+
export function defineAppLocales(
|
|
121
|
+
declaration: AppI18nDeclaration,
|
|
122
|
+
frameworkLocales: Record<string, LocaleCatalog> = {},
|
|
123
|
+
): Record<string, LocaleCatalog> {
|
|
124
|
+
if (!isRecord(declaration) || typeof declaration.sourceLocale !== "string") {
|
|
125
|
+
throw new Error("frontend/i18n.json must declare sourceLocale and a locales map.");
|
|
126
|
+
}
|
|
127
|
+
assertLocaleCatalogs(declaration.locales, declaration.sourceLocale);
|
|
128
|
+
if (!isRecord(frameworkLocales)) {
|
|
129
|
+
throw new Error("Framework locale catalogs must be an object.");
|
|
130
|
+
}
|
|
131
|
+
if (Object.keys(frameworkLocales).length > 0) {
|
|
132
|
+
assertLocaleCatalogs(frameworkLocales);
|
|
133
|
+
}
|
|
134
|
+
const merged = Object.fromEntries(
|
|
135
|
+
Object.entries(declaration.locales).map(([code, app]) => {
|
|
136
|
+
const framework = frameworkLocales[code] ?? {};
|
|
137
|
+
return [
|
|
138
|
+
code,
|
|
139
|
+
{
|
|
140
|
+
...framework,
|
|
141
|
+
...app,
|
|
142
|
+
strings: { ...framework.strings, ...app.strings },
|
|
143
|
+
messages: { ...framework.messages, ...app.messages },
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
assertLocaleCatalogs(merged, declaration.sourceLocale);
|
|
149
|
+
assertFrameworkStringsComplete(merged);
|
|
150
|
+
return merged;
|
|
19
151
|
}
|
|
20
152
|
|
|
21
153
|
/** The built-in English catalog — the bundled defaults, no overrides needed. */
|
|
@@ -29,6 +161,15 @@ export const LOCALE_EN: LocaleCatalog = { label: "English" };
|
|
|
29
161
|
export const LOCALE_NL: LocaleCatalog = {
|
|
30
162
|
label: "Nederlands",
|
|
31
163
|
strings: {
|
|
164
|
+
clearSelection: "Selectie wissen",
|
|
165
|
+
clearAllSelections: "Alle selecties wissen",
|
|
166
|
+
comboboxRemove: "Verwijderen",
|
|
167
|
+
comboboxLoading: "Laden…",
|
|
168
|
+
comboboxNoOptions: "Geen opties",
|
|
169
|
+
previousMonth: "Vorige maand",
|
|
170
|
+
selectDate: "Kies een datum",
|
|
171
|
+
selectDateRange: "Kies een periode",
|
|
172
|
+
nextMonth: "Volgende maand",
|
|
32
173
|
loading: "Laden...",
|
|
33
174
|
emptyList: "Nog niets te zien.",
|
|
34
175
|
add: "Toevoegen",
|
|
@@ -142,6 +283,8 @@ export interface LocaleProviderProps {
|
|
|
142
283
|
locales: Record<string, LocaleCatalog>;
|
|
143
284
|
/** Starting locale when the user has not chosen one; default: the first key. */
|
|
144
285
|
defaultLocale?: string;
|
|
286
|
+
/** Locale whose descriptor `message` is the authored fallback; default: the first key. */
|
|
287
|
+
sourceLocale?: string;
|
|
145
288
|
children: ReactNode;
|
|
146
289
|
}
|
|
147
290
|
|
|
@@ -151,11 +294,20 @@ export interface LocaleProviderProps {
|
|
|
151
294
|
* `UiText` context — so every react-core component (and every `UiText` prop) follows the
|
|
152
295
|
* switch with no per-component wiring. Adding a language to an app is one catalog entry.
|
|
153
296
|
*/
|
|
154
|
-
export function LocaleProvider({
|
|
297
|
+
export function LocaleProvider({
|
|
298
|
+
locales,
|
|
299
|
+
defaultLocale,
|
|
300
|
+
sourceLocale,
|
|
301
|
+
children,
|
|
302
|
+
}: LocaleProviderProps) {
|
|
303
|
+
assertLocaleCatalogs(locales, sourceLocale);
|
|
304
|
+
assertFrameworkStringsComplete(locales);
|
|
155
305
|
const codes = Object.keys(locales);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
306
|
+
if (defaultLocale !== undefined && !codes.includes(defaultLocale)) {
|
|
307
|
+
throw new Error(`Default locale "${defaultLocale}" is not present in the locale catalogs.`);
|
|
308
|
+
}
|
|
309
|
+
const resolvedSourceLocale = sourceLocale ?? codes[0];
|
|
310
|
+
const fallback = defaultLocale ?? codes[0];
|
|
159
311
|
const [locale, setLocaleState] = useState<string>(() => {
|
|
160
312
|
try {
|
|
161
313
|
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
@@ -164,6 +316,7 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
|
|
|
164
316
|
return fallback ?? "en";
|
|
165
317
|
}
|
|
166
318
|
});
|
|
319
|
+
const activeLocale = codes.includes(locale) ? locale : fallback;
|
|
167
320
|
|
|
168
321
|
const setLocale = useCallback(
|
|
169
322
|
(next: string) => {
|
|
@@ -182,17 +335,49 @@ export function LocaleProvider({ locales, defaultLocale, children }: LocaleProvi
|
|
|
182
335
|
|
|
183
336
|
const value = useMemo<LocaleContextValue>(
|
|
184
337
|
() => ({
|
|
185
|
-
locale,
|
|
338
|
+
locale: activeLocale,
|
|
186
339
|
locales: codes,
|
|
187
340
|
labelOf: (code) => locales[code]?.label ?? code,
|
|
188
341
|
setLocale,
|
|
189
342
|
}),
|
|
190
|
-
[
|
|
343
|
+
[activeLocale, codes.join("\u0000"), setLocale, locales],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const resolveText = useCallback(
|
|
347
|
+
(text: UiText): string => {
|
|
348
|
+
if (typeof text === "string") {
|
|
349
|
+
return text;
|
|
350
|
+
}
|
|
351
|
+
if (text.id.trim() === "" || text.message.trim() === "") {
|
|
352
|
+
throw new Error("UiText descriptors require non-empty id and message values.");
|
|
353
|
+
}
|
|
354
|
+
if (activeLocale === resolvedSourceLocale) {
|
|
355
|
+
return text.message;
|
|
356
|
+
}
|
|
357
|
+
const catalog = locales[activeLocale];
|
|
358
|
+
const translated = catalog?.messages?.[text.id];
|
|
359
|
+
if (typeof translated === "string" && translated.trim() !== "") {
|
|
360
|
+
if (translated === text.message && !catalog.allowIdentical?.includes(text.id)) {
|
|
361
|
+
throw new Error(
|
|
362
|
+
`Translation "${text.id}" for locale "${activeLocale}" copies its source text. ` +
|
|
363
|
+
"Translate it or document an intentional proper noun/acronym in allowIdentical.",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return translated;
|
|
367
|
+
}
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Missing translation "${text.id}" for locale "${activeLocale}". ` +
|
|
370
|
+
"Add it to frontend/i18n.json and run the frontend lint gate.",
|
|
371
|
+
);
|
|
372
|
+
},
|
|
373
|
+
[activeLocale, locales, resolvedSourceLocale],
|
|
191
374
|
);
|
|
192
375
|
|
|
193
376
|
return (
|
|
194
377
|
<LocaleContext.Provider value={value}>
|
|
195
|
-
<UiTextProvider strings={locales[
|
|
378
|
+
<UiTextProvider strings={locales[activeLocale]?.strings} resolveText={resolveText}>
|
|
379
|
+
{children}
|
|
380
|
+
</UiTextProvider>
|
|
196
381
|
</LocaleContext.Provider>
|
|
197
382
|
);
|
|
198
383
|
}
|
package/src/markers.test.ts
CHANGED
package/src/nav.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ModuleManifest, NavGroup, NavItem } from "@terpjs/contract";
|
|
1
|
+
import type { ModuleManifest, NavGroup, NavItem, UiText } from "@terpjs/contract";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What a manifest's declared visibility is resolved against.
|
|
@@ -81,7 +81,7 @@ export interface NavSection {
|
|
|
81
81
|
/** The declared group's id, or `null` for the default headerless group. */
|
|
82
82
|
id: string | null;
|
|
83
83
|
/** The label to render above the list, or `null` when the section renders none. */
|
|
84
|
-
label:
|
|
84
|
+
label: UiText | null;
|
|
85
85
|
items: NavItem[];
|
|
86
86
|
}
|
|
87
87
|
|
package/src/router.tsx
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "@tanstack/react-router";
|
|
15
15
|
import type { ComponentType, ReactNode } from "react";
|
|
16
16
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
-
import type { ModuleManifest, NavGroup } from "@terpjs/contract";
|
|
17
|
+
import type { ModuleManifest, NavGroup, UiText } from "@terpjs/contract";
|
|
18
18
|
|
|
19
19
|
import { AppShell } from "./AppShell";
|
|
20
20
|
import { ProfileView } from "./ProfileView";
|
|
@@ -238,7 +238,7 @@ export interface BuildAppRouterOptions {
|
|
|
238
238
|
/** Maps a manifest route's `view` id to the component that renders it. */
|
|
239
239
|
views: Record<string, ComponentType>;
|
|
240
240
|
/** App title shown in the shell's sidebar brand. */
|
|
241
|
-
title:
|
|
241
|
+
title: UiText;
|
|
242
242
|
/** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
|
|
243
243
|
logo?: ReactNode;
|
|
244
244
|
/**
|
|
@@ -461,8 +461,18 @@ export function buildAppRouter(
|
|
|
461
461
|
// render it through useNavLink), so an unstable identity is worse than a re-render: it
|
|
462
462
|
// remounts every in-app link in the tree on each navigation.
|
|
463
463
|
const renderNavLink = useCallback<NavLinkRenderer>(
|
|
464
|
+
// `activeOptions={{ exact: true }}` for the same reason the shell's `renderLink`
|
|
465
|
+
// below carries it, and it matters MORE here: this renderer is what `Breadcrumbs`
|
|
466
|
+
// and `HubCard` use, so its job is rendering ANCESTORS. Under the router's default
|
|
467
|
+
// prefix matching every ancestor link matched the URL and was marked active, which
|
|
468
|
+
// on any detail route emitted a second `aria-current="page"` — one on the crumb for
|
|
469
|
+
// `/definitions` and one on the current crumb — plus a stray `.active` class and
|
|
470
|
+
// `data-status="active"` that made an ancestor look like the current page. The fix
|
|
471
|
+
// was applied to the nav and missed on the component whose whole purpose is the
|
|
472
|
+
// trail; exact matching means a crumb is only ever current when it IS the URL, and
|
|
473
|
+
// the current crumb is a span rather than a link.
|
|
464
474
|
({ to, children, attributes }) => (
|
|
465
|
-
<Link to={to} {...attributes}>
|
|
475
|
+
<Link to={to} activeOptions={{ exact: true }} {...attributes}>
|
|
466
476
|
{children}
|
|
467
477
|
</Link>
|
|
468
478
|
),
|
package/src/sso.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TerpClient, TerpClientFor } from "@terpjs/contract";
|
|
1
|
+
import type { TerpClient, TerpClientFor, UiText } from "@terpjs/contract";
|
|
2
2
|
|
|
3
3
|
import { unwrap } from "./unwrap";
|
|
4
4
|
|
|
@@ -61,7 +61,7 @@ export interface SsoProvider {
|
|
|
61
61
|
/** Provider name as mounted on the backend (the `{provider}` path segment). */
|
|
62
62
|
name: string;
|
|
63
63
|
/** Display label for the provider button (defaults to the name). */
|
|
64
|
-
label?:
|
|
64
|
+
label?: UiText;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Default SPA path prefix the IdP redirects back to: `/auth/callback/{provider}`. */
|