@object-ui/i18n 17.6.0 → 18.0.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 (62) hide show
  1. package/CHANGELOG.md +5408 -0
  2. package/README.md +255 -13
  3. package/dist/LocalizationContext.d.ts +32 -9
  4. package/dist/LocalizationContext.js +32 -10
  5. package/dist/builtinAggregateLabels.d.ts +40 -0
  6. package/dist/builtinAggregateLabels.js +23 -0
  7. package/dist/currency.d.ts +27 -3
  8. package/dist/currency.js +28 -4
  9. package/dist/fallbackInterpolation.d.ts +79 -0
  10. package/dist/fallbackInterpolation.js +124 -0
  11. package/dist/i18n.d.ts +27 -1
  12. package/dist/i18n.js +99 -16
  13. package/dist/index.d.ts +8 -13
  14. package/dist/index.js +45 -17
  15. package/dist/locales/ar.d.ts +1504 -188
  16. package/dist/locales/ar.js +1711 -281
  17. package/dist/locales/de.d.ts +1080 -188
  18. package/dist/locales/de.js +1248 -261
  19. package/dist/locales/en.d.ts +1141 -249
  20. package/dist/locales/en.js +1714 -295
  21. package/dist/locales/es.d.ts +1186 -188
  22. package/dist/locales/es.js +1350 -255
  23. package/dist/locales/fr.d.ts +1186 -188
  24. package/dist/locales/fr.js +1351 -258
  25. package/dist/locales/index.d.ts +11940 -2033
  26. package/dist/locales/index.js +48 -9
  27. package/dist/locales/ja.d.ts +1080 -188
  28. package/dist/locales/ja.js +1240 -254
  29. package/dist/locales/ko.d.ts +1080 -188
  30. package/dist/locales/ko.js +1244 -258
  31. package/dist/locales/pt.d.ts +1186 -188
  32. package/dist/locales/pt.js +1350 -255
  33. package/dist/locales/registry.d.ts +102 -0
  34. package/dist/locales/registry.js +220 -0
  35. package/dist/locales/rtl.d.ts +24 -0
  36. package/dist/locales/rtl.js +26 -0
  37. package/dist/locales/ru.d.ts +1292 -188
  38. package/dist/locales/ru.js +1479 -272
  39. package/dist/locales/zh.d.ts +1144 -252
  40. package/dist/locales/zh.js +1245 -259
  41. package/dist/pickLocalized.d.ts +30 -0
  42. package/dist/pickLocalized.js +39 -0
  43. package/dist/provider.d.ts +101 -12
  44. package/dist/provider.js +335 -50
  45. package/dist/translateFn.d.ts +29 -0
  46. package/dist/translateFn.js +8 -0
  47. package/dist/useDisplayLocale.d.ts +11 -0
  48. package/dist/useDisplayLocale.js +11 -0
  49. package/dist/useObjectLabel.d.ts +143 -100
  50. package/dist/useObjectLabel.js +129 -82
  51. package/dist/useSafeTranslation.js +40 -42
  52. package/dist/utils/first-day-of-week.d.ts +71 -0
  53. package/dist/utils/first-day-of-week.js +61 -0
  54. package/dist/utils/formatting.d.ts +23 -1
  55. package/dist/utils/formatting.js +34 -5
  56. package/dist/utils/index.d.ts +1 -0
  57. package/dist/utils/index.js +1 -0
  58. package/dist/utils/spec-formatters.d.ts +13 -23
  59. package/dist/utils/spec-formatters.js +41 -2
  60. package/dist/utils/spec-translations.d.ts +16 -3
  61. package/dist/utils/spec-translations.js +52 -13
  62. 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
