mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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()) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user