Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions homedocs/src/pages/docs/concepts/localization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

<Restate detached culture={createCulture({ timezone: "Asia/Tokyo", defaultCurrency: "JPY" })} data={{ doc: m.doc }}>
<div text={tpl(m.doc.createdAt, "{0:datetime}")} />
</Restate>;
```

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
<ContentResolver
params={{ timezone: m.timezone }}
onResolve={({ timezone }) => (
<Restate detached culture={createCulture({ timezone })} data={{ doc: m.doc }}>
...
</Restate>
)}
/>
```

### Dynamic Culture Loading

Use dynamic imports for code-splitting:
Expand Down
43 changes: 43 additions & 0 deletions packages/cx/src/ui/Culture.spec.ts
Original file line number Diff line number Diff line change
@@ -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, {});
}
});
});
45 changes: 18 additions & 27 deletions packages/cx/src/ui/Culture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResolvedCultureInfo>): 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 {
Expand Down
30 changes: 30 additions & 0 deletions packages/cx/src/ui/Restate.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 = (
<cx>
<div>
<Restate detached culture={createCulture({ timezone: "UTC" })}>
<span text={time} />
</Restate>
<Restate detached culture={createCulture({ timezone: "Asia/Tokyo" })}>
<span text={time} />
</Restate>
</div>
</cx>
);

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"] },
],
});
});
});
Loading