+ }
95
120
  ```
96
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
+ }
139
+ ```
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,191 @@ 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"
193
+ ```
194
+
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. These helpers do not read the
200
+ display zone below; the field cells and measures format through
201
+ `@object-ui/core`'s date faces, which do.
202
+
203
+ ### Regional defaults (`LocalizationProvider`)
204
+
205
+ `LocalizationProvider` carries the workspace's resolved regional defaults to
206
+ every renderer below it, and `useLocalization()` reads them (`{}` outside a
207
+ provider). The console fills it from `GET /api/v1/auth/me/localization`.
208
+
209
+ ```tsx
210
+ import type { ReactNode } from 'react';
211
+ import { LocalizationProvider, useLocalization } from '@object-ui/i18n';
212
+
213
+ export function Regional({ children }: { children: ReactNode }) {
214
+ return (
215
+ <LocalizationProvider value={{ currency: 'EUR', locale: 'de-DE', timezone: 'Europe/Berlin' }}>
216
+ {children}
217
+ </LocalizationProvider>
218
+ );
219
+ }
220
+
221
+ export function ZoneLabel() {
222
+ const { timezone } = useLocalization();
223
+ return <span>{timezone ?? 'viewer zone'}</span>;
224
+ }
225
+ ```
226
+
227
+ - `currency` (ISO 4217) is the default a currency field without its own code
228
+ renders in.
229
+ - `locale` (BCP-47) outranks the UI language for number and date formatting;
230
+ read the effective tag with `useDisplayLocale()`.
231
+ - `timezone` (IANA) is the zone instants render in. The provider hands it to
232
+ `setDisplayTimeZone` in `@object-ui/core`, so every date and datetime face
233
+ takes it without any renderer passing a zone, and `useLocalization().timezone`
234
+ reads back the zone those faces use (`undefined` for a name the runtime does
235
+ not know). Without one, instants render in the viewer's zone. There is one
236
+ display zone per page, and unmounting the provider clears it.
237
+
238
+ #### The first day of the week
239
+
240
+ `firstDayOfWeek(locale)` answers the first day of the week for a BCP-47 tag,
241
+ numbered as `Date.prototype.getDay` numbers weekdays (0 is Sunday), which is
242
+ also what react-day-picker's `weekStartsOn` takes. It reads the engine's
243
+ `Intl.Locale` week info, and CLDR's region table where the engine has none, so
244
+ a week grid or a "this week" bound starts where the user's locale says rather
245
+ than on a fixed day. There is no separate week-start setting: the tag is the
246
+ one the caller already formats its dates with, such as `useDisplayLocale()`.
247
+
248
+ ```tsx
249
+ import { firstDayOfWeek, useDisplayLocale } from '@object-ui/i18n';
250
+
251
+ firstDayOfWeek('en-US'); // 0, Sunday
252
+ firstDayOfWeek('en-GB'); // 1, Monday
253
+ firstDayOfWeek('zh-CN'); // 1, Monday
254
+
255
+ function WeekHeader() {
256
+ const weekStart = firstDayOfWeek(useDisplayLocale());
257
+ // ...lay the week out from `weekStart`
258
+ }
119
259
  ```
120
260
 
121
- ### Built-in Locales
261
+ A tag `Intl` refuses (`en_US`) throws the same `RangeError` that formatting a
262
+ date with it would.
122
263
 
123
- Import individual locale packs:
264
+ ### Built-in locales — one is resident, nine are fetched on demand
265
+
266
+ The package entry re-exports **`en` only**. It is `fallbackLng`, it is the
267
+ source of the `TranslationKeys` type, and it is the synchronous dictionary the
268
+ app-shell splash renders from before i18n is usable, so it has to be there
269
+ without awaiting anything. The other nine catalogues are separate chunks your
270
+ bundler emits once each and the browser fetches only when that locale is
271
+ actually used — roughly 400 KB gzipped that a page load no longer pays
272
+ (objectui#7479).
124
273
 
125
274
  ```tsx
