diff --git a/.changeset/31045-locale-switcher-table-labels.md b/.changeset/31045-locale-switcher-table-labels.md new file mode 100644 index 00000000000..914bd1c65f0 --- /dev/null +++ b/.changeset/31045-locale-switcher-table-labels.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +--- + +Clause-②: no +Metadata-protocol: none + +The Console language menu now names the built-in languages from the platform +locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`): each entry +shows its row's `label`, such as `简体中文`, `繁體中文` and +`Português (Brasil)`. The menu used to keep its own list of names, keyed by +the retired `zh` and `pt` codes, so those three languages were named by the +runtime's `Intl.DisplayNames` instead, which varies by browser and ICU version +and shows the bare code where `Intl.DisplayNames` is missing. A code that is +not on the table, such as an app-shipped `th`, is still named by +`Intl.DisplayNames`. The set of languages the menu offers is unchanged. diff --git a/ui/packages/app-shell/src/layout/LocaleSwitcher.tsx b/ui/packages/app-shell/src/layout/LocaleSwitcher.tsx index 0464c1cbfbd..dfc655647be 100644 --- a/ui/packages/app-shell/src/layout/LocaleSwitcher.tsx +++ b/ui/packages/app-shell/src/layout/LocaleSwitcher.tsx @@ -7,10 +7,11 @@ * The menu is not a list of its own (objectui#4039): it renders whatever the * i18n provider says is offerable — the app's own locale list ∩ what the * renderer can resolve. This file owns only the *labels*, because the native - * names of the built-in packs are the one piece of that no API supplies: + * names are the one piece of that no API response supplies: * `GET /api/v1/i18n/locales` returns descriptors whose `label` is the code * echoed back (`toLocaleDescriptors` in @objectstack/spec), so a server label - * would put `th` in the menu where `ไทย` belongs. + * would put `th` in the menu where `ไทย` belongs. The built-in packs are named + * from the platform locale table instead, every other code by the runtime. * * It is also not a device-local setting any more (objectui#10059). A switch made * while signed in writes `sys_user.locale` — the column the profile page's @@ -28,45 +29,39 @@ import { DropdownMenuTrigger, } from '@object-ui/components'; import { useObjectTranslation } from '@object-ui/i18n'; +import { PLATFORM_LOCALES } from '@objectstack/spec/system'; import { confirmUnsavedLeave } from '../chrome/unsavedEdits.js'; import { useLanguageSelection } from '../hooks/useUserLocale.js'; /** - * Native display names for the built-in language packs. + * Native display names for the built-in language packs, read from the + * platform locale table (`PLATFORM_LOCALES` in `@objectstack/spec/system`): + * each row's `label` is the language's own name for itself, the name a + * language menu shows. The table is the one list of platform languages, so + * there is no second, hand-kept list of names here. Indexed by the row's + * canonical `tag` (`en`, `zh-Hans`, `pt-BR`, …). * - * Kept by hand rather than derived from `Intl.DisplayNames`: these are the - * names the console has always shown, they are stable across ICU versions and - * runtimes, and several read better than the CLDR rendering. Any code *not* - * in here — an app-shipped locale like `th` or `pt-BR` — is named by - * `Intl.DisplayNames` instead. A `Map` rather than an object literal so a code - * like `constructor` cannot pick a label off `Object.prototype`. + * A `Map` rather than an object literal so a code like `constructor` cannot + * pick a label off `Object.prototype`. */ -const BUILT_IN_LABELS = new Map([ - ['en', 'English'], - ['zh', '中文'], - ['ja', '日本語'], - ['ko', '한국어'], - ['de', 'Deutsch'], - ['fr', 'Français'], - ['es', 'Español'], - ['pt', 'Português'], - ['ru', 'Русский'], - ['ar', 'العربية'], -]); +const BUILT_IN_LABEL_BY_TAG: ReadonlyMap = new Map( + PLATFORM_LOCALES.map((row): [string, string] => [row.tag, row.label]), +); /** * Name a locale in its own language. * - * Built-in names win where they exist; everything else goes through - * `Intl.DisplayNames` in the locale itself, so a menu of app locales reads the - * way the built-in ten always have (native, not translated into the current - * UI language). Every failure degrades to the bare code, which is still a - * selectable, honest entry: `Intl.DisplayNames` is absent on old runtimes, - * throws `RangeError` on a malformed tag, and returns the input unchanged for - * a tag it has no name for. + * A platform locale table tag is named by its row's `label`, which is the + * same on every runtime and ICU version. Any other code (an app-shipped + * locale like `th`) goes through `Intl.DisplayNames` in the locale itself, so + * a menu of app locales reads the way the built-in ones do (native, not + * translated into the current UI language). Every failure degrades to the + * bare code, which is still a selectable, honest entry: `Intl.DisplayNames` + * is absent on old runtimes, throws `RangeError` on a malformed tag, and + * returns the input unchanged for a tag it has no name for. */ export function localeLabel(code: string): string { - const builtIn = BUILT_IN_LABELS.get(code); + const builtIn = BUILT_IN_LABEL_BY_TAG.get(code); if (builtIn) return builtIn; try { const name = new Intl.DisplayNames([code], { type: 'language' }).of(code); diff --git a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx index 0bb5bdc1dce..e214e4e11d6 100644 --- a/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx +++ b/ui/packages/app-shell/src/layout/__tests__/LocaleSwitcher.appLocales.test.tsx @@ -37,13 +37,13 @@ vi.mock('lucide-react', async (importOriginal) => ({ Globe: () => , })); -import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; +import { PLATFORM_LOCALES, PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system'; import { LocaleSwitcher, localeLabel } from '../LocaleSwitcher'; /** - * The eight built-in native names an app shipping only `en`+`zh` must NOT show: - * every platform locale table language but English and Chinese, named as the - * menu names them (`pt-BR` by `Intl.DisplayNames`, objectstack#30511). + * The eight built-in native names an app shipping only `en`+`zh-Hans` must NOT + * show: every platform locale table language but English and Chinese, named as + * the menu names them (from the table's own labels). */ const PHANTOM_LABELS = PLATFORM_LOCALE_TAGS .filter((tag) => tag !== 'en' && !tag.startsWith('zh-')) @@ -75,12 +75,12 @@ beforeEach(() => { describe('LocaleSwitcher — menu composed from the app locale list', () => { it('offers an app-shipped locale outside the built-in ten, named by Intl.DisplayNames', async () => { - mountConsole({ locales: async () => ['en', 'zh', 'th'] }); + mountConsole({ locales: async () => ['en', 'zh-Hans', 'th'] }); - // `th` has no built-in pack and no hand-written label; CLDR names it. + // `th` has no built-in pack and no platform locale table row; CLDR names it. await waitFor(() => expect(screen.getByText('ไทย')).toBeInTheDocument()); expect(screen.getByText('English')).toBeInTheDocument(); - expect(screen.getByText('中文')).toBeInTheDocument(); + expect(screen.getByText('简体中文')).toBeInTheDocument(); }); it('offers an app-shipped locale supplied through config.resources', async () => { @@ -106,10 +106,10 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => { }); it('shows ONLY what the app ships — the phantom eight are gone', async () => { - mountConsole({ locales: async () => ['en', 'zh'] }); + mountConsole({ locales: async () => ['en', 'zh-Hans'] }); await waitFor(() => expect(screen.getByText('English')).toBeInTheDocument()); - expect(screen.getByText('中文')).toBeInTheDocument(); + expect(screen.getByText('简体中文')).toBeInTheDocument(); for (const phantom of PHANTOM_LABELS) { expect(screen.queryByText(phantom)).not.toBeInTheDocument(); } @@ -154,7 +154,7 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => { expect(container.firstChild).toBeNull(); expect(screen.queryByText('English')).not.toBeInTheDocument(); - answer(['en', 'zh']); + answer(['en', 'zh-Hans']); await waitFor(() => expect(screen.getByText('English')).toBeInTheDocument()); expect(screen.queryByText('日本語')).not.toBeInTheDocument(); @@ -175,3 +175,40 @@ describe('LocaleSwitcher — menu composed from the app locale list', () => { } }); }); + +describe('localeLabel — the built-in packs are named by the platform locale table', () => { + it('names every table tag with its row label', () => { + for (const row of PLATFORM_LOCALES) { + expect(localeLabel(row.tag), row.tag).toBe(row.label); + } + }); + + it('names every table tag with its row label on a runtime without Intl.DisplayNames', () => { + // The table label must not be whatever the runtime's CLDR happens to say: + // with the runtime naming nothing, every row still reads its own label. + const displayNames = Intl.DisplayNames; + Object.defineProperty(Intl, 'DisplayNames', { + configurable: true, + writable: true, + value: undefined, + }); + try { + for (const row of PLATFORM_LOCALES) { + expect(localeLabel(row.tag), row.tag).toBe(row.label); + } + // An app-shipped code still degrades to the bare code, not a crash. + expect(localeLabel('th')).toBe('th'); + } finally { + Object.defineProperty(Intl, 'DisplayNames', { + configurable: true, + writable: true, + value: displayNames, + }); + } + }); + + it('cannot read a label off Object.prototype', () => { + expect(localeLabel('constructor')).toBe('constructor'); + expect(localeLabel('__proto__')).toBe('__proto__'); + }); +});