feat(client): add nine interface languages and a searchable language picker

Traditional Chinese cannot reuse the `%two_letters_code%` filename because it would collide with
Simplified under `zh.json`, so `crowdin.yml` maps `zh-TW` to a `zh-Hant` catalog and locale
matching resolves by script subtag before falling back to the base language. Every other new
locale keeps its bare two-letter ID.

Twenty-five entries outgrew the old menus. The mobile submenu inherited `overflow-hidden` with no
max-height, so the last nine languages were unreachable on a phone, and the list was ordered by
ISO code while displaying endonyms, which reads as unsorted. Both header menus now render one
LanguagePicker that searches the native name, the English name and the locale code, groups the
browser's own languages as suggestions, and orders the rest with a collator on the string it
actually shows. Mobile opens a sheet instead of a nested flyout, since a flyout is the wrong
shape for touch regardless of height. Settings gains the Appearance language section its copy
has anticipated since the keys were added.

Tighten the Crowdin progress chart, which the longer language list had stretched to 810px tall.
This commit is contained in:
neon
2026-08-11 07:25:09 +00:00
parent 953c9e334b
commit 7dcc509a54
31 changed files with 573 additions and 88 deletions
+1
View File
@@ -19,6 +19,7 @@ const CROWDIN_LANGUAGE_ID_OVERRIDES = new Map([
['pt', 'pt-BR'],
['sv', 'sv-SE'],
['zh', 'zh-CN'],
['zh-Hant', 'zh-TW'],
])
for (const locale of CROWDIN_LANGUAGE_ID_OVERRIDES.keys()) {
+35 -28
View File
@@ -17,7 +17,6 @@ import {
Star,
ExternalLink,
Sparkles,
Check,
Languages,
} from '@lucide/vue'
import { useRouter, useRoute } from 'vue-router'
@@ -38,7 +37,9 @@ import {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import AccentPicker from '@/components/AccentPicker.vue'
import LanguagePicker from '@/components/LanguagePicker.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import RadiusPicker from '@/components/RadiusPicker.vue'
import BackgroundPicker from '@/components/BackgroundPicker.vue'
@@ -55,7 +56,7 @@ import NotificationSheet from '@/features/notifications/components/NotificationS
import { useNotifications } from '@/features/notifications/composables/useNotifications'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import UserAvatar from '@/components/UserAvatar.vue'
import { DEFAULT_FORMAT_PRIORITY, LOCALE_LABELS, SUPPORTED_LOCALES, type Locale } from '@bookorbit/types'
import { DEFAULT_FORMAT_PRIORITY, LOCALE_LABELS, type Locale } from '@bookorbit/types'
import { useThemeStore } from '@/stores/theme'
import { useLocaleStore } from '@/stores/locale'
import { getFormatColor } from '@/features/book/lib/format-colors'
@@ -71,7 +72,7 @@ const { subscribe: subscribeNotifications } = useNotifications()
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
const themeStore = useThemeStore()
const localeStore = useLocaleStore()
const languageOptions = SUPPORTED_LOCALES.map((id) => ({ id, label: LOCALE_LABELS[id] }))
const currentLanguageLabel = computed(() => LOCALE_LABELS[localeStore.locale])
const documentationUrl = 'https://bookorbit.app/what-is-bookorbit'
const githubRepositoryUrl = 'https://github.com/bookorbit/bookorbit'
const githubStarPopoverOpen = ref(false)
@@ -125,7 +126,13 @@ function navigateToSettings() {
router.push({ name: 'settings-libraries' })
}
const languageSheetOpen = ref(false)
const languagePopoverOpen = ref(false)
async function selectLanguage(locale: Locale) {
languageSheetOpen.value = false
languagePopoverOpen.value = false
try {
await localeStore.setLocale(locale)
} catch {
@@ -133,6 +140,10 @@ async function selectLanguage(locale: Locale) {
}
}
function openLanguageSheet() {
languageSheetOpen.value = true
}
function navigateToWhatsNew() {
router.push({ name: 'whats-new' })
}
@@ -667,19 +678,11 @@ function formatBadgeStyle(fmt: string) {
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Languages :size="15" class="mr-2 text-muted-foreground" />
{{ t('settings.appearance.language.label') }}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent class="w-44">
<DropdownMenuItem v-for="option in languageOptions" :key="option.id" @click="selectLanguage(option.id)">
<Check v-if="localeStore.locale === option.id" :size="14" class="mr-2" />
<span v-else class="mr-2 w-3.5" />
{{ option.label }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem @click="openLanguageSheet">
<Languages :size="15" class="mr-2 text-muted-foreground" />
{{ t('settings.appearance.language.label') }}
<span class="ms-auto ps-3 text-xs text-muted-foreground">{{ currentLanguageLabel }}</span>
</DropdownMenuItem>
<DropdownMenuItem @click="navigateToSettings">
<Settings :size="15" class="mr-2 text-muted-foreground" />
@@ -851,9 +854,9 @@ function formatBadgeStyle(fmt: string) {
</Tooltip>
<Tooltip>
<DropdownMenu>
<Popover v-model:open="languagePopoverOpen">
<TooltipTrigger as-child>
<DropdownMenuTrigger as-child>
<PopoverTrigger as-child>
<Button
data-testid="language-control"
variant="ghost"
@@ -863,17 +866,12 @@ function formatBadgeStyle(fmt: string) {
>
<Languages :size="15" />
</Button>
</DropdownMenuTrigger>
</PopoverTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuLabel>{{ t('settings.appearance.language.label') }}</DropdownMenuLabel>
<DropdownMenuItem v-for="option in languageOptions" :key="option.id" @click="selectLanguage(option.id)">
<Check v-if="localeStore.locale === option.id" :size="14" class="mr-2" />
<span v-else class="mr-2 w-3.5" />
{{ option.label }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<PopoverContent align="end" class="w-80 p-1">
<LanguagePicker :autofocus="languagePopoverOpen" class="max-h-[min(26rem,calc(100dvh-8rem))]" @select="selectLanguage" />
</PopoverContent>
</Popover>
<TooltipContent>{{ t('settings.appearance.language.label') }}</TooltipContent>
</Tooltip>
@@ -929,4 +927,13 @@ function formatBadgeStyle(fmt: string) {
</header>
<BookUploadModal v-if="uploadOpen" @close="uploadOpen = false" @uploaded="uploadOpen = false" />
<Sheet v-model:open="languageSheetOpen">
<SheetContent side="bottom" class="h-[85dvh] rounded-t-xl px-2 pb-2">
<SheetHeader class="pb-1">
<SheetTitle>{{ t('settings.appearance.language.label') }}</SheetTitle>
</SheetHeader>
<LanguagePicker :autofocus="false" class="min-h-0 flex-1" @select="selectLanguage" />
</SheetContent>
</Sheet>
</template>
+142
View File
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Check, Search } from '@lucide/vue'
import { LOCALE_LABELS, SUPPORTED_LOCALES, type Locale } from '@bookorbit/types'
import { Input } from '@/components/ui/input'
import { matchSupportedLocale, useLocaleStore } from '@/stores/locale'
const props = withDefaults(defineProps<{ autofocus?: boolean }>(), { autofocus: false })
const emit = defineEmits<{ select: [locale: Locale] }>()
const { t } = useI18n()
const localeStore = useLocaleStore()
const query = ref('')
const searchInput = ref<InstanceType<typeof Input> | null>(null)
// `zh` names the Simplified catalog, but Intl resolves the bare tag to plain "Chinese",
// which reads as the parent of both scripts next to "Traditional Chinese".
const ENGLISH_NAME_TAGS: Partial<Record<Locale, string>> = { zh: 'zh-Hans' }
const englishNames = computed(() => {
const display = new Intl.DisplayNames(['en'], { type: 'language' })
const names = {} as Record<Locale, string>
for (const locale of SUPPORTED_LOCALES) {
names[locale] = display.of(ENGLISH_NAME_TAGS[locale] ?? locale) ?? LOCALE_LABELS[locale]
}
return names
})
interface LanguageOption {
id: Locale
label: string
englishName: string
}
const allLanguages = computed<LanguageOption[]>(() => {
const collator = new Intl.Collator('en')
return SUPPORTED_LOCALES.map((id) => ({ id, label: LOCALE_LABELS[id], englishName: englishNames.value[id] })).sort((a, b) =>
collator.compare(a.label, b.label),
)
})
// The browser's own preference order is the best guess we have, and it is the same
// signal the store uses to pick a default before anyone has chosen one.
const suggested = computed<LanguageOption[]>(() => {
const ids: Locale[] = [localeStore.locale]
if (typeof navigator !== 'undefined') {
const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0 ? navigator.languages : [navigator.language]
for (const candidate of candidates) {
if (typeof candidate !== 'string') continue
const matched = matchSupportedLocale([candidate])
if (matched && !ids.includes(matched)) ids.push(matched)
}
}
return ids.map((id) => allLanguages.value.find((language) => language.id === id)).filter((language) => language !== undefined)
})
function matches(language: LanguageOption, needle: string): boolean {
return (
language.label.toLocaleLowerCase().includes(needle) ||
language.englishName.toLocaleLowerCase().includes(needle) ||
language.id.toLocaleLowerCase().startsWith(needle)
)
}
const trimmedQuery = computed(() => query.value.trim().toLocaleLowerCase())
const isSearching = computed(() => trimmedQuery.value.length > 0)
const results = computed(() => allLanguages.value.filter((language) => matches(language, trimmedQuery.value)))
const groups = computed<{ key: string; heading: string; languages: LanguageOption[] }[]>(() => {
if (isSearching.value) {
return results.value.length > 0 ? [{ key: 'results', heading: t('components.languagePicker.results'), languages: results.value }] : []
}
return [
{ key: 'suggested', heading: t('components.languagePicker.suggested'), languages: suggested.value },
{ key: 'all', heading: t('components.languagePicker.all'), languages: allLanguages.value },
]
})
watch(
() => props.autofocus,
async (autofocus) => {
if (!autofocus) return
await nextTick()
searchInput.value?.$el?.focus()
},
{ immediate: true },
)
function handleSelect(locale: Locale) {
emit('select', locale)
}
</script>
<template>
<div class="flex flex-col min-h-0">
<div class="relative px-1 pb-2 shrink-0">
<Search :size="15" class="absolute start-4 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" aria-hidden="true" />
<Input
ref="searchInput"
v-model="query"
type="search"
class="ps-9"
:placeholder="t('components.languagePicker.searchPlaceholder')"
:aria-label="t('components.languagePicker.searchPlaceholder')"
/>
</div>
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
<p v-if="groups.length === 0" class="px-3 py-6 text-center text-sm text-muted-foreground">
{{ t('components.languagePicker.empty', { query: query.trim() }) }}
</p>
<div v-for="group in groups" :key="group.key" class="pb-1">
<p class="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{{ group.heading }}
</p>
<ul class="px-1">
<li v-for="language in group.languages" :key="`${group.key}-${language.id}`">
<button
type="button"
class="flex w-full items-center gap-2 rounded-sm px-2 py-2 text-start text-sm text-foreground hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-current="localeStore.locale === language.id ? 'true' : undefined"
@click="handleSelect(language.id)"
>
<Check v-if="localeStore.locale === language.id" :size="14" class="shrink-0 text-primary" aria-hidden="true" />
<span v-else class="w-3.5 shrink-0" aria-hidden="true" />
<span :lang="language.id">{{ language.label }}</span>
<span class="ms-auto ps-3 text-xs text-muted-foreground">{{ language.englishName }}</span>
</button>
</li>
</ul>
</div>
</div>
</div>
</template>
@@ -83,6 +83,7 @@ vi.mock('@/stores/theme', () => ({
vi.mock('@/stores/locale', () => ({
useLocaleStore: () => ({ locale: 'en', setLocale: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined) }),
matchSupportedLocale: () => null,
}))
vi.mock('@/components/ui/sidebar', () => ({
@@ -0,0 +1,118 @@
import { mount } from '@vue/test-utils'
import { reactive } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LanguagePicker from '../LanguagePicker.vue'
const localeStore = reactive({ locale: 'en' })
const matchSupportedLocale = vi.fn<(candidates: readonly string[]) => string | null>(() => null)
vi.mock('@/stores/locale', () => ({
useLocaleStore: () => localeStore,
matchSupportedLocale: (candidates: readonly string[]) => matchSupportedLocale(candidates),
}))
function setBrowserLanguages(languages: string[]) {
Object.defineProperty(window.navigator, 'languages', { value: languages, configurable: true })
}
/** Row labels for one heading, so assertions do not depend on the other group's contents. */
function groupLabels(wrapper: ReturnType<typeof mount>, heading: string): string[] {
const group = wrapper.findAll('div.pb-1').find((candidate) => candidate.find('p')?.text() === heading)
if (!group) return []
return group.findAll('button').map((button) => button.find('span[lang]').text())
}
describe('LanguagePicker', () => {
beforeEach(() => {
localeStore.locale = 'en'
matchSupportedLocale.mockReset()
matchSupportedLocale.mockReturnValue(null)
setBrowserLanguages(['en-US'])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('sorts every language by the name it actually renders, not by locale code', () => {
const labels = groupLabels(mount(LanguagePicker), 'All languages')
// Ordering by code would put Suomi between Español and Français, and Bahasa
// Indonesia between Magyar and Italiano, which reads as unsorted to a user.
expect(labels.slice(0, 6)).toEqual(['Bahasa Indonesia', 'Čeština', 'Dansk', 'Deutsch', 'English', 'Español'])
expect(labels).toHaveLength(25)
})
it('pairs each native name with its English name', () => {
const wrapper = mount(LanguagePicker)
const german = wrapper.findAll('button').find((button) => button.find('span[lang]').text() === 'Deutsch')
expect(german?.text()).toContain('German')
expect(german?.find('span[lang]').attributes('lang')).toBe('de')
})
it('distinguishes the two Chinese catalogs by script rather than by bare language', () => {
const wrapper = mount(LanguagePicker)
const texts = wrapper.findAll('button').map((button) => button.text())
expect(texts.some((text) => text.includes('简体中文') && text.includes('Simplified Chinese'))).toBe(true)
expect(texts.some((text) => text.includes('繁體中文') && text.includes('Traditional Chinese'))).toBe(true)
})
it('suggests the current locale plus the browser languages it can match', () => {
localeStore.locale = 'de'
setBrowserLanguages(['fr-FR', 'ja-JP'])
matchSupportedLocale.mockImplementation((candidates) => {
const [candidate] = candidates
if (candidate === 'fr-FR') return 'fr'
if (candidate === 'ja-JP') return 'ja'
return null
})
expect(groupLabels(mount(LanguagePicker), 'Suggested')).toEqual(['Deutsch', 'Français', '日本語'])
})
it('never repeats the current locale in the suggestions', () => {
localeStore.locale = 'fr'
setBrowserLanguages(['fr-FR', 'fr-CA'])
matchSupportedLocale.mockReturnValue('fr')
expect(groupLabels(mount(LanguagePicker), 'Suggested')).toEqual(['Français'])
})
it('filters on the native name, the English name, and the locale code', async () => {
const wrapper = mount(LanguagePicker)
const input = wrapper.find('input')
for (const [query, expected] of [
['deuts', 'Deutsch'],
['germ', 'Deutsch'],
['ko', '한국어'],
] as const) {
await input.setValue(query)
expect(groupLabels(wrapper, 'Results')).toContain(expected)
}
})
it('reports an empty search instead of rendering an empty list', async () => {
const wrapper = mount(LanguagePicker)
await wrapper.find('input').setValue('klingon')
expect(wrapper.findAll('button')).toHaveLength(0)
expect(wrapper.text()).toContain('No languages match "klingon"')
})
it('marks the active language and emits the one that was chosen', async () => {
localeStore.locale = 'pl'
const wrapper = mount(LanguagePicker)
const active = wrapper.findAll('button').filter((button) => button.attributes('aria-current') === 'true')
expect(active.every((button) => button.find('span[lang]').text() === 'Polski')).toBe(true)
const italian = wrapper.findAll('button').find((button) => button.find('span[lang]').text() === 'Italiano')
await italian!.trigger('click')
expect(wrapper.emitted('select')?.at(-1)).toEqual(['it'])
})
})
@@ -19,7 +19,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-lg',
props.class,
)
"
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ChevronDown, Languages } from '@lucide/vue'
import { LOCALE_LABELS, type Locale } from '@bookorbit/types'
import { toast } from 'vue-sonner'
import LanguagePicker from '@/components/LanguagePicker.vue'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useLocaleStore } from '@/stores/locale'
const { t } = useI18n()
const localeStore = useLocaleStore()
const pickerOpen = ref(false)
const currentLabel = computed(() => LOCALE_LABELS[localeStore.locale])
async function selectLanguage(locale: Locale) {
pickerOpen.value = false
try {
await localeStore.setLocale(locale)
} catch {
toast.error(t('settings.appearance.language.loadError'))
}
}
</script>
<template>
<div class="space-y-6">
<div>
<p class="settings-group-label">
{{ t('settings.appearance.language.title') }}
</p>
<div class="settings-card">
<div class="settings-row">
<div>
<p class="settings-label">
{{ t('settings.appearance.language.label') }}
</p>
<p class="settings-hint">
{{ t('settings.appearance.language.description') }}
</p>
</div>
<Popover v-model:open="pickerOpen">
<PopoverTrigger as-child>
<Button variant="outline" class="self-start justify-between gap-2 min-w-48" data-testid="settings-language-trigger">
<Languages :size="15" class="text-muted-foreground" />
<span :lang="localeStore.locale">{{ currentLabel }}</span>
<ChevronDown :size="15" class="ms-auto text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" class="w-80 p-1">
<LanguagePicker :autofocus="pickerOpen" class="max-h-[min(26rem,calc(100dvh-8rem))]" @select="selectLanguage" />
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
</template>
@@ -5,6 +5,7 @@ import { ACCENT_PASTEL, ACCENT_VIVID, BACKGROUND_OPTIONS, useThemeStore } from '
import AppearanceBehaviorSettings from './AppearanceBehaviorSettings.vue'
import AppearanceBookCoverSettings from './AppearanceBookCoverSettings.vue'
import AppearanceIconsSettings from './AppearanceIconsSettings.vue'
import AppearanceLanguageSettings from './AppearanceLanguageSettings.vue'
import AppearanceLayoutSettings from './AppearanceLayoutSettings.vue'
import AppearanceThemeSettings from './AppearanceThemeSettings.vue'
import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -59,4 +60,5 @@ const themeLabel = computed(() => t(`settings.appearance.themeMode.${themeStore.
<AppearanceIconsSettings v-else-if="activeTab === 'icons'" />
<AppearanceLayoutSettings v-else-if="activeTab === 'layout'" />
<AppearanceBehaviorSettings v-else-if="activeTab === 'behavior'" />
<AppearanceLanguageSettings v-else-if="activeTab === 'language'" />
</template>
@@ -3,7 +3,7 @@ import { APPEARANCE_TABS, normalizeAppearanceTab } from '../appearance-tabs'
describe('appearance-tabs', () => {
it('keeps the public tab order stable', () => {
expect(APPEARANCE_TABS).toEqual(['theme', 'book-covers', 'icons', 'layout', 'behavior'])
expect(APPEARANCE_TABS).toEqual(['theme', 'book-covers', 'icons', 'layout', 'behavior', 'language'])
})
it('normalizes invalid values to theme', () => {
@@ -1,4 +1,4 @@
export const APPEARANCE_TABS = ['theme', 'book-covers', 'icons', 'layout', 'behavior'] as const
export const APPEARANCE_TABS = ['theme', 'book-covers', 'icons', 'layout', 'behavior', 'language'] as const
export type AppearanceTab = (typeof APPEARANCE_TABS)[number]
+18
View File
@@ -4,19 +4,28 @@ import type { Locale } from '@bookorbit/types'
import cs from '@/locales/cs.json'
import da from '@/locales/da.json'
import de from '@/locales/de.json'
import greek from '@/locales/el.json'
import en from '@/locales/en.json'
import fi from '@/locales/fi.json'
import spanish from '@/locales/es.json'
import french from '@/locales/fr.json'
import hungarian from '@/locales/hu.json'
import indonesian from '@/locales/id.json'
import italian from '@/locales/it.json'
import japanese from '@/locales/ja.json'
import korean from '@/locales/ko.json'
import nl from '@/locales/nl.json'
import polish from '@/locales/pl.json'
import pt from '@/locales/pt.json'
import romanian from '@/locales/ro.json'
import ru from '@/locales/ru.json'
import slovak from '@/locales/sk.json'
import sl from '@/locales/sl.json'
import sv from '@/locales/sv.json'
import turkish from '@/locales/tr.json'
import uk from '@/locales/uk.json'
import zh from '@/locales/zh.json'
import traditionalChinese from '@/locales/zh-Hant.json'
import { compileIcuCatalog, icuCountValues, isIcuPluralMessage, splitIcuCount } from './icu'
import { i18n as applicationI18n } from './index'
@@ -313,20 +322,29 @@ describe('ICU message compilation', () => {
['cs', cs, [0, 1, 2, 5, 1.5]],
['da', da, [0, 1, 2]],
['de', de, [0, 1, 2]],
['el', greek, [0, 1, 2]],
['es', spanish, [0, 1, 2, 1_000_000]],
['fi', fi, [0, 1, 2]],
['fr', french, [0, 1, 2, 1_000_000]],
['hu', hungarian, [0, 1, 2]],
['id', indonesian, [0, 1, 2]],
['it', italian, [0, 1, 2, 1_000_000]],
['ja', japanese, [0, 1, 2]],
['ko', korean, [0, 1, 2]],
['nl', nl, [0, 1, 2]],
// Polish reserves `other` for fractions, exactly as Czech does.
['pl', polish, [0, 1, 2, 5, 1.5, 1_000_000]],
['pt', pt, [0, 1, 2, 1_000_000]],
['ro', romanian, [0, 1, 2, 20]],
// Russian and Ukrainian reach `other` only through a fraction; 5 is `many`.
['ru', ru, [0, 1, 2, 5, 1.5]],
['sk', slovak, [0, 1, 2, 5, 1.1]],
['sl', sl, [0, 1, 2, 3, 5]],
['sv', sv, [0, 1, 2]],
['tr', turkish, [0, 1, 2]],
['uk', uk, [0, 1, 2, 5, 1.5]],
['zh', zh, [0, 1, 2]],
['zh-Hant', traditionalChinese, [0, 1, 2]],
] as const)('formats every ICU message for all relevant plural categories in %s', (locale, catalog, counts) => {
const testI18n = createCatalogI18n(locale, catalog)
+1
View File
@@ -0,0 +1 @@
{}
+12 -3
View File
@@ -83,7 +83,7 @@
"language": {
"title": "Language",
"description": "Choose the language used across the interface.",
"label": "Interface language",
"label": "Language",
"loadError": "Failed to load the selected language",
"saveError": "Failed to save language preference"
},
@@ -464,7 +464,8 @@
"book-covers": "Book Covers",
"icons": "Icons",
"layout": "Layout",
"behavior": "Behavior"
"behavior": "Behavior",
"language": "Language"
}
},
"account": {
@@ -4755,6 +4756,13 @@
"dark": "Dark",
"system": "System"
},
"languagePicker": {
"searchPlaceholder": "Search languages",
"suggested": "Suggested",
"all": "All languages",
"results": "Results",
"empty": "No languages match \"{query}\""
},
"userAvatar": {
"profilePicture": "Profile picture",
"profilePictureOf": "{name} profile picture"
@@ -7107,7 +7115,8 @@
"book-covers": "Book Covers",
"icons": "Icons",
"layout": "Display Layout",
"behavior": "Library Behavior"
"behavior": "Library Behavior",
"language": "Language"
},
"reader": {
"general": "Reader Settings",
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -0,0 +1 @@
{}
+14 -6
View File
@@ -31,22 +31,30 @@ describe('locale store', () => {
expect(matchSupportedLocale(['nl-NL', 'en-US'])).toBe('nl')
expect(matchSupportedLocale(['de-DE', 'en-GB'])).toBe('de')
expect(matchSupportedLocale(['el-GR', 'en-GB'])).toBe('el')
expect(matchSupportedLocale(['es-ES', 'en-GB'])).toBe('es')
expect(matchSupportedLocale(['fr-FR', 'en-GB'])).toBe('fr')
expect(matchSupportedLocale(['hu-HU', 'en-GB'])).toBe('hu')
expect(matchSupportedLocale(['id-ID', 'en-GB'])).toBe('id')
expect(matchSupportedLocale(['it-IT', 'en-GB'])).toBe('it')
expect(matchSupportedLocale(['ja-JP', 'en-GB'])).toBe('ja')
expect(matchSupportedLocale(['ko-KR', 'en-GB'])).toBe('ko')
expect(matchSupportedLocale(['nl-BE', 'en'])).toBe('nl')
expect(matchSupportedLocale(['pl-PL', 'en-GB'])).toBe('pl')
expect(matchSupportedLocale(['pt-BR'])).toBe('pt')
expect(matchSupportedLocale(['ro-RO', 'en-GB'])).toBe('ro')
expect(matchSupportedLocale(['ru-RU', 'en-GB'])).toBe('ru')
expect(matchSupportedLocale(['sk-SK', 'en-GB'])).toBe('sk')
expect(matchSupportedLocale(['uk-UA', 'en-GB'])).toBe('uk')
expect(matchSupportedLocale(['cs-CZ', 'en-GB'])).toBe('cs')
expect(matchSupportedLocale(['da-DK', 'en-GB'])).toBe('da')
expect(matchSupportedLocale(['fi-FI', 'en-GB'])).toBe('fi')
expect(matchSupportedLocale(['sv-SE', 'en-GB'])).toBe('sv')
expect(matchSupportedLocale(['sv-FI', 'en-GB'])).toBe('sv')
expect(matchSupportedLocale(['tr-TR', 'en-GB'])).toBe('tr')
})
it('resolves every Chinese script and region variant to the Simplified catalog', async () => {
it('distinguishes Simplified and Traditional Chinese scripts and regions', async () => {
const { matchSupportedLocale } = await import('../locale')
expect(matchSupportedLocale(['zh'])).toBe('zh')
@@ -54,11 +62,11 @@ describe('locale store', () => {
expect(matchSupportedLocale(['zh-Hans-CN'])).toBe('zh')
expect(matchSupportedLocale(['zh-SG'])).toBe('zh')
// Traditional readers intentionally land on Simplified until a zh-Hant
// catalog exists. Adding one means matching by script before base language.
expect(matchSupportedLocale(['zh-TW'])).toBe('zh')
expect(matchSupportedLocale(['zh-Hant'])).toBe('zh')
expect(matchSupportedLocale(['zh-HK'])).toBe('zh')
expect(matchSupportedLocale(['zh-TW'])).toBe('zh-Hant')
expect(matchSupportedLocale(['zh-Hant'])).toBe('zh-Hant')
expect(matchSupportedLocale(['zh-Hant-HK'])).toBe('zh-Hant')
expect(matchSupportedLocale(['zh-HK'])).toBe('zh-Hant')
expect(matchSupportedLocale(['zh-MO'])).toBe('zh-Hant')
})
it('prefers the stored locale over browser detection', async () => {
+13 -2
View File
@@ -16,9 +16,15 @@ function canonicalizeLocale(value: string): string | null {
export function matchSupportedLocale(candidates: readonly string[]): Locale | null {
const supportedByCanonical = new Map<string, Locale>()
const supportedByLanguageAndScript = new Map<string, Locale>()
for (const locale of SUPPORTED_LOCALES) {
const canonical = canonicalizeLocale(locale)
if (canonical) supportedByCanonical.set(canonical.toLowerCase(), locale)
if (!canonical) continue
supportedByCanonical.set(canonical.toLowerCase(), locale)
const maximized = new Intl.Locale(canonical).maximize()
if (maximized.script) {
supportedByLanguageAndScript.set(`${maximized.language.toLowerCase()}-${maximized.script.toLowerCase()}`, locale)
}
}
for (const candidate of candidates) {
@@ -26,7 +32,12 @@ export function matchSupportedLocale(candidates: readonly string[]): Locale | nu
if (!canonical) continue
const exact = supportedByCanonical.get(canonical.toLowerCase())
if (exact) return exact
const language = new Intl.Locale(canonical).language.toLowerCase()
const maximized = new Intl.Locale(canonical).maximize()
const language = maximized.language.toLowerCase()
if (maximized.script) {
const scriptMatch = supportedByLanguageAndScript.get(`${language}-${maximized.script.toLowerCase()}`)
if (scriptMatch) return scriptMatch
}
const baseLocale = supportedByCanonical.get(language)
if (baseLocale) return baseLocale
}