@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.
- package/CHANGELOG.md +5408 -0
- package/README.md +255 -13
- package/dist/LocalizationContext.d.ts +32 -9
- package/dist/LocalizationContext.js +32 -10
- 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 +27 -1
- package/dist/i18n.js +99 -16
- package/dist/index.d.ts +8 -13
- package/dist/index.js +45 -17
- package/dist/locales/ar.d.ts +1504 -188
- package/dist/locales/ar.js +1711 -281
- package/dist/locales/de.d.ts +1080 -188
- package/dist/locales/de.js +1248 -261
- package/dist/locales/en.d.ts +1141 -249
- package/dist/locales/en.js +1714 -295
- package/dist/locales/es.d.ts +1186 -188
- package/dist/locales/es.js +1350 -255
- package/dist/locales/fr.d.ts +1186 -188
- package/dist/locales/fr.js +1351 -258
- package/dist/locales/index.d.ts +11940 -2033
- package/dist/locales/index.js +48 -9
- package/dist/locales/ja.d.ts +1080 -188
- package/dist/locales/ja.js +1240 -254
- package/dist/locales/ko.d.ts +1080 -188
- package/dist/locales/ko.js +1244 -258
- package/dist/locales/pt.d.ts +1186 -188
- package/dist/locales/pt.js +1350 -255
- 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 +1292 -188
- package/dist/locales/ru.js +1479 -272
- package/dist/locales/zh.d.ts +1144 -252
- package/dist/locales/zh.js +1245 -259
- package/dist/pickLocalized.d.ts +30 -0
- package/dist/pickLocalized.js +39 -0
- package/dist/provider.d.ts +101 -12
- 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 +11 -0
- package/dist/useObjectLabel.d.ts +143 -100
- package/dist/useObjectLabel.js +129 -82
- package/dist/useSafeTranslation.js +40 -42
- package/dist/utils/first-day-of-week.d.ts +71 -0
- package/dist/utils/first-day-of-week.js +61 -0
- package/dist/utils/formatting.d.ts +23 -1
- package/dist/utils/formatting.js +34 -5
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- 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/README.md
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
# @object-ui/i18n
|
|
2
2
|
|
|
3
|
-
Internationalization for Object UI —
|
|
3
|
+
Internationalization for Object UI — 10 built-in locales, RTL support, and date/currency formatting.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- 🌍 **
|
|
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
|
-
|
|
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');
|
|
116
|
-
formatCurrency(99.99, 'USD', 'en');
|
|
117
|
-
formatNumber(1234567, 'de');
|
|
118
|
-
formatRelativeTime(-3
|
|
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
|
-
|
|
261
|
+
A tag `Intl` refuses (`en_US`) throws the same `RangeError` that formatting a
|
|
262
|
+
date with it would.
|
|
122
263
|
|
|
123
|
-
|
|
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 {
|
|
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
|
|
14
|
-
* exposes them at `GET /api/v1/auth/me/localization`. A
|
|
15
|
-
* app-shell) loads them once and feeds this PURE context,
|
|
16
|
-
* measure renderers can resolve a currency code down to
|
|
17
|
-
* instead of hard-coding `$`/`¥`/`USD` or degrading to a
|
|
18
|
-
* any of them depending on app-shell or making their own
|
|
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
|
-
/**
|
|
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
|
|
15
|
-
* exposes them at `GET /api/v1/auth/me/localization`. A
|
|
16
|
-
* app-shell) loads them once and feeds this PURE context,
|
|
17
|
-
* measure renderers can resolve a currency code down to
|
|
18
|
-
* instead of hard-coding `$`/`¥`/`USD` or degrading to a
|
|
19
|
-
* any of them depending on app-shell or making their own
|
|
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
|
-
/**
|
|
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
|
+
}
|
package/dist/currency.d.ts
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:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
48
|
+
(config?.currencyMode === 'fixed' ? config.defaultCurrency : undefined) ||
|
|
25
49
|
field?.defaultCurrency ||
|
|
26
50
|
tenantDefault ||
|
|
27
51
|
undefined);
|