diff --git a/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx index a9b60836e..47bc98937 100644 --- a/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx @@ -10,10 +10,7 @@ import { css } from 'styled-components'; import { Box, DropdownMenu, Icon } from '@/components/'; import { useConfig } from '@/core'; import { useAuthQuery } from '@/features/auth'; -import { - getMatchingLocales, - useSynchronizedLanguage, -} from '@/features/language'; +import { getClosestLocale, useSynchronizedLanguage } from '@/features/language'; import { useResponsiveStore } from '@/stores/useResponsiveStore'; /** @@ -35,12 +32,16 @@ export const LanguagePickerLegacy = () => { // Compute options for dropdown const optionsPicker = useMemo(() => { const backendOptions = conf?.LANGUAGES ?? [[language, language]]; + const currentLocale = getClosestLocale( + backendOptions.map(([locale]) => locale), + language, + ); return backendOptions.map(([backendLocale, backendLabel]) => { return { label: backendLabel, lang: toLangTag(backendLocale), value: backendLocale, - isSelected: getMatchingLocales([backendLocale], [language]).length > 0, + isSelected: backendLocale === currentLocale, callback: async () => { await changeLanguageSynchronized(backendLocale, user); announce( @@ -56,8 +57,12 @@ export const LanguagePickerLegacy = () => { }, [changeLanguageSynchronized, conf?.LANGUAGES, language, t, user]); // Extract current language label for display + const currentLocale = getClosestLocale( + conf?.LANGUAGES.map(([code]) => code) ?? [], + language, + ); const [currentLanguageCode, currentLanguageLabel] = conf?.LANGUAGES.find( - ([code]) => getMatchingLocales([code], [language]).length > 0, + ([code]) => code === currentLocale, ) ?? [language, language]; return ( @@ -109,10 +114,14 @@ export const LanguagePicker = () => { const languages: LanguagesOption[] = useMemo(() => { const backendOptions = conf?.LANGUAGES ?? [[language, language]]; + const currentLocale = getClosestLocale( + backendOptions.map(([locale]) => locale), + language, + ); return backendOptions.map(([backendLocale, backendLabel]) => ({ label: backendLabel, value: backendLocale, - isChecked: getMatchingLocales([backendLocale], [language]).length > 0, + isChecked: backendLocale === currentLocale, })); }, [conf?.LANGUAGES, language]); diff --git a/src/frontend/apps/impress/src/features/language/utils/__tests__/locale.test.ts b/src/frontend/apps/impress/src/features/language/utils/__tests__/locale.test.ts new file mode 100644 index 000000000..4528b12e1 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/utils/__tests__/locale.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { getClosestLocale, getMatchingLocales } from '../locale'; + +const LOCALES = ['en-us', 'fr-fr', 'zh-cn', 'zh-tw']; + +describe('getMatchingLocales', () => { + it('returns every region of a language on a soft match', () => { + expect(getMatchingLocales(LOCALES, ['zh'])).toEqual(['zh-cn', 'zh-tw']); + }); + + it('only returns the exact match when there is one', () => { + expect(getMatchingLocales(LOCALES, ['zh-TW'])).toEqual(['zh-tw']); + }); +}); + +describe('getClosestLocale', () => { + it.each([ + ['zh-tw', 'zh-tw'], + ['zh-cn', 'zh-cn'], + ['zh-TW', 'zh-tw'], + ['fr', 'fr-fr'], + ['fr-CA', 'fr-fr'], + ['en', 'en-us'], + ])('resolves %s to %s', (language, expected) => { + expect(getClosestLocale(LOCALES, language)).toBe(expected); + }); + + it('prefers the exact match over the first region of the language', () => { + expect(getClosestLocale(['en-us', 'en-gb'], 'en-gb')).toBe('en-gb'); + }); + + it('returns a single locale, never every region', () => { + const selected = LOCALES.filter( + (locale) => locale === getClosestLocale(LOCALES, 'zh-tw'), + ); + expect(selected).toEqual(['zh-tw']); + }); + + it('returns undefined when no locale shares the language', () => { + expect(getClosestLocale(LOCALES, 'ja')).toBeUndefined(); + expect(getClosestLocale([], 'fr')).toBeUndefined(); + }); +}); diff --git a/src/frontend/apps/impress/src/features/language/utils/locale.ts b/src/frontend/apps/impress/src/features/language/utils/locale.ts index 19868b944..666619b8a 100644 --- a/src/frontend/apps/impress/src/features/language/utils/locale.ts +++ b/src/frontend/apps/impress/src/features/language/utils/locale.ts @@ -47,3 +47,19 @@ export function getMatchingLocales( } return matchingLocales; } + +/** + * Finds the single available locale closest to the given one. Unlike + * getMatchingLocales, which returns every region of a language on a soft + * match, this is what to use to mark one entry as selected. + * + * @param {readonly string[]} localesAvailable - The list of available locale strings. + * @param {string} locale - The locale to search for. + * @returns {string | undefined} The exact match, else the first locale sharing the language. + */ +export function getClosestLocale( + localesAvailable: readonly string[], + locale: string, +): string | undefined { + return getMatchingLocales(localesAvailable, [locale])[0]; +}