diff --git a/homedocs/src/pages/docs/concepts/localization.mdx b/homedocs/src/pages/docs/concepts/localization.mdx index 48cb6ec02..e25f49a15 100644 --- a/homedocs/src/pages/docs/concepts/localization.mdx +++ b/homedocs/src/pages/docs/concepts/localization.mdx @@ -49,6 +49,44 @@ Available locale packages include: `de-de`, `en-us`, `es-es`, `fr-fr`, `nl-nl`, | `Culture.setDefaultTimezone(tz)` | Sets the default timezone. | | `Culture.setDefaultDateEncoding(fn)` | Sets function for encoding dates. | +These methods configure the default culture. Call them before the content is rendered: they apply to content +rendered after the call, while widgets that already exist may keep the previous settings. If you change settings at +runtime, re-render the content afterwards, as shown in [Dynamic Culture Loading](#dynamic-culture-loading). + +### Culture Scopes + +To format part of the page with a different culture, timezone or currency, for example to show documents for +different timezones side by side, wrap that content in a detached `Restate` with its own culture created by +`createCulture`: + +```tsx +import { createCulture, enableCultureSensitiveFormatting, Restate, tpl } from "cx/ui"; + +enableCultureSensitiveFormatting(); + + +
+; +``` + +Each culture created by `createCulture` has its own format cache. `culture`, `defaultCurrency` and `dateEncoding` +are taken from the current culture unless specified; other settings are not. Culture scopes require culture-sensitive +formatting, which is enabled automatically by some widgets, such as `NumberField`. + +The culture of a scope is set when the scope is created. To switch it, create a new scope, for example inside +a `ContentResolver`: + +```tsx + ( + + ... + + )} +/> +``` + ### Dynamic Culture Loading Use dynamic imports for code-splitting: diff --git a/packages/cx/src/ui/Culture.spec.ts b/packages/cx/src/ui/Culture.spec.ts new file mode 100644 index 000000000..8cdd466e8 --- /dev/null +++ b/packages/cx/src/ui/Culture.spec.ts @@ -0,0 +1,43 @@ +import assert from "assert"; +import { Culture, getDefaultCulture } from "./Culture"; + +describe("Culture", function () { + afterEach(function () { + Object.assign(getDefaultCulture(), { + numberCulture: null, + dateTimeCulture: null, + defaultCurrency: "USD", + timezone: null, + dateEncoding: (date: Date) => date.toISOString(), + }); + Culture.setCulture("en"); + }); + + it("setDefaultTimezone applies to an already used date-time culture", function () { + let date = new Date("2026-04-24T12:00:00Z"); + Culture.setDefaultTimezone("UTC"); + assert.equal(Culture.getDateTimeCulture().getFormatter("HHmmN").format(date), "12:00"); + Culture.setDefaultTimezone("Asia/Tokyo"); + assert.equal(Culture.getDateTimeCulture().getFormatter("HHmmN").format(date), "21:00"); + }); + + // With culture-sensitive formatting, formatters and compiled expressions and templates + // are cached on the culture and hold the culture objects they were built with. + it("setters drop everything cached on the default culture", function () { + let setters = [ + () => Culture.setCulture("de"), + () => Culture.setNumberCulture("de-DE"), + () => Culture.setDateTimeCulture("de-DE"), + () => Culture.setDefaultCurrency("EUR"), + () => Culture.setDefaultTimezone("Asia/Tokyo"), + () => Culture.setDefaultDateEncoding((date) => date.toDateString()), + ]; + for (let set of setters) { + let cache = getDefaultCulture().cache; + cache.numberCulture = cache.dateTimeCulture = {}; + cache.formatCache = cache.exprCache = cache.strTplCache = {}; + set(); + assert.deepEqual(getDefaultCulture().cache, {}); + } + }); +}); diff --git a/packages/cx/src/ui/Culture.ts b/packages/cx/src/ui/Culture.ts index f9a64895f..9fde292d9 100644 --- a/packages/cx/src/ui/Culture.ts +++ b/packages/cx/src/ui/Culture.ts @@ -77,45 +77,36 @@ export function popCulture(cultureSpecs?: CultureInfo): CultureInfo | undefined return stack.pop(); } +// formatters, expressions and templates cached on the culture are built from its settings, so drop them all +function updateDefaultCulture(changes: Partial): void { + Object.assign(getDefaultCulture(), changes, { cache: {} }); + Culture.invalidateCache(); +} + export class Culture { - static setCulture(cultureCode: string): void { - let cultureSpecs = getDefaultCulture(); - cultureSpecs.culture = cultureCode; - cultureSpecs.cache = {}; - Localization.setCulture(cultureCode); - this.invalidateCache(); + static setCulture(culture: string): void { + Localization.setCulture(culture); + updateDefaultCulture({ culture }); } - static setNumberCulture(cultureCode: string): void { - let cultureSpecs = getDefaultCulture(); - cultureSpecs.numberCulture = cultureCode; - delete cultureSpecs.cache.numberCulture; - this.invalidateCache(); + static setNumberCulture(numberCulture: string): void { + updateDefaultCulture({ numberCulture }); } - static setDateTimeCulture(cultureCode: string): void { - let cultureSpecs = getDefaultCulture(); - cultureSpecs.dateTimeCulture = cultureCode; - delete cultureSpecs.cache.dateTimeCulture; - this.invalidateCache(); + static setDateTimeCulture(dateTimeCulture: string): void { + updateDefaultCulture({ dateTimeCulture }); } - static setDefaultCurrency(currencyCode: string): void { - let cultureSpecs = getDefaultCulture(); - cultureSpecs.defaultCurrency = currencyCode; - this.invalidateCache(); + static setDefaultCurrency(defaultCurrency: string): void { + updateDefaultCulture({ defaultCurrency }); } static setDefaultTimezone(timezone: string): void { - let cultureSpecs = getDefaultCulture(); - cultureSpecs.timezone = timezone; - this.invalidateCache(); + updateDefaultCulture({ timezone }); } - static setDefaultDateEncoding(encoding: (date: Date) => string): void { - let cultureSpecs = getDefaultCulture(); - cultureSpecs.dateEncoding = encoding; - this.invalidateCache(); + static setDefaultDateEncoding(dateEncoding: (date: Date) => string): void { + updateDefaultCulture({ dateEncoding }); } static invalidateCache(): void { diff --git a/packages/cx/src/ui/Restate.spec.tsx b/packages/cx/src/ui/Restate.spec.tsx index 090c4df0a..7b2a65f52 100644 --- a/packages/cx/src/ui/Restate.spec.tsx +++ b/packages/cx/src/ui/Restate.spec.tsx @@ -5,6 +5,7 @@ import assert from "assert"; import { Controller } from "./Controller"; import { bind } from "./bind"; import { FirstVisibleChildLayout } from "./layout/FirstVisibleChildLayout"; +import { Culture, createCulture } from "./Culture"; describe("Restate", () => { it("provides a blank slate", async () => { @@ -419,4 +420,33 @@ describe("Restate", () => { children: [{ type: "div", props: {}, children: ["Second"] }], }); }); + + it("renders detached content in its own culture", async () => { + let time = () => + Culture.getDateTimeCulture().getFormatter("HHmmN").format(new Date("2026-04-24T12:00:00Z")); + + let widget = ( + +
+ + + + + + +
+
+ ); + + const component = await createTestRenderer(new Store(), widget); + + assert.deepEqual(component.toJSON(), { + type: "div", + props: {}, + children: [ + { type: "span", props: {}, children: ["12:00"] }, + { type: "span", props: {}, children: ["21:00"] }, + ], + }); + }); });