126
- import { en, zh, ja, ko, de, fr, es, pt, ru, ar } from '@object-ui/i18n';
275
+ import {
276
+ en, // resident
277
+ BUILT_IN_LANGUAGE_CODES, // all ten codes, no catalogue payload
278
+ isBuiltInLanguage,
279
+ loadBuiltInLocale, // fetch one: Promise<catalogue | null>
280
+ getLoadedBuiltInLocales, // synchronous snapshot of what is resident
281
+ } from '@object-ui/i18n';
282
+
283
+ BUILT_IN_LANGUAGE_CODES; // ['en','zh','ja','ko','de','fr','es','pt','ru','ar']
284
+ await loadBuiltInLocale('zh'); // the zh catalogue, fetched once and memoised
285
+ await loadBuiltInLocale('zh-CN'); // the same zh catalogue — a region tag is served by its base
286
+ await loadBuiltInLocale('tlh'); // null — not a code this package ships
287
+ isBuiltInLanguage('zh-CN'); // true
288
+ isBuiltInLanguage('xx-YY'); // false — no catalogue for the tag or its base
127
289
  ```
128
290
 
291
+ A code is looked up **exact tag first, then its base language**: `zh-CN` is
292
+ served by the `zh` catalogue, while a catalogue keyed by a full tag would win
293
+ over its base. `isBuiltInLanguage` and `isBuiltInLocaleLoaded` answer by the same
294
+ rule as `loadBuiltInLocale`, and a catalogue is memoised under its own code, so
295
+ `zh` and `zh-CN` share one fetch.
296
+
297
+ `I18nProvider` does this for you: it fetches the catalogue for whatever language
298
+ it boots into, and `changeLanguage()` awaits the new catalogue before switching,
299
+ so a switcher needs no extra wiring.
300
+
301
+ #### Resolving before the first render
302
+
303
+ Without a resolve, a provider booting into `zh` paints once through the `en`
304
+ fallback — correct strings, never a raw key — and re-renders in Chinese when the
305
+ catalogue lands. If your app already awaits something before mounting, spend
306
+ that await here instead and the first paint is already right:
307
+
308
+ ```tsx
309
+ import { I18nProvider, preloadBootstrapLocale, resolveBootstrapLanguage } from '@object-ui/i18n';
310
+ import { createRoot } from 'react-dom/client';
311
+ import type { FC } from 'react';
312
+
313
+ declare const App: FC;
314
+ declare const container: HTMLElement;
315
+ declare const loadLanguage: (lang: string) => Promise<Record<string, unknown>>;
316
+
317
+ // Pass the SAME options you will pass to <I18nProvider>, or the two will
318
+ // disagree about which language this boot is in.
319
+ await preloadBootstrapLocale({ hasLoader: true });
320
+ createRoot(container).render(
321
+ <I18nProvider loadLanguage={loadLanguage}>
322
+ <App />
323
+ </I18nProvider>,
324
+ );
325
+
326
+ resolveBootstrapLanguage({ hasLoader: true }); // 'zh' — the same answer, no fetch
327
+ ```
328
+
329
+ `preloadBootstrapLocale` never rejects: a catalogue that will not download must
330
+ not take the boot down, and the provider retries on mount.
331
+ `apps/console/src/main.tsx` is the worked example.
332
+
333
+ #### When you really do want all ten
334
+
335
+ The parity suites do, and so does an app that ships every language resident. The
336
+ door is a separate specifier, so the cost is visible at the import site:
337
+
338
+ ```tsx
339
+ import { builtInLocales, zh, ru } from '@object-ui/i18n/locales';
340
+ ```
341
+
342
+ ⚠️ That module statically imports every catalogue. Never reach for it on a
343
+ page-load path — that is exactly the eager payload the split removed.
344
+
129
345
  ### RTL Helpers
130
346
 
131
347
  ```tsx
@@ -135,6 +351,32 @@ isRTL('ar'); // true
135
351
  isRTL('en'); // false
