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
16 changes: 16 additions & 0 deletions .changeset/31045-locale-switcher-table-labels.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 24 additions & 29 deletions ui/packages/app-shell/src/layout/LocaleSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, string>([
['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<string, string> = 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ vi.mock('lucide-react', async (importOriginal) => ({
Globe: () => <span />,
}));

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-'))
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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();
}
Expand Down Expand Up @@ -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();
Expand All @@ -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__');
});
});
Loading