@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +4954 -0
  2. package/README.md +234 -13
  3. package/dist/builtinAggregateLabels.d.ts +40 -0
  4. package/dist/builtinAggregateLabels.js +23 -0
  5. package/dist/currency.d.ts +27 -3
  6. package/dist/currency.js +28 -4
  7. package/dist/fallbackInterpolation.d.ts +79 -0
  8. package/dist/fallbackInterpolation.js +124 -0
  9. package/dist/i18n.d.ts +28 -2
  10. package/dist/i18n.js +99 -17
  11. package/dist/index.d.ts +17 -23
  12. package/dist/index.js +51 -27
  13. package/dist/locales/ar.d.ts +1113 -370
  14. package/dist/locales/ar.js +1276 -428
  15. package/dist/locales/de.d.ts +785 -370
  16. package/dist/locales/de.js +896 -401
  17. package/dist/locales/en.d.ts +800 -385
  18. package/dist/locales/en.js +1261 -419
  19. package/dist/locales/es.d.ts +867 -370
  20. package/dist/locales/es.js +976 -398
  21. package/dist/locales/fr.d.ts +867 -370
  22. package/dist/locales/fr.js +976 -399
  23. package/dist/locales/index.d.ts +8682 -3761
  24. package/dist/locales/index.js +68 -29
  25. package/dist/locales/ja.d.ts +785 -370
  26. package/dist/locales/ja.js +891 -395
  27. package/dist/locales/ko.d.ts +785 -370
  28. package/dist/locales/ko.js +893 -399
  29. package/dist/locales/pt.d.ts +867 -370
  30. package/dist/locales/pt.js +975 -397
  31. package/dist/locales/registry.d.ts +102 -0
  32. package/dist/locales/registry.js +220 -0
  33. package/dist/locales/rtl.d.ts +24 -0
  34. package/dist/locales/rtl.js +26 -0
  35. package/dist/locales/ru.d.ts +949 -370
  36. package/dist/locales/ru.js +1093 -419
  37. package/dist/locales/zh.d.ts +803 -388
  38. package/dist/locales/zh.js +899 -388
  39. package/dist/pickLocalized.d.ts +67 -0
  40. package/dist/pickLocalized.js +114 -0
  41. package/dist/provider.d.ts +102 -13
  42. package/dist/provider.js +335 -50
  43. package/dist/translateFn.d.ts +29 -0
  44. package/dist/translateFn.js +8 -0
  45. package/dist/useDisplayLocale.d.ts +11 -0
  46. package/dist/useDisplayLocale.js +13 -2
  47. package/dist/useObjectLabel.d.ts +118 -22
  48. package/dist/useObjectLabel.js +148 -23
  49. package/dist/useSafeTranslation.js +41 -43
  50. package/dist/utils/formatting.d.ts +16 -0
  51. package/dist/utils/formatting.js +23 -3
  52. package/dist/utils/index.d.ts +4 -4
  53. package/dist/utils/index.js +4 -4
  54. package/dist/utils/spec-formatters.d.ts +13 -23
  55. package/dist/utils/spec-formatters.js +41 -2
  56. package/dist/utils/spec-translations.d.ts +16 -3
  57. package/dist/utils/spec-translations.js +52 -13
  58. package/package.json +12 -6
package/README.md CHANGED
@@ -1,15 +1,16 @@
1
1
  # @object-ui/i18n
2
2
 
3
- Internationalization for Object UI — 11 built-in locales, RTL support, and date/currency formatting.
3
+ Internationalization for Object UI — 10 built-in locales, RTL support, and date/currency formatting.
4
4
 
5
5
  ## Features
6
6
 
7
- - 🌍 **11 Built-in Locales** - English, Chinese, Japanese, Korean, German, French, Spanish, Portuguese, Russian, Arabic, and more
7
+ - 🌍 **10 Built-in Locales** - English, Chinese, Japanese, Korean, German, French, Spanish, Portuguese, Russian, Arabic
8
8
  - 🔄 **RTL Support** - Automatic right-to-left layout for Arabic and other RTL languages
9
9
  - 📅 **Date Formatting** - Locale-aware date, datetime, and relative time formatting
10
10
  - 💰 **Currency & Number Formatting** - Locale-aware currency and number formatting