136
352
  ```
137
353
 
354
+ ### Localized value helpers — reading and editing an `I18nLabel`
355
+
356
+ Server-driven metadata types a label as a plain string **or** an inline
357
+ per-locale map (`{ en: 'Pricing', 'zh-CN': '定价' }`). An authoring surface that
358
+ shows one locale in one text input has to answer three separate questions, so
359
+ there are three functions and each answers exactly one:
360
+
361
+ ```tsx
362
+ import { pickLocalized, setLocalized, clearLocalized } from '@object-ui/i18n';
363
+
364
+ const stored = { en: 'Pricing', 'zh-CN': '定价' };
365
+
366
+ pickLocalized(stored, 'en-US'); // 'Pricing' — which entry to DISPLAY
367
+ setLocalized(stored, 'en-US', 'Plans'); // { en: 'Plans', 'zh-CN': '定价' }
368
+ clearLocalized(stored, 'en-US'); // { 'zh-CN': '定价' }
369
+ ```
370
+
371
+ ⛔ Never write the input's string back as the whole value — that replaces the
372
+ map and every locale the author was not looking at is gone, silently, on the
373
+ first keystroke. `setLocalized` replaces **one** entry (adding it when the
374
+ active locale has none) and `clearLocalized` removes **one** entry (returning
375
+ `undefined` once nothing localized is left). Both choose that entry with the
376
+ same limbs, and deliberately stop short of `pickLocalized`'s display-only
377
+ fallbacks, so an author editing in `fr` while shown the `en` string can neither
378
+ overwrite nor delete English.
379
+
138
380
  ## Scope — the `engine.*` carve-out (metadata-admin / Studio)
139
381
 
140
382
  Not every user-facing string in this repository resolves through these packs.
@@ -7,18 +7,20 @@
7
7
  */
8
8
  /**
9
9
  * LocalizationContext — the tenant's resolved REGIONAL defaults (currency,
10
- * locale) for the current request, surfaced to every renderer.
10
+ * locale, time zone) for the current request, surfaced to every renderer.
11
11
  *
12
12
  * This is the client half of ADR-0053: the framework resolves a workspace
13
- * `localization.currency` / `locale` onto each request's ExecutionContext and
14
- * exposes them at `GET /api/v1/auth/me/localization`. A fetching wrapper (in
15
- * app-shell) loads them once and feeds this PURE context, so low-level field /
16
- * measure renderers can resolve a currency code down to the org default —
17
- * instead of hard-coding `$`/`¥`/`USD` or degrading to a bare number — without
18
- * any of them depending on app-shell or making their own fetch.
13
+ * `localization.currency` / `locale` / `timezone` onto each request's
14
+ * ExecutionContext and exposes them at `GET /api/v1/auth/me/localization`. A
15
+ * fetching wrapper (in app-shell) loads them once and feeds this PURE context,
16
+ * so low-level field / measure renderers can resolve a currency code down to
17
+ * the org default — instead of hard-coding `$`/`¥`/`USD` or degrading to a
18
+ * bare number — without any of them depending on app-shell or making their own
19
+ * fetch.
19
20
  *
20
21
  * Undefined values mean "no tenant default known" → consumers render a plain
21
- * number (the existing safe behavior).
22
+ * number (the existing safe behavior), and instants render in the viewer's
23
+ * own zone.
22
24
  */
23
25
  import * as React from 'react';
24
26
  export interface LocalizationValue {
@@ -26,8 +28,29 @@ export interface LocalizationValue {
26
28
  currency?: string;
27
29
  /** Tenant/display locale (BCP-47) for Intl formatting, or undefined. */
28
30
  locale?: string;
31
+ /**
32
+ * The zone instants render in (an IANA name), or undefined for the viewer's
33
+ * own (objectui#11693). The provider hands it to the central date faces in
34
+ * `@object-ui/core`; read back from {@link useLocalization}, it is the zone
35
+ * those faces render in, so a name the runtime does not know reads back
36
+ * `undefined`.
37
+ */
38
+ timezone?: string;
29
39
  }
30
- /** Pure provider — the fetching/loading concern lives in the app shell. */
40
+ /**
41
+ * Pure provider — the fetching/loading concern lives in the app shell.
42
+ *
43
+ * It is also the ONE place the display zone enters the date faces
44
+ * (objectui#11693): `value.timezone` is handed to `setDisplayTimeZone` in
45
+ * `@object-ui/core`, which every date and datetime face reads, so no renderer
46
+ * threads a zone of its own. The hand-off runs in a layout effect, and the
47
+ * zone the context carries is read back from core through
48
+ * `useSyncExternalStore`, so the context changes only AFTER the faces have the
49
+ * new zone: every consumer then re-renders in it, before the browser paints.
50
+ *
51
+ * The display zone is one per page, as the date faces are: a second provider
52
+ * with a different zone would race this one for it, and unmounting clears it.
53
+ */
31
54
  export declare function LocalizationProvider({ value, children, }: {
32
55
  value: LocalizationValue;
33
56
  children: React.ReactNode;
@@ -8,25 +8,47 @@ import { jsx as _jsx } from "react/jsx-runtime";
8
8
  */
9
9
  /**
10
10
  * LocalizationContext — the tenant's resolved REGIONAL defaults (currency,
11
- * locale) for the current request, surfaced to every renderer.
11
+ * locale, time zone) for the current request, surfaced to every renderer.
12
12
  *
13
13
  * This is the client half of ADR-0053: the framework resolves a workspace
14
- * `localization.currency` / `locale` onto each request's ExecutionContext and
15
- * exposes them at `GET /api/v1/auth/me/localization`. A fetching wrapper (in
16
- * app-shell) loads them once and feeds this PURE context, so low-level field /
17
- * measure renderers can resolve a currency code down to the org default —
18
- * instead of hard-coding `$`/`¥`/`USD` or degrading to a bare number — without
19
- * any of them depending on app-shell or making their own fetch.
14
+ * `localization.currency` / `locale` / `timezone` onto each request's
15
+ * ExecutionContext and exposes them at `GET /api/v1/auth/me/localization`. A
16
+ * fetching wrapper (in app-shell) loads them once and feeds this PURE context,
17
+ * so low-level field / measure renderers can resolve a currency code down to
18
+ * the org default — instead of hard-coding `$`/`¥`/`USD` or degrading to a
19
+ * bare number — without any of them depending on app-shell or making their own
20
+ * fetch.
20
21
  *
21
22
  * Undefined values mean "no tenant default known" → consumers render a plain
22
- * number (the existing safe behavior).
23
+ * number (the existing safe behavior), and instants render in the viewer's
24
+ * own zone.
23
25
  */
24
26
  import * as React from 'react';
27
+ import { getDisplayTimeZone, setDisplayTimeZone, subscribeDisplayTimeZone } from '@object-ui/core';
25
28
  const LocalizationCtx = React.createContext({});
26
- /** Pure provider — the fetching/loading concern lives in the app shell. */
29
+ /**
30
+ * Pure provider — the fetching/loading concern lives in the app shell.
31
+ *
32
+ * It is also the ONE place the display zone enters the date faces
33
+ * (objectui#11693): `value.timezone` is handed to `setDisplayTimeZone` in
34
+ * `@object-ui/core`, which every date and datetime face reads, so no renderer
35
+ * threads a zone of its own. The hand-off runs in a layout effect, and the
36
+ * zone the context carries is read back from core through
37
+ * `useSyncExternalStore`, so the context changes only AFTER the faces have the
38
+ * new zone: every consumer then re-renders in it, before the browser paints.
39
+ *
40
+ * The display zone is one per page, as the date faces are: a second provider
41
+ * with a different zone would race this one for it, and unmounting clears it.
42
+ */
27
43
  export function LocalizationProvider({ value, children, }) {
44
+ const { timezone } = value;
45
+ React.useLayoutEffect(() => {
46
+ setDisplayTimeZone(timezone);
47
+ return () => setDisplayTimeZone(undefined);
48
+ }, [timezone]);
49
+ const appliedZone = React.useSyncExternalStore(subscribeDisplayTimeZone, getDisplayTimeZone, getDisplayTimeZone);
28
50
  // Stable identity unless the resolved values actually change.
29
- const memo = React.useMemo(() => ({ currency: value.currency, locale: value.locale }), [value.currency, value.locale]);
51
+ const memo = React.useMemo(() => ({ currency: value.currency, locale: value.locale, timezone: appliedZone }), [value.currency, value.locale, appliedZone]);
30
52
  return _jsx(LocalizationCtx.Provider, { value: memo, children: children });
31
53
  }
32
54
  /**
@@ -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);