@object-ui/i18n 17.5.0 → 17.7.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 +4954 -0
- package/README.md +234 -13
- package/dist/builtinAggregateLabels.d.ts +40 -0
- package/dist/builtinAggregateLabels.js +23 -0
- package/dist/currency.d.ts +27 -3
- package/dist/currency.js +28 -4
- package/dist/fallbackInterpolation.d.ts +79 -0
- package/dist/fallbackInterpolation.js +124 -0
- package/dist/i18n.d.ts +28 -2
- package/dist/i18n.js +99 -17
- package/dist/index.d.ts +17 -23
- package/dist/index.js +51 -27
- package/dist/locales/ar.d.ts +1113 -370
- package/dist/locales/ar.js +1276 -428
- package/dist/locales/de.d.ts +785 -370
- package/dist/locales/de.js +896 -401
- package/dist/locales/en.d.ts +800 -385
- package/dist/locales/en.js +1261 -419
- package/dist/locales/es.d.ts +867 -370
- package/dist/locales/es.js +976 -398
- package/dist/locales/fr.d.ts +867 -370
- package/dist/locales/fr.js +976 -399
- package/dist/locales/index.d.ts +8682 -3761
- package/dist/locales/index.js +68 -29
- package/dist/locales/ja.d.ts +785 -370
- package/dist/locales/ja.js +891 -395
- package/dist/locales/ko.d.ts +785 -370
- package/dist/locales/ko.js +893 -399
- package/dist/locales/pt.d.ts +867 -370
- package/dist/locales/pt.js +975 -397
- package/dist/locales/registry.d.ts +102 -0
- package/dist/locales/registry.js +220 -0
- package/dist/locales/rtl.d.ts +24 -0
- package/dist/locales/rtl.js +26 -0
- package/dist/locales/ru.d.ts +949 -370
- package/dist/locales/ru.js +1093 -419
- package/dist/locales/zh.d.ts +803 -388
- package/dist/locales/zh.js +899 -388
- package/dist/pickLocalized.d.ts +67 -0
- package/dist/pickLocalized.js +114 -0
- package/dist/provider.d.ts +102 -13
- package/dist/provider.js +335 -50
- package/dist/translateFn.d.ts +29 -0
- package/dist/translateFn.js +8 -0
- package/dist/useDisplayLocale.d.ts +11 -0
- package/dist/useDisplayLocale.js +13 -2
- package/dist/useObjectLabel.d.ts +118 -22
- package/dist/useObjectLabel.js +148 -23
- package/dist/useSafeTranslation.js +41 -43
- package/dist/utils/formatting.d.ts +16 -0
- package/dist/utils/formatting.js +23 -3
- package/dist/utils/index.d.ts +4 -4
- package/dist/utils/index.js +4 -4
- package/dist/utils/spec-formatters.d.ts +13 -23
- package/dist/utils/spec-formatters.js +41 -2
- package/dist/utils/spec-translations.d.ts +16 -3
- package/dist/utils/spec-translations.js +52 -13
- package/package.json +12 -6
package/dist/useObjectLabel.d.ts
CHANGED
|
@@ -88,13 +88,29 @@ 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 (`9c60144b5`): 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, and so is the guard itself
|
|
110
|
+
* (objectui#11201: the renderer no longer matches a label's text against
|
|
111
|
+
* any name); the promise was false, not merely unused. This helper is kept
|
|
112
|
+
* as a plain key reader for a consumer that renders navigation itself — it
|
|
113
|
+
* has no first-party caller.
|
|
98
114
|
*/
|
|
99
115
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
100
116
|
/**
|
|
@@ -133,6 +149,29 @@ export declare function useObjectLabel(): {
|
|
|
133
149
|
* Returns undefined when neither metadata nor translation provides one.
|
|
134
150
|
*/
|
|
135
151
|
widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
154
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
155
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
156
|
+
*
|
|
157
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
158
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
159
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
160
|
+
* value, which is authored as `widget.options.description` — and the
|
|
161
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
162
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
163
|
+
* translation entry silently retarget the other field on any widget type
|
|
164
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
165
|
+
* type except the self-contained `metric`).
|
|
166
|
+
*
|
|
167
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
168
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
169
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
170
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
171
|
+
* through `/meta` and a document translated here land on the same string —
|
|
172
|
+
* this is the client half of one convention, not a second dialect.
|
|
173
|
+
*/
|
|
174
|
+
widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
136
175
|
/**
|
|
137
176
|
* Resolve translated page label, falling back to pageDef.label.
|
|
138
177
|
* Convention: `{ns}.pages.{pageName}.label`.
|
|
@@ -157,11 +196,6 @@ export declare function useObjectLabel(): {
|
|
|
157
196
|
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
158
197
|
*/
|
|
159
198
|
viewLabel: (objectName: string, viewName: string, fallback: string) => string;
|
|
160
|
-
/**
|
|
161
|
-
* Resolve translated list-view description.
|
|
162
|
-
* Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
|
|
163
|
-
*/
|
|
164
|
-
viewDescription: (objectName: string, viewName: string, fallback?: string) => string | undefined;
|
|
165
199
|
/**
|
|
166
200
|
* Resolve translated list-view emptyState. Returns a {title, message}
|
|
167
201
|
* tuple with each field independently translated. Convention:
|
|
@@ -204,6 +238,20 @@ export declare function useObjectLabel(): {
|
|
|
204
238
|
* falling back to `{ns}.globalActions.{actionName}.successMessage`.
|
|
205
239
|
*/
|
|
206
240
|
actionSuccess: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
|
|
241
|
+
/**
|
|
242
|
+
* Resolve the translated success copy for ONE handler outcome — an entry of
|
|
243
|
+
* the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
|
|
244
|
+
* Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
|
|
245
|
+
* falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
|
|
246
|
+
* — the address the spec's translation bundle declares for the key.
|
|
247
|
+
* Returns undefined when no translation and no fallback exist.
|
|
248
|
+
*
|
|
249
|
+
* Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
|
|
250
|
+
* bundle entry passes through untouched (i18next interpolates its own
|
|
251
|
+
* `{{…}}` form, never `${…}`), and the action runner fills it in from the
|
|
252
|
+
* handler's answer once the action has run.
|
|
253
|
+
*/
|
|
254
|
+
actionOutcome: (objectName: string | undefined, actionName: string, outcome: string, fallback?: string) => string | undefined;
|
|
207
255
|
/**
|
|
208
256
|
* Resolve translated action description (the explanatory line shown in the
|
|
209
257
|
* action's param dialog / sheet / drawer header).
|
|
@@ -311,13 +359,29 @@ export declare function useSafeFieldLabel(): {
|
|
|
311
359
|
description?: string;
|
|
312
360
|
}) => string | undefined;
|
|
313
361
|
/**
|
|
314
|
-
*
|
|
315
|
-
*
|
|
362
|
+
* Read `{ns}.apps.{appName}.navigation.{groupId}.label` out of the loaded
|
|
363
|
+
* CLIENT translation resources, falling back to `fallback`.
|
|
316
364
|
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
365
|
+
* ⚠️ This does NOT localize the sidebar, and adding a translation pack
|
|
366
|
+
* keyed this way will not change a single rendered nav label.
|
|
367
|
+
* **App-navigation localization is owned solely by the server-side
|
|
368
|
+
* `/meta` boundary**: `translateApp` in `@objectstack/spec`
|
|
369
|
+
* (`src/system/i18n-resolver.ts`) rewrites every navigation node's
|
|
370
|
+
* `label` by id, and `@objectstack/rest` applies it before the metadata
|
|
371
|
+
* reaches this client — so nav labels arrive already localized. One
|
|
372
|
+
* owner, not two. To translate a sidebar group, translate it there.
|
|
373
|
+
*
|
|
374
|
+
* History (`9c60144b5`): until then this docstring promised
|
|
375
|
+
* `"Sales" → "销售"` for sidebar groups, and `NavigationRenderer`
|
|
376
|
+
* accepted `resolveGroupLabel`/`resolveItemLabel` to wire it up. Those
|
|
377
|
+
* props could never fire — the renderer's `isCustomized` guard compared a
|
|
378
|
+
* node's authored label against its own `id` (`Workspace` vs
|
|
379
|
+
* `grp_workspace`), which never match, so the guard was true for every
|
|
380
|
+
* real entry. The props are gone, and so is the guard itself
|
|
381
|
+
* (objectui#11201: the renderer no longer matches a label's text against
|
|
382
|
+
* any name); the promise was false, not merely unused. This helper is kept
|
|
383
|
+
* as a plain key reader for a consumer that renders navigation itself — it
|
|
384
|
+
* has no first-party caller.
|
|
321
385
|
*/
|
|
322
386
|
navGroupLabel: (appName: string, groupId: string, fallback: string) => string;
|
|
323
387
|
/**
|
|
@@ -356,6 +420,29 @@ export declare function useSafeFieldLabel(): {
|
|
|
356
420
|
* Returns undefined when neither metadata nor translation provides one.
|
|
357
421
|
*/
|
|
358
422
|
widgetDescription: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
423
|
+
/**
|
|
424
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
425
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
426
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
427
|
+
*
|
|
428
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
429
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
430
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
431
|
+
* value, which is authored as `widget.options.description` — and the
|
|
432
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
433
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
434
|
+
* translation entry silently retarget the other field on any widget type
|
|
435
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
436
|
+
* type except the self-contained `metric`).
|
|
437
|
+
*
|
|
438
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
439
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
440
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
441
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
442
|
+
* through `/meta` and a document translated here land on the same string —
|
|
443
|
+
* this is the client half of one convention, not a second dialect.
|
|
444
|
+
*/
|
|
445
|
+
widgetSubCaption: (dashboardName: string, widgetId: string, fallback?: string) => string | undefined;
|
|
359
446
|
/**
|
|
360
447
|
* Resolve translated page label, falling back to pageDef.label.
|
|
361
448
|
* Convention: `{ns}.pages.{pageName}.label`.
|
|
@@ -380,11 +467,6 @@ export declare function useSafeFieldLabel(): {
|
|
|
380
467
|
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
381
468
|
*/
|
|
382
469
|
viewLabel: (objectName: string, viewName: string, fallback: string) => string;
|
|
383
|
-
/**
|
|
384
|
-
* Resolve translated list-view description.
|
|
385
|
-
* Convention: `{ns}.objects.{objectName}._views.{viewName}.description`.
|
|
386
|
-
*/
|
|
387
|
-
viewDescription: (objectName: string, viewName: string, fallback?: string) => string | undefined;
|
|
388
470
|
/**
|
|
389
471
|
* Resolve translated list-view emptyState. Returns a {title, message}
|
|
390
472
|
* tuple with each field independently translated. Convention:
|
|
@@ -427,6 +509,20 @@ export declare function useSafeFieldLabel(): {
|
|
|
427
509
|
* falling back to `{ns}.globalActions.{actionName}.successMessage`.
|
|
428
510
|
*/
|
|
429
511
|
actionSuccess: (objectName: string | undefined, actionName: string, fallback?: string) => string | undefined;
|
|
512
|
+
/**
|
|
513
|
+
* Resolve the translated success copy for ONE handler outcome — an entry of
|
|
514
|
+
* the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
|
|
515
|
+
* Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
|
|
516
|
+
* falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
|
|
517
|
+
* — the address the spec's translation bundle declares for the key.
|
|
518
|
+
* Returns undefined when no translation and no fallback exist.
|
|
519
|
+
*
|
|
520
|
+
* Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
|
|
521
|
+
* bundle entry passes through untouched (i18next interpolates its own
|
|
522
|
+
* `{{…}}` form, never `${…}`), and the action runner fills it in from the
|
|
523
|
+
* handler's answer once the action has run.
|
|
524
|
+
*/
|
|
525
|
+
actionOutcome: (objectName: string | undefined, actionName: string, outcome: string, fallback?: string) => string | undefined;
|
|
430
526
|
/**
|
|
431
527
|
* Resolve translated action description (the explanatory line shown in the
|
|
432
528
|
* action's param dialog / sheet / drawer header).
|
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');
|
|
@@ -193,8 +242,8 @@ export function useObjectLabel() {
|
|
|
193
242
|
* name under `_views` — the runtime view identity's own name, stripped of the
|
|
194
243
|
* object prefix. That single spelling is canonical per the objectstack#5164
|
|
195
244
|
* ruling A (2026-08-06): the i18n extractor asks the view composer for the key
|
|
196
|
-
* (objectstack
|
|
197
|
-
* (objectstack
|
|
245
|
+
* (objectstack `b3c1f3cd5`) and `packages/lint` enforces exactly that spelling
|
|
246
|
+
* (objectstack `7618ee814`), so this resolver accepts exactly what those produce.
|
|
198
247
|
*
|
|
199
248
|
* Deliberately NOT a second candidate: the prefixed full name
|
|
200
249
|
* (`_views.<objectName>.<viewName>`). Accepting it made this client more
|
|
@@ -267,13 +316,29 @@ 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`.
|
|
272
321
|
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
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.
|
|
330
|
+
*
|
|
331
|
+
* History (`9c60144b5`): 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, and so is the guard itself
|
|
338
|
+
* (objectui#11201: the renderer no longer matches a label's text against
|
|
339
|
+
* any name); the promise was false, not merely unused. This helper is kept
|
|
340
|
+
* as a plain key reader for a consumer that renders navigation itself — it
|
|
341
|
+
* has no first-party caller.
|
|
277
342
|
*/
|
|
278
343
|
navGroupLabel: (appName, groupId, fallback) => resolve(`apps.${appName}.navigation.${groupId}.label`, fallback),
|
|
279
344
|
/**
|
|
@@ -314,6 +379,33 @@ export function useObjectLabel() {
|
|
|
314
379
|
const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.description`), fb);
|
|
315
380
|
return resolved || undefined;
|
|
316
381
|
},
|
|
382
|
+
/**
|
|
383
|
+
* Resolve a translated metric-widget SUB-CAPTION within a dashboard.
|
|
384
|
+
* Convention: `{ns}.dashboards.{dashboardName}.widgets.{widgetId}.subCaption`.
|
|
385
|
+
* Returns undefined when neither metadata nor translation provides one.
|
|
386
|
+
*
|
|
387
|
+
* Deliberately its OWN key, not a second reader of `widgets.{id}.description`.
|
|
388
|
+
* The KPI card renders two authored strings from two different fields — the
|
|
389
|
+
* shared card header's `widget.description`, and the sub-caption under the
|
|
390
|
+
* value, which is authored as `widget.options.description` — and the
|
|
391
|
+
* objectstack#5428 item-4 ruling (2026-08-06) settled that they get two
|
|
392
|
+
* keys, not one: 「两个作者字段两个 key」. Collapsing them would make one
|
|
393
|
+
* translation entry silently retarget the other field on any widget type
|
|
394
|
+
* that renders both at once (`kpi`, `gauge`, `bullet` — every metric-family
|
|
395
|
+
* type except the self-contained `metric`).
|
|
396
|
+
*
|
|
397
|
+
* `subCaption` is the member objectstack#8056 added to the widget
|
|
398
|
+
* translation node for exactly this, shipped in `@objectstack/spec@17.0.0`.
|
|
399
|
+
* The server-side resolver reads the SAME key and overlays it onto
|
|
400
|
+
* `options.description` (`translateDashboard`), so a document served
|
|
401
|
+
* through `/meta` and a document translated here land on the same string —
|
|
402
|
+
* this is the client half of one convention, not a second dialect.
|
|
403
|
+
*/
|
|
404
|
+
widgetSubCaption: (dashboardName, widgetId, fallback) => {
|
|
405
|
+
const fb = fallback ?? '';
|
|
406
|
+
const resolved = resolve(dashboardSuffixes(dashboardName, `widgets.${widgetId}.subCaption`), fb);
|
|
407
|
+
return resolved || undefined;
|
|
408
|
+
},
|
|
317
409
|
/**
|
|
318
410
|
* Resolve translated page label, falling back to pageDef.label.
|
|
319
411
|
* Convention: `{ns}.pages.{pageName}.label`.
|
|
@@ -332,15 +424,27 @@ export function useObjectLabel() {
|
|
|
332
424
|
* that spells the prefix out does not resolve (see `viewSuffixes`).
|
|
333
425
|
*/
|
|
334
426
|
viewLabel: (objectName, viewName, fallback) => resolve(viewSuffixes(objectName, viewName, 'label'), fallback),
|
|
335
|
-
|
|
336
|
-
*
|
|
337
|
-
*
|
|
427
|
+
/*
|
|
428
|
+
* There is deliberately NO `viewDescription` member here, and no
|
|
429
|
+
* `{ns}.objects.{objectName}._views.{viewName}.description` convention for
|
|
430
|
+
* it to resolve (objectui#7219, maintainer ruling 2026-09-02, option B).
|
|
431
|
+
*
|
|
432
|
+
* A list view's description has exactly ONE channel: the `I18nLabel` value
|
|
433
|
+
* authored on the view entry -- a string or an inline locale map -- which
|
|
434
|
+
* `ObjectView` relays and the render site resolves with `pickLocalized`
|
|
435
|
+
* (objectui#7199). The catalog key used to be declared and resolved right
|
|
436
|
+
* here, between its two wired-up siblings, but had zero callers and zero
|
|
437
|
+
* in-repo bundle usage: a bundle entry written under it reached no screen.
|
|
438
|
+
*
|
|
439
|
+
* Wiring it in instead was weighed and NOT taken -- two vocabularies for
|
|
440
|
+
* one concept plus a precedence rule is the ambiguity, not the fix. Leaving
|
|
441
|
+
* it declared and unfulfilled was not taken either; the standing rule is
|
|
442
|
+
* remove, not phase out, when a surface has measured zero use.
|
|
443
|
+
*
|
|
444
|
+
* `viewSuffixes` is NOT retired with it: `viewLabel` above and
|
|
445
|
+
* `viewEmptyState` below share that helper and keep resolving. Only the
|
|
446
|
+
* `'description'` tail passed to it is gone.
|
|
338
447
|
*/
|
|
339
|
-
viewDescription: (objectName, viewName, fallback) => {
|
|
340
|
-
const fb = fallback ?? '';
|
|
341
|
-
const resolved = resolve(viewSuffixes(objectName, viewName, 'description'), fb);
|
|
342
|
-
return resolved || undefined;
|
|
343
|
-
},
|
|
344
448
|
/**
|
|
345
449
|
* Resolve translated list-view emptyState. Returns a {title, message}
|
|
346
450
|
* tuple with each field independently translated. Convention:
|
|
@@ -393,6 +497,24 @@ export function useObjectLabel() {
|
|
|
393
497
|
const resolved = resolve(actionSuffixes(objectName, actionName, 'successMessage'), fb);
|
|
394
498
|
return resolved || undefined;
|
|
395
499
|
},
|
|
500
|
+
/**
|
|
501
|
+
* Resolve the translated success copy for ONE handler outcome — an entry of
|
|
502
|
+
* the action's `outcomeMessages` (`@objectstack/spec` 17.6.0, objectui#11344).
|
|
503
|
+
* Convention: `{ns}.objects.{objectName}._actions.{actionName}.outcomeMessages.{outcome}`,
|
|
504
|
+
* falling back to `{ns}.globalActions.{actionName}.outcomeMessages.{outcome}`
|
|
505
|
+
* — the address the spec's translation bundle declares for the key.
|
|
506
|
+
* Returns undefined when no translation and no fallback exist.
|
|
507
|
+
*
|
|
508
|
+
* Like `actionSuccess`, the answer is a TEMPLATE: a `${result.*}` token in a
|
|
509
|
+
* bundle entry passes through untouched (i18next interpolates its own
|
|
510
|
+
* `{{…}}` form, never `${…}`), and the action runner fills it in from the
|
|
511
|
+
* handler's answer once the action has run.
|
|
512
|
+
*/
|
|
513
|
+
actionOutcome: (objectName, actionName, outcome, fallback) => {
|
|
514
|
+
const fb = fallback ?? '';
|
|
515
|
+
const resolved = resolve(actionSuffixes(objectName, actionName, `outcomeMessages.${outcome}`), fb);
|
|
516
|
+
return resolved || undefined;
|
|
517
|
+
},
|
|
396
518
|
/**
|
|
397
519
|
* Resolve translated action description (the explanatory line shown in the
|
|
398
520
|
* action's param dialog / sheet / drawer header).
|
|
@@ -511,8 +633,11 @@ const SAFE_FIELD_LABEL_FALLBACK = {
|
|
|
511
633
|
export function useSafeFieldLabel() {
|
|
512
634
|
// useObjectLabel delegates to the provider-safe useObjectTranslation (react-
|
|
513
635
|
// 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
|
-
//
|
|
636
|
+
// try/catch — wrapping the hook call would violate rules-of-hooks. Its memo
|
|
637
|
+
// now holds with or without an i18next instance, so the object below is
|
|
638
|
+
// stable on both paths — it was NOT before objectui#5564, which measured a
|
|
639
|
+
// fresh object every render outside a provider, i.e. the opposite of what
|
|
640
|
+
// this wrapper advertises. The module-level fallback stays as a defensive
|
|
641
|
+
// default, reached only if it ever returns nullish.
|
|
517
642
|
return useObjectLabel() ?? SAFE_FIELD_LABEL_FALLBACK;
|
|
518
643
|
}
|
|
@@ -6,21 +6,38 @@
|
|
|
6
6
|
* returning raw i18n keys — and, for a key the `defaults` map does not carry,
|
|
7
7
|
* to the call site's own inline `t(key, { defaultValue })` (objectui#3865).
|
|
8
8
|
* The lookup order is i18next's, so a provider-less host and a provider-mounted
|
|
9
|
-
* one render the same string: `defaults[
|
|
9
|
+
* one render the same string: `defaults[key_<category>]` for a numeric `count`
|
|
10
|
+
* (objectui#11445), then `defaults[key]` (the pack value's stand-in here)
|
|
10
11
|
* -> `defaultValue` -> the key.
|
|
11
12
|
*
|
|
12
13
|
* @param defaults - Fallback English translations keyed by i18n key
|
|
13
14
|
* @param testKey - A key to test if i18n is properly configured (must be in defaults)
|
|
14
15
|
*/
|
|
15
|
-
import { useObjectTranslation } from './provider';
|
|
16
|
+
import { useObjectTranslation } from './provider.js';
|
|
17
|
+
import { DEFAULT_VALUE_OPTION, interpolateFallback } from './fallbackInterpolation.js';
|
|
16
18
|
/**
|
|
17
|
-
* The
|
|
19
|
+
* The plural rules the defaults tables are written in. Every table is English
|
|
20
|
+
* (the `en` pack's stand-in), so English selects the slot.
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULTS_PLURAL_RULES = new Intl.PluralRules('en');
|
|
23
|
+
/**
|
|
24
|
+
* The `defaults` row a count family answers from, the way i18next walks a pack
|
|
25
|
+
* (objectui#11445): `key_<category>` first, then the base `key`.
|
|
18
26
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
27
|
+
* i18next plural-selects only for a NUMERIC `count` (`needsPluralHandling` in
|
|
28
|
+
* i18next 26 is `count !== undefined && !isString(count)`), so a string count
|
|
29
|
+
* reads the base row on both paths. `_zero` is not looked up: no English row
|
|
30
|
+
* spells one, because `en` selects no `zero` category.
|
|
22
31
|
*/
|
|
23
|
-
|
|
32
|
+
function defaultsRowFor(defaults, key, options) {
|
|
33
|
+
const count = options?.count;
|
|
34
|
+
if (typeof count === 'number' && Number.isFinite(count)) {
|
|
35
|
+
const slot = defaults[`${key}_${DEFAULTS_PLURAL_RULES.select(count)}`];
|
|
36
|
+
if (slot)
|
|
37
|
+
return slot;
|
|
38
|
+
}
|
|
39
|
+
return defaults[key];
|
|
40
|
+
}
|
|
24
41
|
export function createSafeTranslation(defaults, testKey) {
|
|
25
42
|
// Factory-level fallback: one stable reference per defaults map, so
|
|
26
43
|
// downstream useMemo/useCallback deps don't invalidate every render in
|
|
@@ -47,43 +64,24 @@ export function createSafeTranslation(defaults, testKey) {
|
|
|
47
64
|
// `string`, and i18next itself declines a `null` default. `||` (not `??`)
|
|
48
65
|
// keeps the whole chain consistent with the pre-existing `defaults[key] ||`
|
|
49
66
|
// step: an empty string at any position falls through to the next.
|
|
67
|
+
//
|
|
68
|
+
// A count family's suffixed rows are reached here too (objectui#11445):
|
|
69
|
+
// `t('detail.replyCount', { count: 3 })` reads `detail.replyCount_other`
|
|
70
|
+
// before the base row, exactly as i18next reads the `en` pack. Before that
|
|
71
|
+
// card a family's `_one` / `_other` rows were unreachable on this path, so
|
|
72
|
+
// the code-selected `xxxCountOne` pairs were the only way a provider-less
|
|
73
|
+
// host rendered "1 reply" — the card retired those pairs.
|
|
50
74
|
const inlineDefault = options?.[DEFAULT_VALUE_OPTION];
|
|
51
|
-
let value = defaults
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// 26.3.6) — but no value in this repo spells that hole (zero hits for
|
|
62
|
-
// `{{defaultValue` across packages/apps/examples), and splicing a
|
|
63
|
-
// fallback string into a hole named after itself has no sensible
|
|
64
|
-
// reading. The alternative — letting a call site's fallback text leak
|
|
65
|
-
// into an unrelated table value — is the worse of the two.
|
|
66
|
-
if (k === DEFAULT_VALUE_OPTION)
|
|
67
|
-
continue;
|
|
68
|
-
// `split(needle).join(value)` — deliberately not `replace`, and not
|
|
69
|
-
// `replaceAll` either (objectui#3418). This path must agree with
|
|
70
|
-
// i18next, which serves the *provider* path; any divergence is a
|
|
71
|
-
// silent fork that only shows up on provider-less hosts, where we are
|
|
72
|
-
// least likely to see it:
|
|
73
|
-
// 1. `replace` with a string needle substitutes only the FIRST
|
|
74
|
-
// occurrence. i18next substitutes every one, so a sentence that
|
|
75
|
-
// repeats a placeholder ("Selected {{count}} of {{count}} items"
|
|
76
|
-
// — natural in many locales, and often required by RTL /
|
|
77
|
-
// agglutinative word order) leaked literal braces to users.
|
|
78
|
-
// 2. `replace` AND `replaceAll` both interpret `$&`, `` $` ``, `$'`
|
|
79
|
-
// and `$$` in the *replacement* string. i18next does not. Values
|
|
80
|
-
// here are runtime data — record labels, search terms — so this
|
|
81
|
-
// one is reachable today, unlike (1).
|
|
82
|
-
// split/join is literal on both sides, which is exactly i18next's
|
|
83
|
-
// behaviour, and needs no regex escaping of the placeholder name.
|
|
84
|
-
value = value.split(`{{${k}}}`).join(String(v));
|
|
85
|
-
}
|
|
86
|
-
}
|
|
75
|
+
let value = defaultsRowFor(defaults, key, options) ||
|
|
76
|
+
(typeof inlineDefault === 'string' ? inlineDefault : '') ||
|
|
77
|
+
key;
|
|
78
|
+
// The interpolation itself lives in `fallbackInterpolation.ts` — ONE
|
|
79
|
+
// function, shared with `useObjectTranslation`'s not-ready path
|
|
80
|
+
// (objectui#6219). Both provider-less renderers therefore fill exactly
|
|
81
|
+
// the `{{name}}` spelling objectui#3512 holds the copy to, and the
|
|
82
|
+
// reserved-name rule for `defaultValue` (objectui#3865) is stated once.
|
|
83
|
+
// The behaviour pinned by `useSafeTranslation.test.tsx` is unchanged.
|
|
84
|
+
value = interpolateFallback(value, options);
|
|
87
85
|
return value;
|
|
88
86
|
};
|
|
89
87
|
return function useSafeTranslation() {
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
* @object-ui/i18n - Date and currency formatting utilities
|
|
3
3
|
*
|
|
4
4
|
* Uses the native Intl API for locale-aware formatting.
|
|
5
|
+
*
|
|
6
|
+
* Every date helper below reads a string through `toDisplayDate`
|
|
7
|
+
* (`@object-ui/core`), the one parse step behind the date faces
|
|
8
|
+
* (objectui#10110, objectui#10183). The engine's own parse reads a date-only
|
|
9
|
+
* `2026-09-01` as UTC midnight, so each helper named August 31st for every
|
|
10
|
+
* viewer west of UTC (objectui#10866). The shared step tells the two shapes
|
|
11
|
+
* apart by the value: a date-only string is rebuilt at local midnight of the
|
|
12
|
+
* day it names, and a value with a time part keeps its instant. It also
|
|
13
|
+
* refuses a day its month does not have (`2026-02-30`, objectui#10026), which
|
|
14
|
+
* each helper then renders as the raw string, its face for any unparsable
|
|
15
|
+
* value.
|
|
5
16
|
*/
|
|
6
17
|
export interface DateFormatOptions {
|
|
7
18
|
locale?: string;
|
|
@@ -33,6 +44,11 @@ export declare function formatDate(date: Date | string | number, options?: DateF
|
|
|
33
44
|
export declare function formatDateTime(date: Date | string | number, options?: DateFormatOptions): string;
|
|
34
45
|
/**
|
|
35
46
|
* Format a relative time (e.g., "2 days ago", "in 3 hours")
|
|
47
|
+
*
|
|
48
|
+
* A date-only value counts from now to the START of the day it names (local
|
|
49
|
+
* midnight), the same distance in every zone at the same wall-clock time.
|
|
50
|
+
* An unparsable value comes back as its string, as it does from
|
|
51
|
+
* {@link formatDate}; `Intl.RelativeTimeFormat` would throw on it.
|
|
36
52
|
*/
|
|
37
53
|
export declare function formatRelativeTime(date: Date | string | number, locale?: string): string;
|
|
38
54
|
/**
|
package/dist/utils/formatting.js
CHANGED
|
@@ -2,13 +2,25 @@
|
|
|
2
2
|
* @object-ui/i18n - Date and currency formatting utilities
|
|
3
3
|
*
|
|
4
4
|
* Uses the native Intl API for locale-aware formatting.
|
|
5
|
+
*
|
|
6
|
+
* Every date helper below reads a string through `toDisplayDate`
|
|
7
|
+
* (`@object-ui/core`), the one parse step behind the date faces
|
|
8
|
+
* (objectui#10110, objectui#10183). The engine's own parse reads a date-only
|
|
9
|
+
* `2026-09-01` as UTC midnight, so each helper named August 31st for every
|
|
10
|
+
* viewer west of UTC (objectui#10866). The shared step tells the two shapes
|
|
11
|
+
* apart by the value: a date-only string is rebuilt at local midnight of the
|
|
12
|
+
* day it names, and a value with a time part keeps its instant. It also
|
|
13
|
+
* refuses a day its month does not have (`2026-02-30`, objectui#10026), which
|
|
14
|
+
* each helper then renders as the raw string, its face for any unparsable
|
|
15
|
+
* value.
|
|
5
16
|
*/
|
|
17
|
+
import { toDisplayDate } from '@object-ui/core';
|
|
6
18
|
/**
|
|
7
19
|
* Format a date according to locale conventions
|
|
8
20
|
*/
|
|
9
21
|
export function formatDate(date, options = {}) {
|
|
10
22
|
const { locale = 'en', style = 'medium' } = options;
|
|
11
|
-
const d =
|
|
23
|
+
const d = toDisplayDate(date);
|
|
12
24
|
if (isNaN(d.getTime())) {
|
|
13
25
|
return String(date);
|
|
14
26
|
}
|
|
@@ -28,7 +40,7 @@ export function formatDate(date, options = {}) {
|
|
|
28
40
|
*/
|
|
29
41
|
export function formatDateTime(date, options = {}) {
|
|
30
42
|
const { locale = 'en', style = 'medium' } = options;
|
|
31
|
-
const d =
|
|
43
|
+
const d = toDisplayDate(date);
|
|
32
44
|
if (isNaN(d.getTime())) {
|
|
33
45
|
return String(date);
|
|
34
46
|
}
|
|
@@ -42,9 +54,17 @@ export function formatDateTime(date, options = {}) {
|
|
|
42
54
|
}
|
|
43
55
|
/**
|
|
44
56
|
* Format a relative time (e.g., "2 days ago", "in 3 hours")
|
|
57
|
+
*
|
|
58
|
+
* A date-only value counts from now to the START of the day it names (local
|
|
59
|
+
* midnight), the same distance in every zone at the same wall-clock time.
|
|
60
|
+
* An unparsable value comes back as its string, as it does from
|
|
61
|
+
* {@link formatDate}; `Intl.RelativeTimeFormat` would throw on it.
|
|
45
62
|
*/
|
|
46
63
|
export function formatRelativeTime(date, locale = 'en') {
|
|
47
|
-
const d =
|
|
64
|
+
const d = toDisplayDate(date);
|
|
65
|
+
if (isNaN(d.getTime())) {
|
|
66
|
+
return String(date);
|
|
67
|
+
}
|
|
48
68
|
const now = new Date();
|
|
49
69
|
const diffMs = d.getTime() - now.getTime();
|
|
50
70
|
const diffSec = Math.round(diffMs / 1000);
|
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';
|