From 925ececab6f5c50cc6c6f67bb2834e770d559ae7 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 2 Oct 2026 11:32:58 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(frontend)=20select=20a=20single=20?= =?UTF-8?q?language=20in=20the=20language=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The language picker marked an option as selected when it matched the current language, falling back to a match on the language alone. With the current language zh-tw, the zh-cn option matched too, so several regions of a language were selected at once, and the legacy picker could show the label of the wrong one. Now that zh-cn and zh-tw are separate frontend translations, this shows up for the Chinese locales, and it would for any language with several regions (en-us/en-gb, pt-pt/pt-br). Add getClosestLocale, which returns the one available locale closest to the current language (the exact match first, then the first region of the language), and use it in both pickers to select the option and to display the current label. --- .../language/components/LanguagePicker.tsx | 23 +++++++--- .../language/utils/__tests__/locale.test.ts | 44 +++++++++++++++++++ .../src/features/language/utils/locale.ts | 16 +++++++ 3 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/language/utils/__tests__/locale.test.ts 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]; +}