🐛(frontend) select a single language in the language picker

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.
This commit is contained in:
Anthony LC
2026-10-02 12:12:53 +02:00
parent c39c51a756
commit 925ececab6
3 changed files with 76 additions and 7 deletions
@@ -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]);
@@ -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();
});
});
@@ -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];
}