@object-ui/i18n 17.5.0 → 17.6.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/CHANGELOG.md +634 -0
- package/README.md +42 -0
- package/dist/i18n.d.ts +1 -1
- package/dist/i18n.js +1 -2
- package/dist/index.d.ts +23 -23
- package/dist/index.js +23 -23
- package/dist/locales/ar.d.ts +172 -262
- package/dist/locales/ar.js +187 -273
- package/dist/locales/de.d.ts +172 -262
- package/dist/locales/de.js +187 -273
- package/dist/locales/en.d.ts +173 -263
- package/dist/locales/en.js +223 -263
- package/dist/locales/es.d.ts +172 -262
- package/dist/locales/es.js +188 -274
- package/dist/locales/fr.d.ts +172 -262
- package/dist/locales/fr.js +187 -273
- package/dist/locales/index.d.ts +1740 -2640
- package/dist/locales/index.js +20 -20
- package/dist/locales/ja.d.ts +172 -262
- package/dist/locales/ja.js +189 -273
- package/dist/locales/ko.d.ts +172 -262
- package/dist/locales/ko.js +187 -273
- package/dist/locales/pt.d.ts +172 -262
- package/dist/locales/pt.js +187 -273
- package/dist/locales/ru.d.ts +172 -262
- package/dist/locales/ru.js +189 -273
- package/dist/locales/zh.d.ts +173 -263
- package/dist/locales/zh.js +180 -263
- package/dist/pickLocalized.d.ts +37 -0
- package/dist/pickLocalized.js +75 -0
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +2 -2
- package/dist/useDisplayLocale.js +2 -2
- package/dist/useObjectLabel.d.ts +86 -12
- package/dist/useObjectLabel.js +106 -13
- package/dist/useSafeTranslation.js +1 -1
- package/dist/utils/index.d.ts +4 -4
- package/dist/utils/index.js +4 -4
- package/package.json +2 -2
package/dist/pickLocalized.d.ts
CHANGED
|
@@ -34,3 +34,40 @@
|
|
|
34
34
|
* `pickLocalized({ en: 'Pricing' }, 'constructor')` is `'Pricing'`, not `''`.
|
|
35
35
|
*/
|
|
36
36
|
export declare function pickLocalized(value: unknown, language: string | undefined | null): string;
|
|
37
|
+
/**
|
|
38
|
+
* Write `next` back into a possibly-localized value as the `language` entry,
|
|
39
|
+
* preserving every other locale — the write-side inverse of
|
|
40
|
+
* {@link pickLocalized}.
|
|
41
|
+
*
|
|
42
|
+
* An authoring surface that shows one locale in one text input has to answer
|
|
43
|
+
* two questions, and they are not the same question: which entry to DISPLAY
|
|
44
|
+
* (`pickLocalized`) and which entry a save REPLACES. Answering the second with
|
|
45
|
+
* "the whole value" is the data-loss shape this function exists to remove —
|
|
46
|
+
* `onChange(e.target.value)` straight back over `{ en, zh-CN }` destroys every
|
|
47
|
+
* locale the author was not looking at, silently, on the first keystroke.
|
|
48
|
+
*
|
|
49
|
+
* - Plain string / `null` / `undefined` / array input: there is no map to
|
|
50
|
+
* preserve, so the result is `next` itself. A string-valued label keeps
|
|
51
|
+
* behaving exactly as it always has.
|
|
52
|
+
* - Inline locale map: the result is the same map with exactly one entry
|
|
53
|
+
* replaced (or added — see {@link localeWriteKey}). Non-string entries and
|
|
54
|
+
* unrelated locales are carried across untouched.
|
|
55
|
+
*
|
|
56
|
+
* The pairing with `pickLocalized` is the invariant that matters and it is
|
|
57
|
+
* pinned in `__tests__/setLocalized.test.ts`: for any map and any language,
|
|
58
|
+
* `pickLocalized(setLocalized(map, lang, s), lang) === s`. If the two functions
|
|
59
|
+
* ever drift apart, an edit lands in an entry the panel does not display and
|
|
60
|
+
* the "saved" string vanishes — which is why they live in one file.
|
|
61
|
+
*
|
|
62
|
+
* ⚠️ This is the minimal non-destructive write for a SINGLE-locale editor. It
|
|
63
|
+
* is not a multi-locale authoring UI and does not pretend to be: an author can
|
|
64
|
+
* only ever reach the entry for the locale they are in.
|
|
65
|
+
*
|
|
66
|
+
* Authoring every locale from one surface remains an OPEN product question, and
|
|
67
|
+
* is deliberately not deferred to a tracker here: the deferral this replaced
|
|
68
|
+
* named objectui#4163, which closed as completed on 2026-08-15 with the question
|
|
69
|
+
* still unanswered. A comment pointing at a closed card reads as though the
|
|
70
|
+
* question were settled somewhere, which is how the previous stale premise in
|
|
71
|
+
* this area survived unread.
|
|
72
|
+
*/
|
|
73
|
+
export declare function setLocalized(value: unknown, language: string | undefined | null, next: string): string | Record<string, unknown>;
|
package/dist/pickLocalized.js
CHANGED
|
@@ -69,3 +69,78 @@ export function pickLocalized(value, language) {
|
|
|
69
69
|
}
|
|
70
70
|
return String(value);
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The map key an edit made in `language` must be WRITTEN to — the write-side
|
|
74
|
+
* twin of {@link pickLocalized}'s read limbs.
|
|
75
|
+
*
|
|
76
|
+
* It deliberately implements only the first three limbs (exact tag, base
|
|
77
|
+
* language, region-qualified sibling) and then STOPS. `pickLocalized` continues
|
|
78
|
+
* into `default` -> `en` -> first value, and those last three limbs must never
|
|
79
|
+
* be write targets: they are DISPLAY fallbacks that hand back *another*
|
|
80
|
+
* locale's string. Following them would mean an author editing in `fr`, shown
|
|
81
|
+
* the `en` entry because the map has no French, saves and overwrites English.
|
|
82
|
+
* When no entry for the active locale exists the edit ADDS one under the active
|
|
83
|
+
* tag and every stored entry survives untouched.
|
|
84
|
+
*
|
|
85
|
+
* The first three limbs are followed exactly because they identify the entry
|
|
86
|
+
* the author was actually looking at. Writing `zh` when the map stores `zh-CN`
|
|
87
|
+
* would leave the edited-away `zh-CN` string in place — invisible in the panel
|
|
88
|
+
* that just "changed" it, still live for any consumer that asks for `zh-CN`.
|
|
89
|
+
*
|
|
90
|
+
* Own properties only, `string` values only, for the same reason every
|
|
91
|
+
* `pickLocalized` limb narrowed that way in objectui#3907: a locale tag that
|
|
92
|
+
* names an `Object.prototype` member is not an entry, so it cannot be the entry
|
|
93
|
+
* the author edited.
|
|
94
|
+
*/
|
|
95
|
+
function localeWriteKey(o, language) {
|
|
96
|
+
const lang = (language || 'en').trim() || 'en';
|
|
97
|
+
const base = lang.split('-')[0];
|
|
98
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(o, key) && typeof o[key] === 'string';
|
|
99
|
+
if (has(lang))
|
|
100
|
+
return lang;
|
|
101
|
+
if (has(base))
|
|
102
|
+
return base;
|
|
103
|
+
return Object.keys(o).find((k) => k.split('-')[0] === base && has(k)) ?? lang;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Write `next` back into a possibly-localized value as the `language` entry,
|
|
107
|
+
* preserving every other locale — the write-side inverse of
|
|
108
|
+
* {@link pickLocalized}.
|
|
109
|
+
*
|
|
110
|
+
* An authoring surface that shows one locale in one text input has to answer
|
|
111
|
+
* two questions, and they are not the same question: which entry to DISPLAY
|
|
112
|
+
* (`pickLocalized`) and which entry a save REPLACES. Answering the second with
|
|
113
|
+
* "the whole value" is the data-loss shape this function exists to remove —
|
|
114
|
+
* `onChange(e.target.value)` straight back over `{ en, zh-CN }` destroys every
|
|
115
|
+
* locale the author was not looking at, silently, on the first keystroke.
|
|
116
|
+
*
|
|
117
|
+
* - Plain string / `null` / `undefined` / array input: there is no map to
|
|
118
|
+
* preserve, so the result is `next` itself. A string-valued label keeps
|
|
119
|
+
* behaving exactly as it always has.
|
|
120
|
+
* - Inline locale map: the result is the same map with exactly one entry
|
|
121
|
+
* replaced (or added — see {@link localeWriteKey}). Non-string entries and
|
|
122
|
+
* unrelated locales are carried across untouched.
|
|
123
|
+
*
|
|
124
|
+
* The pairing with `pickLocalized` is the invariant that matters and it is
|
|
125
|
+
* pinned in `__tests__/setLocalized.test.ts`: for any map and any language,
|
|
126
|
+
* `pickLocalized(setLocalized(map, lang, s), lang) === s`. If the two functions
|
|
127
|
+
* ever drift apart, an edit lands in an entry the panel does not display and
|
|
128
|
+
* the "saved" string vanishes — which is why they live in one file.
|
|
129
|
+
*
|
|
130
|
+
* ⚠️ This is the minimal non-destructive write for a SINGLE-locale editor. It
|
|
131
|
+
* is not a multi-locale authoring UI and does not pretend to be: an author can
|
|
132
|
+
* only ever reach the entry for the locale they are in.
|
|
133
|
+
*
|
|
134
|
+
* Authoring every locale from one surface remains an OPEN product question, and
|
|
135
|
+
* is deliberately not deferred to a tracker here: the deferral this replaced
|
|
136
|
+
* named objectui#4163, which closed as completed on 2026-08-15 with the question
|
|
137
|
+
* still unanswered. A comment pointing at a closed card reads as though the
|
|
138
|
+
* question were settled somewhere, which is how the previous stale premise in
|
|
139
|
+
* this area survived unread.
|
|
140
|
+
*/
|
|
141
|
+
export function setLocalized(value, language, next) {
|
|
142
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value))
|
|
143
|
+
return next;
|
|
144
|
+
const o = value;
|
|
145
|
+
return { ...o, [localeWriteKey(o, language)]: next };
|
|
146
|
+
}
|
package/dist/provider.d.ts
CHANGED
package/dist/provider.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
|
7
7
|
import { I18nextProvider, useTranslation } from 'react-i18next';
|
|
8
|
-
import { createI18n, getDirection } from './i18n';
|
|
9
|
-
import { builtInLocales } from './locales/index';
|
|
8
|
+
import { createI18n, getDirection } from './i18n.js';
|
|
9
|
+
import { builtInLocales } from './locales/index.js';
|
|
10
10
|
/**
|
|
11
11
|
* `localStorage` key holding the user's explicit language choice.
|
|
12
12
|
*
|
package/dist/useDisplayLocale.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* This source code is licensed under the MIT license found in the
|
|
6
6
|
* LICENSE file in the root directory of this source tree.
|
|
7
7
|
*/
|
|
8
|
-
import { useLocalization } from './LocalizationContext';
|
|
9
|
-
import { useObjectTranslation } from './provider';
|
|
8
|
+
import { useLocalization } from './LocalizationContext.js';
|
|
9
|
+
import { useObjectTranslation } from './provider.js';
|
|
10
10
|
/**
|
|
11
11
|
* `useDisplayLocale` — the BCP-47 tag that field / cell / metric renderers
|
|
12
12
|
* format numbers and other `Intl` values with.
|
package/dist/useObjectLabel.d.ts
CHANGED
|
@@ -88,13 +88,27 @@ export declare function useObjectLabel(): {
|
|
|
88
88
|
description?: string;
|
|
89
89
|
}) => string | undefined;
|
|
90
90
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
91
|
+
* Read `{ns}.apps.{appName}.navigation.{groupId}.label` out of the loaded
|
|
92
|
+
* CLIENT translation resources, falling back to `fallback`.
|
|
93
93
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
94
|
+
* ⚠️ This does NOT localize the sidebar, and adding a translation pack
|
|
95
|
+
* keyed this way will not change a single rendered nav label.
|
|
96
|
+
* **App-navigation localization is owned solely by the server-side
|
|
97
|
+
* `/meta` boundary**: `translateApp` in `@objectstack/spec`
|
|
98
|
+
* (`src/system/i18n-resolver.ts`) rewrites every navigation node's
|
|
99
|
+
* `label` by id, and `@objectstack/rest` applies it before the metadata
|
|
100
|
+
* reaches this client — so nav labels arrive already localized. One
|
|
101
|
+
* owner, not two. To translate a sidebar group, translate it there.
|
|
102
|
+
*
|
|
103
|
+
* History (objectui#5197): until then this docstring promised
|
|
104
|
+
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
105
|
+
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
106
|
+
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
107
|
+
* node's authored label against its own `id` (`Workspace` vs
|
|
108
|
+
* `grp_workspace`), which never match, so the guard was true for every
|
|
109
|
+
* real entry. The props are gone; the promise was false, not merely
|
|
110
|
+
* unused. This helper is kept as a plain key reader for a consumer that
|
|
111
|
+
* renders navigation itself — it has no first-party caller.
|
|
98
112
|
*/
|
|
99
113
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
100
114
|
/**
|
|
@@ -133,6 +147,29 @@ export declare function useObjectLabel(): {
|
|
|
133
147
|
* Returns undefined when neither metadata nor translation provides one.
|
|
134
148
|
*/
|
|
135
149
|
widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
152
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
153
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
154
|
+
*
|
|
155
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
156
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
157
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
158
|
+
* value, which is authored as `widget.options.description` — and the
|
|
159
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
160
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
161
|
+
* translation entry silently retarget the other field on any widget type
|
|
162
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
163
|
+
* type except the self-contained `metric`).
|
|
164
|
+
*
|
|
165
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
166
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
167
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
168
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
169
|
+
* through `/meta` and a document translated here land on the same string —
|
|
170
|
+
* this is the client half of one convention, not a second dialect.
|
|
171
|
+
*/
|
|
172
|
+
widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
136
173
|
/**
|
|
137
174
|
* Resolve translated page label, falling back to pageDef.label.
|
|
138
175
|
* Convention: `{ns}.pages.{pageName}.label`.
|
|
@@ -311,13 +348,27 @@ export declare function useSafeFieldLabel(): {
|
|
|
311
348
|
description?: string;
|
|
312
349
|
}) => string | undefined;
|
|
313
350
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
351
|
+
* Read `{ns}.apps.{appName}.navigation.{groupId}.label` out of the loaded
|
|
352
|
+
* CLIENT translation resources, falling back to `fallback`.
|
|
316
353
|
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
354
|
+
* ⚠️ This does NOT localize the sidebar, and adding a translation pack
|
|
355
|
+
* keyed this way will not change a single rendered nav label.
|
|
356
|
+
* **App-navigation localization is owned solely by the server-side
|
|
357
|
+
* `/meta` boundary**: `translateApp` in `@objectstack/spec`
|
|
358
|
+
* (`src/system/i18n-resolver.ts`) rewrites every navigation node's
|
|
359
|
+
* `label` by id, and `@objectstack/rest` applies it before the metadata
|
|
360
|
+
* reaches this client — so nav labels arrive already localized. One
|
|
361
|
+
* owner, not two. To translate a sidebar group, translate it there.
|
|
362
|
+
*
|
|
363
|
+
* History (objectui#5197): until then this docstring promised
|
|
364
|
+
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
365
|
+
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
366
|
+
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
367
|
+
* node's authored label against its own `id` (`Workspace` vs
|
|
368
|
+
* `grp_workspace`), which never match, so the guard was true for every
|
|
369
|
+
* real entry. The props are gone; the promise was false, not merely
|
|
370
|
+
* unused. This helper is kept as a plain key reader for a consumer that
|
|
371
|
+
* renders navigation itself — it has no first-party caller.
|
|
321
372
|
*/
|
|
322
373
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
323
374
|
/**
|
|
@@ -356,6 +407,29 @@ export declare function useSafeFieldLabel(): {
|
|
|
356
407
|
* Returns undefined when neither metadata nor translation provides one.
|
|
357
408
|
*/
|
|
358
409
|
widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
410
|
+
/**
|
|
411
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
412
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
413
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
414
|
+
*
|
|
415
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
416
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
417
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
418
|
+
* value, which is authored as `widget.options.description` — and the
|
|
419
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
420
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
421
|
+
* translation entry silently retarget the other field on any widget type
|
|
422
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
423
|
+
* type except the self-contained `metric`).
|
|
424
|
+
*
|
|
425
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
426
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
427
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
428
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
429
|
+
* through `/meta` and a document translated here land on the same string —
|
|
430
|
+
* this is the client half of one convention, not a second dialect.
|
|
431
|
+
*/
|
|
432
|
+
widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
359
433
|
/**
|
|
360
434
|
* Resolve translated page label, falling back to pageDef.label.
|
|
361
435
|
* Convention: `{ns}.pages.{pageName}.label`.
|
package/dist/useObjectLabel.js
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* @module useObjectLabel
|
|
18
18
|
*/
|
|
19
19
|
import { useMemo } from 'react';
|
|
20
|
-
import { useObjectTranslation } from './provider';
|
|
21
|
-
import { I18N_PROBE_FLAG } from './i18n';
|
|
20
|
+
import { useObjectTranslation } from './provider.js';
|
|
21
|
+
import { I18N_PROBE_FLAG } from './i18n.js';
|
|
22
22
|
/**
|
|
23
23
|
* Built-in Object UI top-level locale keys — not app namespaces.
|
|
24
24
|
* Update this set when new top-level platform translation keys are added
|
|
@@ -30,6 +30,41 @@ const BUILTIN_KEYS = new Set([
|
|
|
30
30
|
'list', 'kanban', 'chart', 'dashboard', 'configPanel',
|
|
31
31
|
'appDesigner', 'console', 'errors', 'detail',
|
|
32
32
|
]);
|
|
33
|
+
/**
|
|
34
|
+
* Whether `i18n` is a real i18next instance rather than react-i18next's
|
|
35
|
+
* no-instance placeholder.
|
|
36
|
+
*
|
|
37
|
+
* With nothing to bind to, `useTranslation` warns `NO_I18NEXT_INSTANCE` and
|
|
38
|
+
* builds its return value out of a fresh `{}` on every render (`const
|
|
39
|
+
* finalI18n = i18n || {}`, which then feeds that hook's own `useMemo` deps), so
|
|
40
|
+
* the `i18n` we receive has a new identity each render even though nothing
|
|
41
|
+
* about it can have changed.
|
|
42
|
+
*
|
|
43
|
+
* `getResourceBundle` is the probe because it is the only instance member the
|
|
44
|
+
* resolvers below ever touch — via `getAppNamespaces`, the single gate every
|
|
45
|
+
* `t()` call in this module sits behind.
|
|
46
|
+
*/
|
|
47
|
+
function hasUsableI18nInstance(i18n) {
|
|
48
|
+
return Boolean(i18n) && typeof i18n?.getResourceBundle === 'function';
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Stand-ins pinned into the memo dependency list while no i18next instance is
|
|
52
|
+
* bound, so the memo in {@link useObjectLabel} actually holds on the
|
|
53
|
+
* no-provider path (objectui#5564). Module-level, so they carry one identity
|
|
54
|
+
* for the lifetime of the process instead of one per render.
|
|
55
|
+
*
|
|
56
|
+
* Substituting them is unobservable rather than merely convenient: every `t()`
|
|
57
|
+
* call in this module sits inside a `for (… of getAppNamespaces())` loop, and
|
|
58
|
+
* `getAppNamespaces()` returns `[]` under exactly {@link hasUsableI18nInstance}
|
|
59
|
+
* — so for as long as the substitution is in effect, the closures cannot read
|
|
60
|
+
* either value at all. `NO_INSTANCE_T` still mirrors react-i18next's own
|
|
61
|
+
* not-ready `t` (honour a string `defaultValue`, otherwise echo the key) so
|
|
62
|
+
* that if that reachability argument ever stops holding, behaviour does not
|
|
63
|
+
* change silently.
|
|
64
|
+
*/
|
|
65
|
+
const NO_INSTANCE_T = ((key, options) => typeof options?.defaultValue === 'string' ? options.defaultValue : key);
|
|
66
|
+
/** @see NO_INSTANCE_T */
|
|
67
|
+
const NO_INSTANCE_I18N = Object.freeze({});
|
|
33
68
|
/**
|
|
34
69
|
* Hook for convention-based auto-resolution of object and field labels.
|
|
35
70
|
*
|
|
@@ -46,7 +81,21 @@ const BUILTIN_KEYS = new Set([
|
|
|
46
81
|
* ```
|
|
47
82
|
*/
|
|
48
83
|
export function useObjectLabel() {
|
|
49
|
-
const { t, i18n } = useObjectTranslation();
|
|
84
|
+
const { t: boundT, i18n: boundI18n } = useObjectTranslation();
|
|
85
|
+
// Pin both memo dependencies to module constants while there is no i18next
|
|
86
|
+
// instance to bind to. Without this the memo below never held on exactly the
|
|
87
|
+
// no-provider path `useSafeFieldLabel` advertises: react-i18next rebuilds its
|
|
88
|
+
// return value from a fresh `{}` each render, so `i18n` arrived with a new
|
|
89
|
+
// identity every time and re-keyed every consumer memo this hook feeds
|
|
90
|
+
// (objectui#5564 measured 4 distinct identities across 4 renders). See
|
|
91
|
+
// `hasUsableI18nInstance` for why the substitution cannot be observed.
|
|
92
|
+
//
|
|
93
|
+
// When an instance does appear these become the live values again, so a
|
|
94
|
+
// provider mounting after first render recomputes the object exactly once and
|
|
95
|
+
// resolves real translations from then on.
|
|
96
|
+
const bound = hasUsableI18nInstance(boundI18n);
|
|
97
|
+
const t = bound ? boundT : NO_INSTANCE_T;
|
|
98
|
+
const i18n = bound ? boundI18n : NO_INSTANCE_I18N;
|
|
50
99
|
// Memoize the entire returned object — all closures below reference `t`/`i18n`
|
|
51
100
|
// and stay valid until the language changes. Returning a fresh object on every
|
|
52
101
|
// render was busting downstream `useMemo`/`useCallback` deps in heavy consumers
|
|
@@ -64,7 +113,7 @@ export function useObjectLabel() {
|
|
|
64
113
|
* (objectui#3372).
|
|
65
114
|
*/
|
|
66
115
|
const getAppNamespaces = () => {
|
|
67
|
-
if (!i18n
|
|
116
|
+
if (!hasUsableI18nInstance(i18n))
|
|
68
117
|
return [];
|
|
69
118
|
const lang = i18n.language || 'en';
|
|
70
119
|
const bundle = i18n.getResourceBundle(lang, 'translation');
|
|
@@ -267,13 +316,27 @@ export function useObjectLabel() {
|
|
|
267
316
|
return resolved || undefined;
|
|
268
317
|
},
|
|
269
318
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
319
|
+
* Read `{ns}.apps.{appName}.navigation.{groupId}.label` out of the loaded
|
|
320
|
+
* CLIENT translation resources, falling back to `fallback`.
|
|
321
|
+
*
|
|
322
|
+
* ⚠️ This does NOT localize the sidebar, and adding a translation pack
|
|
323
|
+
* keyed this way will not change a single rendered nav label.
|
|
324
|
+
* **App-navigation localization is owned solely by the server-side
|
|
325
|
+
* `/meta` boundary**: `translateApp` in `@objectstack/spec`
|
|
326
|
+
* (`src/system/i18n-resolver.ts`) rewrites every navigation node's
|
|
327
|
+
* `label` by id, and `@objectstack/rest` applies it before the metadata
|
|
328
|
+
* reaches this client — so nav labels arrive already localized. One
|
|
329
|
+
* owner, not two. To translate a sidebar group, translate it there.
|
|
272
330
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
331
|
+
* History (objectui#5197): until then this docstring promised
|
|
332
|
+
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
333
|
+
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
334
|
+
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
335
|
+
* node's authored label against its own `id` (`Workspace` vs
|
|
336
|
+
* `grp_workspace`), which never match, so the guard was true for every
|
|
337
|
+
* real entry. The props are gone; the promise was false, not merely
|
|
338
|
+
* unused. This helper is kept as a plain key reader for a consumer that
|
|
339
|
+
* renders navigation itself — it has no first-party caller.
|
|
277
340
|
*/
|
|
278
341
|
navGroupLabel: (appName, groupId, fallback) => resolve(`apps.${appName}.navigation.${groupId}.label`, fallback),
|
|
279
342
|
/**
|
|
@@ -314,6 +377,33 @@ export function useObjectLabel() {
|
|
|
314
377
|
const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.description`), fb);
|
|
315
378
|
return resolved || undefined;
|
|
316
379
|
},
|
|
380
|
+
/**
|
|
381
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
382
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
383
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
384
|
+
*
|
|
385
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
386
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
387
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
388
|
+
* value, which is authored as `widget.options.description` — and the
|
|
389
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
390
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
391
|
+
* translation entry silently retarget the other field on any widget type
|
|
392
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
393
|
+
* type except the self-contained `metric`).
|
|
394
|
+
*
|
|
395
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
396
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
397
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
398
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
399
|
+
* through `/meta` and a document translated here land on the same string —
|
|
400
|
+
* this is the client half of one convention, not a second dialect.
|
|
401
|
+
*/
|
|
402
|
+
widgetSubCaption: (dashboardName, widgetId, fallback) => {
|
|
403
|
+
const fb = fallback ?? '';
|
|
404
|
+
const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.subCaption`), fb);
|
|
405
|
+
return resolved || undefined;
|
|
406
|
+
},
|
|
317
407
|
/**
|
|
318
408
|
* Resolve translated page label, falling back to pageDef.label.
|
|
319
409
|
* Convention: `{ns}.pages.{pageName}.label`.
|
|
@@ -511,8 +601,11 @@ const SAFE_FIELD_LABEL_FALLBACK = {
|
|
|
511
601
|
export function useSafeFieldLabel() {
|
|
512
602
|
// useObjectLabel delegates to the provider-safe useObjectTranslation (react-
|
|
513
603
|
// i18next falls back to the global instance and never throws), so it needs no
|
|
514
|
-
// try/catch — wrapping the hook call would violate rules-of-hooks.
|
|
515
|
-
//
|
|
516
|
-
//
|
|
604
|
+
// try/catch — wrapping the hook call would violate rules-of-hooks. Its memo
|
|
605
|
+
// now holds with or without an i18next instance, so the object below is
|
|
606
|
+
// stable on both paths — it was NOT before objectui#5564, which measured a
|
|
607
|
+
// fresh object every render outside a provider, i.e. the opposite of what
|
|
608
|
+
// this wrapper advertises. The module-level fallback stays as a defensive
|
|
609
|
+
// default, reached only if it ever returns nullish.
|
|
517
610
|
return useObjectLabel() ?? SAFE_FIELD_LABEL_FALLBACK;
|
|
518
611
|
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* @param defaults - Fallback English translations keyed by i18n key
|
|
13
13
|
* @param testKey - A key to test if i18n is properly configured (must be in defaults)
|
|
14
14
|
*/
|
|
15
|
-
import { useObjectTranslation } from './provider';
|
|
15
|
+
import { useObjectTranslation } from './provider.js';
|
|
16
16
|
/**
|
|
17
17
|
* The i18next option that names the string to use when the lookup misses.
|
|
18
18
|
*
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, type DateFormatOptions, type CurrencyFormatOptions, type NumberFormatOptions, } from './formatting';
|
|
2
|
-
export { resolvePlural, formatDateSpec, formatNumberSpec, applyLocaleConfig, type SpecPluralRule, type SpecDateFormat, type SpecNumberFormat, type SpecLocaleConfig, } from './spec-formatters';
|
|
3
|
-
export { isSpecTranslationData, transformSpecTranslations, type SpecTranslationData, } from './spec-translations';
|
|
4
|
-
export { formatDisplayNumber, shouldGroupDisplayNumber, type DisplayNumberFormatOptions, } from './number-display';
|
|
1
|
+
export { formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, type DateFormatOptions, type CurrencyFormatOptions, type NumberFormatOptions, } from './formatting.js';
|
|
2
|
+
export { resolvePlural, formatDateSpec, formatNumberSpec, applyLocaleConfig, type SpecPluralRule, type SpecDateFormat, type SpecNumberFormat, type SpecLocaleConfig, } from './spec-formatters.js';
|
|
3
|
+
export { isSpecTranslationData, transformSpecTranslations, type SpecTranslationData, } from './spec-translations.js';
|
|
4
|
+
export { formatDisplayNumber, shouldGroupDisplayNumber, type DisplayNumberFormatOptions, } from './number-display.js';
|
package/dist/utils/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, } from './formatting';
|
|
2
|
-
export { resolvePlural, formatDateSpec, formatNumberSpec, applyLocaleConfig, } from './spec-formatters';
|
|
3
|
-
export { isSpecTranslationData, transformSpecTranslations, } from './spec-translations';
|
|
4
|
-
export { formatDisplayNumber, shouldGroupDisplayNumber, } from './number-display';
|
|
1
|
+
export { formatDate, formatDateTime, formatRelativeTime, formatCurrency, formatNumber, } from './formatting.js';
|
|
2
|
+
export { resolvePlural, formatDateSpec, formatNumberSpec, applyLocaleConfig, } from './spec-formatters.js';
|
|
3
|
+
export { isSpecTranslationData, transformSpecTranslations, } from './spec-translations.js';
|
|
4
|
+
export { formatDisplayNumber, shouldGroupDisplayNumber, } from './number-display.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/i18n",
|
|
3
|
-
"version": "17.
|
|
3
|
+
"version": "17.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"i18next": "^26.3.6",
|
|
34
34
|
"react-i18next": "^17.0.11",
|
|
35
|
-
"@object-ui/core": "17.
|
|
35
|
+
"@object-ui/core": "17.6.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"react": "^18.0.0 || ^19.0.0"
|