11
11
  - 🎣 **React Hooks** - `useObjectTranslation` for translations, language switching, and direction
12
12
  - 🏗️ **I18nProvider** - Context provider for internationalized applications
13
+ - 🪶 **Lazy catalogues** - only the active locale's catalogue is fetched; the other nine are separate chunks
13
14
  - 🔌 **Extensible** - Add custom locales and translation keys
14
15
  - 🎯 **Type-Safe** - Full TypeScript support with exported types
15
16
 
@@ -55,9 +56,15 @@ function MyComponent() {
55
56
  Wraps your application with i18n context:
56
57
 
57
58
  ```tsx
59
+ import type { ReactNode } from 'react';
60
+ import { I18nProvider } from '@object-ui/i18n';
61
+
62
+ // Your app supplies the tree being wrapped.
63
+ declare const App: () => ReactNode;
64
+
58
65
  <I18nProvider config={{ defaultLanguage: 'en', fallbackLanguage: 'en' }}>
59
66
  <App />
60
- </I18nProvider>
67
+ </I18nProvider>;
61
68
  ```
62
69
 
63
70
  #### Language persistence
@@ -72,14 +79,21 @@ An explicit choice outranks browser detection — otherwise a user who picked
72
79
  on a `ja` browser would be handed `ja` back on every reload. A stored value the
73
80
  app no longer offers (not a built-in pack, not in `config.resources`) is ignored
74
81
  *and purged*, so a stale entry can never lock the UI to a locale with no
75
- translations.
82
+ translations. A region-tagged choice such as `zh-CN` is served by its base
83
+ language's built-in pack (`zh`), so it is kept, and kept as written: the
84
+ provider's `language`, `<html lang>` and the stored value all stay `zh-CN`.
76
85
 
77
86
  ```tsx
87
+ import type { ReactNode } from 'react';
88
+ import { I18nProvider } from '@object-ui/i18n';
89
+
90
+ declare const Preview: () => ReactNode;
91
+
78
92
  // Fixed-language surfaces (previews, demos, screenshot harnesses) opt out —
79
93
  // they neither restore nor write the preference.
80
94
  <I18nProvider config={{ defaultLanguage: 'en' }} persistLanguage={false}>
81
95
  <Preview />
82
- </I18nProvider>
96
+ </I18nProvider>;
83
97
  ```
84
98
 
85
99
  Bringing your own `instance`? Then its bootstrap language is yours to choose —
@@ -91,9 +105,47 @@ Switching through such an instance is still persisted.
91
105
  Hook for translations and language management:
92
106
 
93
107
  ```tsx
94
- const { t, language, changeLanguage, direction } = useObjectTranslation();
108
+ import { useObjectTranslation } from '@object-ui/i18n';
109
+
110
+ function LanguageBar() {
111
+ const { t, language, changeLanguage, direction } = useObjectTranslation();
112
+
113
+ return (
114
+ <div dir={direction}>
115
+ {t('common.save')} — {language}
116
+ <button onClick={() => changeLanguage('zh')}>中文</button>
117
+ </div>
118
+ );
119
+ }
120
+ ```
121
+
122
+ #### Count labels are i18next count families
123
+
124
+ A label a word must agree with — `3 replies`, `Approve 3 requests?` — is one
125
+ i18next count family: pass the number as `count` and let i18next pick the slot
126
+ `Intl.PluralRules` selects for the language. Every pack spells out each
127
+ category its language has (`en` `_one`/`_other`; `ru` adds `_few`/`_many`; `ar`
128
+ has all six), plus a count-invariant base key for a call made without a count.
129
+
130
+ ```tsx
131
+ import { useObjectTranslation } from '@object-ui/i18n';
132
+
133
+ function ReplyCount({ replies }: { replies: readonly string[] }) {
134
+ const { t } = useObjectTranslation();
135
+
136
+ // "1 reply", "3 replies"; Russian «3 ответа», «5 ответов»
137
+ return <span>{t('detail.replyCount', { count: replies.length })}</span>;
138
+ }
95
139
  ```
96
140
 
141
+ Pass `count` as a **number** — i18next selects a slot for nothing else — and
142
+ never choose the key yourself (`count === 1 ? 'xOne' : 'x'`): two keys give a
143
+ language two forms, and Russian needs three, Arabic five. A
144
+ `createSafeTranslation` defaults table answers the same way on a provider-less
145
+ host: it reads a family's `_one` / `_other` row for a numeric `count`, then the
146
+ base row. `count-families-11445.test.ts` holds every `{{count}}` value in the
147
+ ten packs to this rule.
148
+
97
149
  ### createI18n
98
150
 
99
151
  Factory for creating an i18n instance outside React:
@@ -105,27 +157,128 @@ const i18n = createI18n({ defaultLanguage: 'de' });
105
157
  i18n.t('common.cancel'); // "Abbrechen"
106
158
  ```
107
159
 
160
+ ### TranslateFn — typing a `t` you are handed
161
+
162
+ A helper that receives `t` from its caller, rather than calling a hook itself,
163
+ types that parameter with `TranslateFn`: i18next's `t` narrowed to
164
+ `(key: string, options?: Record<string, unknown>) => string`. This package is
165
+ the one authority for that type — import it rather than declaring a local
166
+ copy:
167
+
168
+ ```ts
169
+ import type { TranslateFn } from '@object-ui/i18n';
170
+
171
+ export function saveLabel(t: TranslateFn): string {
172
+ return t('common.save');
173
+ }
174
+ ```
175
+
108
176
  ### Formatting Utilities
109
177
 
110
178
  Locale-aware formatting functions:
111
179
 
180
+ Each formatter takes the value first and an **options object** second; the
181
+ locale is a field on that object (`DateFormatOptions`, `CurrencyFormatOptions`,
182
+ `NumberFormatOptions`), never a positional argument. `formatRelativeTime` is the
183
+ one exception: it takes the locale directly, and derives the unit from how far
184
+ the date is from now.
185
+
112
186
  ```tsx
113
187
  import { formatDate, formatCurrency, formatNumber, formatRelativeTime } from '@object-ui/i18n';
114
188
 
115
- formatDate(new Date(), 'en'); // "Jan 1, 2025"
116
- formatCurrency(99.99, 'USD', 'en'); // "$99.99"
117
- formatNumber(1234567, 'de'); // "1.234.567"
118
- formatRelativeTime(-3, 'days', 'en'); // "3 days ago"
189
+ formatDate(new Date(2025, 0, 1), { locale: 'en' }); // "Jan 1, 2025"
190
+ formatCurrency(99.99, { currency: 'USD', locale: 'en' }); // "$99.99"
191
+ formatNumber(1234567, { locale: 'de' }); // "1.234.567"
192
+ formatRelativeTime(Date.now() - 3 * 86_400_000, 'en'); // "3 days ago"
119
193
  ```
120
194
 
121
- ### Built-in Locales
195
+ A date-only string such as `'2026-09-01'` names a calendar day: every date
196
+ helper reads it as that day in every viewer's time zone (`formatDateTime` shows
197
+ its midnight, and `formatRelativeTime` counts to the start of it). A string
198
+ with a time part is an instant, read in the viewer's zone. `formatDateSpec`
199
+ applies its `timeZone` to an instant only.
122
200
 
123
- Import individual locale packs:
201
+ ### Built-in locales — one is resident, nine are fetched on demand
202
+
203
+ The package entry re-exports **`en` only**. It is `fallbackLng`, it is the
204
+ source of the `TranslationKeys` type, and it is the synchronous dictionary the
205
+ app-shell splash renders from before i18n is usable, so it has to be there
206
+ without awaiting anything. The other nine catalogues are separate chunks your
207
+ bundler emits once each and the browser fetches only when that locale is
208
+ actually used — roughly 400 KB gzipped that a page load no longer pays
209
+ (objectui#7479).
124
210
 
125
211
  ```tsx
126
- import { en, zh, ja, ko, de, fr, es, pt, ru, ar } from '@object-ui/i18n';
212
+ import {
213
+ en, // resident
214
+ BUILT_IN_LANGUAGE_CODES, // all ten codes, no catalogue payload
215
+ isBuiltInLanguage,
216
+ loadBuiltInLocale, // fetch one: Promise<catalogue | null>
217
+ getLoadedBuiltInLocales, // synchronous snapshot of what is resident
218
+ } from '@object-ui/i18n';
219
+
220
+ BUILT_IN_LANGUAGE_CODES; // ['en','zh','ja','ko','de','fr','es','pt','ru','ar']
221
+ await loadBuiltInLocale('zh'); // the zh catalogue, fetched once and memoised
222
+ await loadBuiltInLocale('zh-CN'); // the same zh catalogue — a region tag is served by its base
223
+ await loadBuiltInLocale('tlh'); // null — not a code this package ships
224
+ isBuiltInLanguage('zh-CN'); // true
225
+ isBuiltInLanguage('xx-YY'); // false — no catalogue for the tag or its base
127
226
  ```
128
227
 
228
+ A code is looked up **exact tag first, then its base language**: `zh-CN` is
229
+ served by the `zh` catalogue, while a catalogue keyed by a full tag would win
230
+ over its base. `isBuiltInLanguage` and `isBuiltInLocaleLoaded` answer by the same
231
+ rule as `loadBuiltInLocale`, and a catalogue is memoised under its own code, so
232
+ `zh` and `zh-CN` share one fetch.
233
+
234
+ `I18nProvider` does this for you: it fetches the catalogue for whatever language
235
+ it boots into, and `changeLanguage()` awaits the new catalogue before switching,
236
+ so a switcher needs no extra wiring.
237
+
238
+ #### Resolving before the first render
239
+
240
+ Without a resolve, a provider booting into `zh` paints once through the `en`
241
+ fallback — correct strings, never a raw key — and re-renders in Chinese when the
242
+ catalogue lands. If your app already awaits something before mounting, spend
243
+ that await here instead and the first paint is already right:
244
+
245
+ ```tsx
246
+ import { I18nProvider, preloadBootstrapLocale, resolveBootstrapLanguage } from '@object-ui/i18n';
247
+ import { createRoot } from 'react-dom/client';
248
+ import type { FC } from 'react';
249
+
250
+ declare const App: FC;
251
+ declare const container: HTMLElement;
252
+ declare const loadLanguage: (lang: string) => Promise<Record<string, unknown>>;
253
+
254
+ // Pass the SAME options you will pass to <I18nProvider>, or the two will
255
+ // disagree about which language this boot is in.
256
+ await preloadBootstrapLocale({ hasLoader: true });
257
+ createRoot(container).render(
258
+ <I18nProvider loadLanguage={loadLanguage}>
259
+ <App />
260
+ </I18nProvider>,
261
+ );
262
+
263
+ resolveBootstrapLanguage({ hasLoader: true }); // 'zh' — the same answer, no fetch
264
+ ```
265
+
266
+ `preloadBootstrapLocale` never rejects: a catalogue that will not download must
267
+ not take the boot down, and the provider retries on mount.
268
+ `apps/console/src/main.tsx` is the worked example.
269
+
270
+ #### When you really do want all ten
271
+
272
+ The parity suites do, and so does an app that ships every language resident. The
273
+ door is a separate specifier, so the cost is visible at the import site:
274
+
275
+ ```tsx
276
+ import { builtInLocales, zh, ru } from '@object-ui/i18n/locales';
277
+ ```
278
+
279
+ ⚠️ That module statically imports every catalogue. Never reach for it on a
280
+ page-load path — that is exactly the eager payload the split removed.
281
+
129
282
  ### RTL Helpers
130
283
 
131
284
  ```tsx
@@ -135,6 +288,74 @@ isRTL('ar'); // true
135
288
  isRTL('en'); // false
136
289
  ```
137
290
 
291
+ ### Localized value helpers — reading and editing an `I18nLabel`
292
+
293
+ Server-driven metadata types a label as a plain string **or** an inline
294
+ per-locale map (`{ en: 'Pricing', 'zh-CN': '定价' }`). An authoring surface that
295
+ shows one locale in one text input has to answer three separate questions, so
296
+ there are three functions and each answers exactly one:
297
+
298
+ ```tsx
299
+ import { pickLocalized, setLocalized, clearLocalized } from '@object-ui/i18n';
300
+
301
+ const stored = { en: 'Pricing', 'zh-CN': '定价' };
302
+
303
+ pickLocalized(stored, 'en-US'); // 'Pricing' — which entry to DISPLAY
304
+ setLocalized(stored, 'en-US', 'Plans'); // { en: 'Plans', 'zh-CN': '定价' }
305
+ clearLocalized(stored, 'en-US'); // { 'zh-CN': '定价' }
306
+ ```
307
+
308
+ ⛔ Never write the input's string back as the whole value — that replaces the
309
+ map and every locale the author was not looking at is gone, silently, on the
310
+ first keystroke. `setLocalized` replaces **one** entry (adding it when the
311
+ active locale has none) and `clearLocalized` removes **one** entry (returning
312
+ `undefined` once nothing localized is left). Both choose that entry with the
313
+ same limbs, and deliberately stop short of `pickLocalized`'s display-only
314
+ fallbacks, so an author editing in `fr` while shown the `en` string can neither
315
+ overwrite nor delete English.
316
+
317
+ ## Scope — the `engine.*` carve-out (metadata-admin / Studio)
318
+
319
+ Not every user-facing string in this repository resolves through these packs.
320
+ The metadata-admin (Studio) surface — the metadata directory, the inspectors,
321
+ package management, the flow designer and their refusal messages — resolves its
322
+ `engine.*` keys through a module-local **two-locale** table in
323
+ [`packages/app-shell/src/views/metadata-admin/i18n.ts`](../app-shell/src/views/metadata-admin/i18n.ts):
324
+ a plain `Record` lookup over `en-US` and `zh-CN`, not an i18next namespace.
325
+ Roughly 1,300 keys per locale live there, and **no `engine.*` key exists in any
326
+ of the ten packs.**
327
+
328
+ That is design, not drift — the file's header records it as "Phase 3f". The
329
+ server holds the primary path: the engine consumes `label` from the
330
+ `/meta/types` response, sourced from the platform's metadata-type registry. The
331
+ module-local table is a **fallback** for deployments with no translation bundles
332
+ configured, and the interim source of truth for Chinese until the platform's
333
+ `setup.translation.ts` ships zh-CN coverage. Copying the namespace into the
334
+ packs would duplicate strings whose primary source is a server response.
335
+
336
+ Two consequences follow, and both are accepted rather than outstanding work:
337
+
338
+ - **Only `en` and `zh` are covered.** On an `ar` / `de` / `es` / `fr` / `ja` /
339
+ `ko` / `pt` / `ru` console, `engine.*` strings render English. That is the
340
+ price of the carve-out, not a backfill someone forgot.
341
+ - **The i18n gates cannot see this namespace, by construction.** Key parity
342
+ ([`src/__tests__/all-locales-key-parity.test.ts`](./src/__tests__/all-locales-key-parity.test.ts))
343
+ compares pack against pack, and `engine.*` is in no pack — so it is not an
344
+ *exception* to that test, it is outside its subject. The call-site gate
345
+ ([`scripts/check-i18n-call-site-keys.mjs`](../../scripts/check-i18n-call-site-keys.mjs))
346
+ skips these call sites **by declaration**: the module is registered in its
347
+ `EXCLUDED_TRANSLATORS` list with a reason, and an imported `t` from any
348
+ unregistered module is a hard error there — so a second local table cannot
349
+ appear silently, and this carve-out cannot quietly widen.
350
+
351
+ **When to revisit.** Migrating the namespace into the packs is mechanical but
352
+ wide (~1,300 keys × 8 further locales) and argues against the server-driven
353
+ design above, so it is not worth doing speculatively. The condition to reopen it
354
+ is concrete: a stated demand for an admin console in a language other than `en`
355
+ or `zh` — not a general wish for broader locale coverage, which the packs
356
+ already serve on every other surface. See objectui#4662 for the measurement and
357
+ the ruling.
358
+
138
359
  ## Links
139
360
 
140
361
  - 📦 [npm package](https://www.npmjs.com/package/@object-ui/i18n)
@@ -0,0 +1,40 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * objectui#7258 — the locale labels for the analytics service's BUILT-IN
10
+ * default measures, keyed by the wire discriminator a result field carries as
11
+ * `builtinAggregate` (producer half: objectstack#14492).
12
+ *
13
+ * `@object-ui/core`'s `buildChartSeries` cannot read the locale bundle (that
14
+ * package is React-free and i18n-free by design), so this is the seam: the
15
+ * renderer that holds the provider resolves the six strings ONCE through its
16
+ * `useSafeTranslate()` and hands the map to core as
17
+ * `ChartSeriesOptions.builtinAggregateLabels` — the same division of labour
18
+ * `chart.nullCategory` already makes for the null bucket's label.
19
+ *
20
+ * The keys are the `report.aggregate.*` family the matrix report and the
21
+ * dashboard's aggregate charts already read, not a second namespace: the
22
+ * vocabulary (Count / Sum / Average / …) is the same on every surface, so ten
23
+ * packs already carry it and a new key would only add drift surface. The only
24
+ * mapping done here is spelling — the wire enum says `count_distinct`, the
25
+ * bundle key says `countDistinct`.
26
+ *
27
+ * The English fallbacks are byte-equal to the `en` pack's values, so a
28
+ * provider-less host renders exactly what an `en` session renders (pinned in
29
+ * `__tests__/builtinAggregateLabels-locale-parity-7258.test.ts`).
30
+ */
31
+ import type { BuiltinAggregate } from '@object-ui/core';
32
+ /** The per-call translate shape `useSafeTranslate()` returns: first real translation, else the English fallback. */
33
+ export type SafeTranslate = (key: string, fallback: string) => string;
34
+ /**
35
+ * Resolve the display label of every built-in aggregate through the locale
36
+ * bundle. Typed as the FULL record on purpose: a member added to core's
37
+ * `BUILTIN_AGGREGATES` without a line here is a compile error, not a legend
38
+ * that silently falls back to the server's English.
39
+ */
40
+ export declare function builtinAggregateLabels(tt: SafeTranslate): Record<BuiltinAggregate, string>;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ObjectUI
3
+ * Copyright (c) 2024-present ObjectStack Inc.
4
+ *
5
+ * This source code is licensed under the MIT license found in the
6
+ * LICENSE file in the root directory of this source tree.
7
+ */
8
+ /**
9
+ * Resolve the display label of every built-in aggregate through the locale
10
+ * bundle. Typed as the FULL record on purpose: a member added to core's
11
+ * `BUILTIN_AGGREGATES` without a line here is a compile error, not a legend
12
+ * that silently falls back to the server's English.
13
+ */
14
+ export function builtinAggregateLabels(tt) {
15
+ return {
16
+ count: tt('report.aggregate.count', 'Count'),
17
+ count_distinct: tt('report.aggregate.countDistinct', 'Distinct Count'),
18
+ sum: tt('report.aggregate.sum', 'Sum'),
19
+ avg: tt('report.aggregate.avg', 'Average'),
20
+ min: tt('report.aggregate.min', 'Min'),
21
+ max: tt('report.aggregate.max', 'Max'),
22
+ };
23
+ }
@@ -7,12 +7,35 @@
7
7
  */
8
8
  /**
9
9
  * Resolve the display currency code for a currency-typed field, in precedence
10
- * order: the field's explicit `currency` -> its `currencyConfig.defaultCurrency`
11
- * (fixed mode) -> a legacy top-level `defaultCurrency` -> the tenant default
12
- * (`localization.currency`, ADR-0053, surfaced via {@link useLocalization}).
10
+ * order:
11
+ *
12
+ * 1. the field's explicit `currency` — a per-node display code. The spec
13
+ * declares it on presentation surfaces (the metric widget's `currency`, a
14
+ * dataset measure's `currency`) and looser grid/column configs carry it;
15
+ * `FieldSchema` itself refuses it as a field key;
16
+ * 2. `currencyConfig.defaultCurrency`, ONLY when `currencyConfig.currencyMode`
17
+ * is `'fixed'` — the spec's one fixed-currency declaration;
18
+ * 3. a legacy top-level `defaultCurrency` — a pre-spec spelling that
19
+ * `FieldSchema` refuses as an unrecognized key, kept in place so configs
20
+ * written against it still read their code;
21
+ * 4. the tenant default (`localization.currency`, ADR-0053, surfaced via
22
+ * {@link useLocalization}).
23
+ *
13
24
  * Returns `undefined` when none is known, so the renderer shows a plain number
14
25
  * rather than guessing a symbol.
15
26
  *
27
+ * `currencyMode: 'dynamic'` — and a `currencyConfig` with no `currencyMode` at
28
+ * all, which `@objectstack/spec` defaults to `'dynamic'` — skips step 2
29
+ * (objectui#10422). The spec describes that mode as "user selectable", and its
30
+ * `FieldSchema` guidance says a field without a fixed currency "uses the tenant
31
+ * default at runtime". No value carries a user-selected code today (a currency
32
+ * value is a bare number), so a dynamic field reads the tenant default, never
33
+ * its `defaultCurrency`. That matters beyond authored dynamic configs: the
34
+ * spec parses an EMPTY `currencyConfig` to
35
+ * `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render
36
+ * as yuan. If a value ever carries its own selected code, that code is the one
37
+ * to win, ahead of the tenant default.
38
+ *
16
39
  * This is the single resolution every field / measure / cell renderer shares -
17
40
  * ending the per-renderer drift where some read only `currency`, others only
18
41
  * `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n`
@@ -23,6 +46,7 @@ export declare function resolveFieldCurrency(field: {
23
46
  currency?: string;
24
47
  defaultCurrency?: string;
25
48
  currencyConfig?: {
49
+ currencyMode?: 'dynamic' | 'fixed';
26
50
  defaultCurrency?: string;
27
51
  };
28
52
  } | null | undefined, tenantDefault?: string): string | undefined;
package/dist/currency.js CHANGED
@@ -7,12 +7,35 @@
7
7
  */
8
8
  /**
9
9
  * Resolve the display currency code for a currency-typed field, in precedence
10
- * order: the field's explicit `currency` -> its `currencyConfig.defaultCurrency`
11
- * (fixed mode) -> a legacy top-level `defaultCurrency` -> the tenant default
12
- * (`localization.currency`, ADR-0053, surfaced via {@link useLocalization}).
10
+ * order:
11
+ *
12
+ * 1. the field's explicit `currency` — a per-node display code. The spec
13
+ * declares it on presentation surfaces (the metric widget's `currency`, a
14
+ * dataset measure's `currency`) and looser grid/column configs carry it;
15
+ * `FieldSchema` itself refuses it as a field key;
16
+ * 2. `currencyConfig.defaultCurrency`, ONLY when `currencyConfig.currencyMode`
17
+ * is `'fixed'` — the spec's one fixed-currency declaration;
18
+ * 3. a legacy top-level `defaultCurrency` — a pre-spec spelling that
19
+ * `FieldSchema` refuses as an unrecognized key, kept in place so configs
20
+ * written against it still read their code;
21
+ * 4. the tenant default (`localization.currency`, ADR-0053, surfaced via
22
+ * {@link useLocalization}).
23
+ *
13
24
  * Returns `undefined` when none is known, so the renderer shows a plain number
14
25
  * rather than guessing a symbol.
15
26
  *
27
+ * `currencyMode: 'dynamic'` — and a `currencyConfig` with no `currencyMode` at
28
+ * all, which `@objectstack/spec` defaults to `'dynamic'` — skips step 2
29
+ * (objectui#10422). The spec describes that mode as "user selectable", and its
30
+ * `FieldSchema` guidance says a field without a fixed currency "uses the tenant
31
+ * default at runtime". No value carries a user-selected code today (a currency
32
+ * value is a bare number), so a dynamic field reads the tenant default, never
33
+ * its `defaultCurrency`. That matters beyond authored dynamic configs: the
34
+ * spec parses an EMPTY `currencyConfig` to
35
+ * `{ currencyMode: 'dynamic', defaultCurrency: 'CNY' }`, which must not render
36
+ * as yuan. If a value ever carries its own selected code, that code is the one
37
+ * to win, ahead of the tenant default.
38
+ *
16
39
  * This is the single resolution every field / measure / cell renderer shares -
17
40
  * ending the per-renderer drift where some read only `currency`, others only
18
41
  * `defaultCurrency`, and others `currencyConfig`. It lives in `@object-ui/i18n`
@@ -20,8 +43,9 @@
20
43
  * concern; `@object-ui/fields` re-exports it for backward compatibility.
21
44
  */
22
45
  export function resolveFieldCurrency(field, tenantDefault) {
46
+ const config = field?.currencyConfig;
23
47
  return (field?.currency ||
24
- field?.currencyConfig?.defaultCurrency ||
48
+ (config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) ||
25
49
  field?.defaultCurrency ||
26
50
  tenantDefault ||
27
51
  undefined);
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The ONE interpolator every provider-less translation path in this package
3
+ * runs — extracted so there is exactly one of it (objectui#6219).
4
+ *
5
+ * ## Why this module exists at all
6
+ *
7
+ * Three things can render a translation in this repo, and each has its own
8
+ * answer to "does a `{{hole}}` get filled?":
9
+ *
10
+ * 1. **i18next, with an `I18nProvider` mounted.** It interpolates. This is
11
+ * the reference behaviour every other path has to agree with.
12
+ * 2. **`createSafeTranslation`'s `fallbackT`** (`useSafeTranslation.ts`).
13
+ * It interpolates — with the exact literal needle below.
14
+ * 3. **react-i18next's not-ready `t`**, which is what a bare
15
+ * `useObjectTranslation()` yields when no i18next instance is initialised.
16
+ * Measured in `react-i18next@17.0.11/dist/es/useTranslation.js`:
17
+ * `notReadyT` returns `options.defaultValue` VERBATIM and does not
18
+ * interpolate at all.
19
+ *
20
+ * (3) was objectui#6219: 68 inline defaults across 24 files carried a
21
+ * `{{hole}}` that reached the user as literal braces on any host that embeds an
22
+ * ObjectUI component without `I18nProvider` — the configuration
23
+ * `createSafeTranslation` exists for (objectui#3865), so a supported one.
24
+ * `useObjectTranslation` now runs its not-ready result through this function,
25
+ * which makes (3) agree with (2), and (2) already agrees with (1) for the one
26
+ * spelling this repo is allowed to author.
27
+ *
28
+ * ## Why one shared function rather than a second copy
29
+ *
30
+ * The semantics below are pinned in three places at once —
31
+ * `useSafeTranslation.test.tsx` (behaviour), `fallback-placeholder-spelling-3512.test.ts`
32
+ * (the copy is held to what this can resolve) and
33
+ * `scripts/check-i18n-call-site-keys.mjs` class 7 (the same rule over inline
34
+ * defaults). A second interpolator with its own drift would have made all three
35
+ * of those true of one path and quietly false of the other. There is one
36
+ * function, so "the fallback resolves only `{{name}}`" is one fact.
37
+ *
38
+ * ## The exact spelling, and why only that one
39
+ *
40
+ * objectui#3512 measured that i18next additionally accepts `{{ name }}`,
41
+ * `{{count, number}}`, `{{- name}}` and `$t(key)`, and the ruling was
42
+ * deliberately NOT to teach the fallback three more dialects (a second
43
+ * interpolator to keep in step with i18next forever) but to hold the copy to
44
+ * the one spelling both paths agree on — enforced by the two gates named above.
45
+ * This module keeps that ruling: it widens WHICH BINDINGS interpolate, never
46
+ * WHICH SPELLINGS resolve.
47
+ */
48
+ /**
49
+ * The i18next option that names the string to use when the lookup misses.
50
+ *
51
+ * It is a LOOKUP CONTROL, not interpolation data (objectui#3865): it chooses
52
+ * which string is rendered, so it must never also be spliced into a
53
+ * `{{defaultValue}}` hole in the string it chose — or in any other.
54
+ */
55
+ export declare const DEFAULT_VALUE_OPTION = "defaultValue";
56
+ /**
57
+ * Fill `{{name}}` holes in `value` from `options`, i18next-compatibly.
58
+ *
59
+ * Returns `value` untouched when there is nothing to fill from, so a caller can
60
+ * hand it every result unconditionally.
61
+ *
62
+ * @param value - The string that was chosen for rendering.
63
+ * @param options - The `t()` call's options object, or `undefined`.
64
+ */
65
+ export declare function interpolateFallback(value: string, options?: Record<string, unknown>): string;
66
+ /**
67
+ * The options object of a `t()` call, or `undefined` when the call passed none.
68
+ *
69
+ * i18next's `t` accepts both `t(key, options)` and `t(key, defaultValue,
70
+ * options)`, so the options object is the LAST argument when it is an object at
71
+ * all. A string second argument is a default value, not data — there is nothing
72
+ * to interpolate from, which is why `t(key, 'Hi {{name}}')` keeps its braces on
73
+ * both paths.
74
+ *
75
+ * Arrays are excluded deliberately: `t(key, [...])` is not an i18next options
76
+ * shape, and `Object.entries` over one would mint `{{0}}`-style needles that no
77
+ * copy in this repo spells.
78
+ */
79
+ export declare function optionsOf(args: readonly unknown[]): Record<string, unknown> | undefined;