busy-office-ui
Menu

Internationalization

ERP screens are dense, and dense screens are where translation breaks: German runs ~35% longer than English, Arabic mirrors the whole layout, and a date written the American way is a support call in Europe. What the framework handles, what it deliberately leaves to you, and what to check before shipping a locale.

RTL: one attribute, because the CSS is logical

<!-- One attribute. The framework's CSS is written in LOGICAL properties
     (inline-size, padding-inline, inset-inline-start), so layout mirrors
     without a separate stylesheet. -->
<html lang="ar" dir="rtl">

The stylesheet uses logical properties throughout — inline-size, padding-inline, inset-inline-start, text-align: start — so a right-to-left document mirrors without a mirrored stylesheet. Six places need an explicit flip, because CSS gives no logical form for what they use — and all six have one: the select chevron (a background image has no logical position), the tree and tree-table disclosure chevrons (a glyph does not mirror — logical properties move boxes, not the character inside them), the off-canvas drawer slide direction, and the slide-in-inline-start animation (transform is physical; there is no logical translate), and the data table's row-state stripe and data-tone cell bar (an inset box-shadow's x-offset is physical). The count is gated: a seventh unflipped case fails the build rather than shipping a stylesheet that mirrors most of the way. Both halves of this section were prose until 2026-08-17, and executing them found two real defects — the animation slid in from the wrong edge despite its logical name, and tree-table was missing the flip its sibling tree already had. The sixth was found the same way in 2026-08-23, from an owner question about the bar itself: the stripe rendered inset 3px 0 0 byte-identically in both directions — so it sat on the row's TRAILING edge in RTL — while its own forced-colors fallback used logical border-inline-start and did flip. Two channels for the same marker, disagreeing about which edge means start. The gate could not see it, because a box-shadow is not a physical box property; it now checks inset x-offsets too.

Your CSS must follow. A single margin-left in app code re-breaks RTL. Numbers stay left-to-right inside RTL text — that is correct, not a bug.

Numbers, currency and dates are your data

<!-- Format on the SERVER, in the user's locale, and send the string.
     The framework aligns and spaces it; it never parses or reformats. -->
<td class="bo-data-table__col--numeric">1.234,56 €</td>

<!-- Client-side, if you must: -->
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1234.56)
new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date())

The framework never parses or reformats your values — a deliberate boundary, because a UI that guesses at number formats eventually corrupts an amount. What it does provide: font-variant-numeric: tabular-nums so digits align in columns, right alignment for numeric columns, and — for editing — the money and quantity fields that derive decimal precision from the selected currency or unit, and reformat only losslessly (they pad and trim zeros, never round).

Two rules that prevent the classic bugs: format on the server where the locale is known, and keep the machine value separate from the display string (that is exactly what data-value does on a combobox and what a hidden input carries in a form).

Worked examples — Singapore and Thailand

Two locales that between them contain most of the traps an ERP hits in the region. Every output below was produced by running Intl:

// Every string below was produced by running Intl, not written by hand,
// and a build check re-runs them so an ICU update cannot make this page lie.

new Intl.NumberFormat("en-SG", { style: "currency", currency: "SGD" }).format(1234567.5)
// "$1,234,567.50"   <- a BARE dollar sign

new Intl.NumberFormat("en-SG", { style: "currency", currency: "USD" }).format(1234.5)
// "US$1,234.50"     <- the foreign one gets disambiguated, the local one does not

new Intl.NumberFormat("en-SG", { style: "currency", currency: "SGD",
                                 currencyDisplay: "code" }).format(1234.5)
// "SGD 1,234.50"    <- what a multi-currency column wants

new Intl.NumberFormat("th-TH", { style: "currency", currency: "THB" }).format(1234567.5)
// "฿1,234,567.50"

new Intl.DateTimeFormat("th-TH", { dateStyle: "medium" }).format(new Date("2026-08-17"))
// "17 ส.ค. 2569"    <- the BUDDHIST era: 2569, not 2026

new Intl.DateTimeFormat("th-TH-u-ca-gregory", { dateStyle: "medium" }).format(new Date("2026-08-17"))
// "17 ส.ค. 2026"    <- what an audit trail needs

Thai dates default to the Buddhist era. th-TH resolves to calendar: "buddhist", so a 2026 date renders as 2569 — a 543-year offset, in the timestamps an audit trail is built on. Ask for th-TH-u-ca-gregory wherever the year is a record rather than a courtesy. (Thai digits are not a default: the numbering system resolves to latn, and opting into -u-nu-thai gives ฿๑,๒๓๔.๕๐, which no amount column should use — it defeats tabular alignment and any parsing.)

The Singapore case is subtler: in en-SG, SGD gets a bare $ while USD gets US$. That is Intl being correct about the local currency, and wrong for a multi-currency column, where the reader cannot tell which dollar they are looking at. Use currencyDisplay: "code" for any column that can hold more than one currency — SGD 1,234.50 is unambiguous and, being the same width for every code, keeps the column aligned.

Entry precision is not display precision

The framework's currencyDecimals() (used by the money field to set an input's step) follows ISO 4217 minor units — what a currency formally subdivides into, and therefore what a user may type and the system may store. Intl reports something else: the CLDR display convention, which drops the minor unit for currencies where it exists on paper but not in shops.

// Two DIFFERENT questions, two different answers:
currencyDecimals("IDR")   // 2  — ISO 4217 minor units: what you may ENTER and store
new Intl.NumberFormat("en", { style: "currency", currency: "IDR" })
  .resolvedOptions().maximumFractionDigits   // 0 — CLDR display convention

// Using the display answer to set entry precision silently truncates cents.

Both are right about their own question, and they disagree for exactly seven codes at the time of writing — IQD, IDR, HUF, COP, PKR, MMK, LAK. Measured, not assumed: a build check walks the framework's table against ICU and reports the divergence, so it stays a documented list rather than a surprise.

The rule: take entry and storage precision from ISO minor units (the framework already does), and take the rendered string from Intl. Deriving step from Intl is the bug this section exists to prevent — on an IDR or HUF amount it silently truncates whatever the user typed after the decimal point. And JPY really is zero-decimal in both answers, so "always two" is wrong even before you reach the edge cases.

String expansion — the dense-screen killer

  • Table headers wrap, they do not truncate. Verify at compact density in your longest locale — that is the combination that breaks first.
  • Buttons size to content, so a long label grows the button rather than clipping. Fixed-width action columns are the anti-pattern.
  • Don't build sentences from fragments. "Approve" + n + "invoices" cannot be translated into languages that inflect; pass the whole sentence with a placeholder.
  • Badges and chips are the tightest spaces — prefer a short status vocabulary you control over free translation.
  • Test with a pseudo-locale (every string ×1.4, accented) before the first real translation lands; it finds clipping without a translator. This framework does: a CI gate expands every visible string ~35% with accents, forces compact density, and fails the build if anything clips or the shell scrolls sideways — eight text-dense pages at desktop and phone width. The claims above are measured, not asserted.

Timezones in an audit trail

Audit timestamps are legal records. Store UTC, render in the viewer's zone, and say which zone you rendered — "2026-08-16 15:02 CET" is unambiguous; "15:02" is not. Use a real <time datetime="…"> element so the machine-readable instant travels with the display string, and keep a fixed format in audit trails rather than relative time ("2 hours ago" is unusable in a dispute).

↑↓ move Enter open Esc close