Add searchable language picker

This commit is contained in:
Jim Liu 宝玉 2026-06-05 22:12:31 -05:00 committed by Teknium
parent b1b89f843e
commit 812dc6957e
8 changed files with 119 additions and 60 deletions

View file

@ -6,6 +6,19 @@ import { type I18nConfigClient, I18nProvider } from '@/i18n'
import { LanguageSwitcher } from './language-switcher'
// cmdk (the searchable list) wires a ResizeObserver and scrolls the active
// item into view — neither exists in jsdom. Stub them, matching the polyfill
// idiom in tool-approval-group.test.tsx.
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
Element.prototype.scrollIntoView = function scrollIntoView() {}
describe('LanguageSwitcher', () => {
afterEach(() => {
cleanup()
@ -15,6 +28,7 @@ describe('LanguageSwitcher', () => {
it('persists language changes through display.language config', async () => {
const saveConfig = vi.fn().mockResolvedValue({ ok: true })
const latestConfig: HermesConfigRecord = { display: { language: 'en', skin: 'slate' } }
const configClient: I18nConfigClient = {
getConfig: vi.fn().mockResolvedValue(latestConfig),
saveConfig

View file

@ -1,8 +1,8 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import { useIsMobile } from '@/hooks/use-mobile'
import { type Locale, LOCALE_META, useI18n } from '@/i18n'
@ -17,12 +17,14 @@ export interface LanguageSwitcherProps {
dropUp?: boolean
}
interface LanguageSwitcherOptionsProps {
interface LanguageCommandProps {
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>
autoFocus?: boolean
disabled?: boolean
label: string
locale: Locale
noResults: string
onSelect: (code: Locale) => void
searchPlaceholder: string
}
export function LanguageSwitcher({ className, collapsed = false, dropUp = false }: LanguageSwitcherProps) {
@ -37,6 +39,7 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
const selectLocale = async (code: Locale) => {
if (code === locale || isSavingLocale) {
setOpen(false)
return
}
@ -53,8 +56,8 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
const trigger = (
<Button
aria-label={title}
aria-expanded={open}
aria-label={title}
className={cn(
'min-w-32 justify-between gap-2 border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) px-2.5 text-left text-muted-foreground hover:text-foreground',
collapsed && 'min-w-0 px-2',
@ -68,7 +71,7 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
>
<span className="inline-flex min-w-0 items-center gap-2">
<Globe className="size-3.5 shrink-0" />
{!collapsed && <span className="truncate">{locale === 'en' ? 'EN' : current.name}</span>}
{!collapsed && <span className="truncate">{current.name}</span>}
</span>
{!collapsed && <ChevronDown className="size-3 shrink-0 opacity-70" />}
</Button>
@ -83,15 +86,14 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{t.language.description}</SheetDescription>
</SheetHeader>
<ScrollArea className="max-h-80 px-2 pb-3">
<LanguageSwitcherOptions
allLocales={allLocales}
disabled={isSavingLocale}
label={title}
locale={locale}
onSelect={code => void selectLocale(code)}
/>
</ScrollArea>
<LanguageCommand
allLocales={allLocales}
disabled={isSavingLocale}
locale={locale}
noResults={t.language.noResults}
onSelect={code => void selectLocale(code)}
searchPlaceholder={t.language.searchPlaceholder}
/>
</SheetContent>
</Sheet>
)
@ -100,46 +102,74 @@ export function LanguageSwitcher({ className, collapsed = false, dropUp = false
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent align="end" className="w-48 p-1" side={dropUp ? 'top' : 'bottom'}>
<ScrollArea className="max-h-80">
<LanguageSwitcherOptions
allLocales={allLocales}
disabled={isSavingLocale}
label={title}
locale={locale}
onSelect={code => void selectLocale(code)}
/>
</ScrollArea>
<PopoverContent align="end" className="w-56 p-0" side={dropUp ? 'top' : 'bottom'}>
<LanguageCommand
allLocales={allLocales}
autoFocus
disabled={isSavingLocale}
locale={locale}
noResults={t.language.noResults}
onSelect={code => void selectLocale(code)}
searchPlaceholder={t.language.searchPlaceholder}
/>
</PopoverContent>
</Popover>
)
}
function LanguageSwitcherOptions({ allLocales, disabled, label, locale, onSelect }: LanguageSwitcherOptionsProps) {
return (
<div aria-label={label} className="py-1" role="listbox">
{allLocales.map(([code, meta]) => {
const selected = code === locale
function LanguageCommand({
allLocales,
autoFocus,
disabled,
locale,
noResults,
onSelect,
searchPlaceholder
}: LanguageCommandProps) {
const [search, setSearch] = useState('')
return (
<button
aria-selected={selected}
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[length:var(--conversation-caption-font-size)] transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground disabled:pointer-events-none disabled:opacity-50',
selected ? 'font-medium text-foreground' : 'text-muted-foreground'
)}
disabled={disabled}
key={code}
onClick={() => onSelect(code)}
role="option"
type="button"
>
<span className="min-w-0 flex-1 truncate">{meta.name}</span>
<span className="font-mono text-[0.65rem] uppercase text-(--ui-text-tertiary)">{code}</span>
{selected && <Check className="size-3.5 shrink-0 text-primary" />}
</button>
)
})}
</div>
// Own the search term and filter manually. cmdk's built-in shouldFilter
// reorders items by its fuzzy-match score (≈alphabetical with an empty
// query), which destroys the curated en→zh→zh-hant→ja order. We disable it
// and do a plain substring filter that preserves array order — matching
// model-picker.tsx. Match against the endonym, the (hidden) English name,
// and the locale code so "日本"/"japanese"/"ja" all find Japanese.
const q = search.trim().toLowerCase()
const filtered = allLocales.filter(
([code, meta]) =>
!q ||
meta.name.toLowerCase().includes(q) ||
meta.englishName.toLowerCase().includes(q) ||
code.toLowerCase().includes(q)
)
return (
<Command className="bg-transparent" shouldFilter={false}>
<CommandInput autoFocus={autoFocus} onValueChange={setSearch} placeholder={searchPlaceholder} value={search} />
<CommandList className="max-h-80 p-1">
{filtered.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">{noResults}</div>
) : (
filtered.map(([code, meta]) => {
const selected = code === locale
return (
<CommandItem
className={cn(selected ? 'font-medium text-foreground' : 'text-muted-foreground')}
disabled={disabled}
key={code}
onSelect={() => onSelect(code)}
value={code}
>
<Check className={cn('size-3.5 shrink-0 text-primary', !selected && 'invisible')} />
<span className="min-w-0 flex-1 truncate">{meta.name}</span>
<span className="font-mono text-[0.65rem] uppercase text-(--ui-text-tertiary)">{code}</span>
</CommandItem>
)
})
)}
</CommandList>
</Command>
)
}

View file

@ -132,7 +132,9 @@ export const en: Translations = {
description: 'Choose the language for the desktop interface.',
saving: 'Saving language…',
saveError: 'Language update failed',
switchTo: 'Switch language'
switchTo: 'Switch language',
searchPlaceholder: 'Search languages…',
noResults: 'No languages found'
},
settings: {

View file

@ -21,7 +21,9 @@ export const ja = defineLocale({
description: 'デスクトップインターフェイスの言語を選択します。',
saving: '言語を保存中…',
saveError: '言語の更新に失敗しました',
switchTo: '言語を切り替え'
switchTo: '言語を切り替え',
searchPlaceholder: '言語を検索…',
noResults: '言語が見つかりません'
},
settings: {

View file

@ -6,31 +6,36 @@ export const LOCALE_OPTIONS = [
{
id: 'en',
name: 'English',
englishName: 'English',
configValue: 'en'
},
{
id: 'zh',
name: '简体中文',
englishName: 'Simplified Chinese',
configValue: 'zh'
},
{
id: 'zh-hant',
name: '繁體中文',
englishName: 'Traditional Chinese',
configValue: 'zh-hant'
},
{
id: 'ja',
name: '日本語',
englishName: 'Japanese',
configValue: 'ja'
}
] as const satisfies readonly { configValue: string; id: Locale; name: string }[]
] as const satisfies readonly { configValue: string; englishName: string; id: Locale; name: string }[]
// Endonyms (native names) for the language picker so users recognize their
// language regardless of the current UI language. No country flags:
// languages are not countries.
export const LOCALE_META: Record<Locale, { name: string }> = Object.fromEntries(
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name }])
) as Record<Locale, { name: string }>
// `name` is the endonym (native name) shown in the picker so users recognize
// their language regardless of the current UI language. No country flags:
// languages are not countries. `englishName` is search-only (not shown) so an
// English speaker can type "japanese"/"traditional" to filter the list.
export const LOCALE_META: Record<Locale, { name: string; englishName: string }> = Object.fromEntries(
LOCALE_OPTIONS.map(locale => [locale.id, { name: locale.name, englishName: locale.englishName }])
) as Record<Locale, { name: string; englishName: string }>
const LOCALE_ALIASES: Record<string, Locale> = {
en: 'en',

View file

@ -144,6 +144,8 @@ export interface Translations {
saving: string
saveError: string
switchTo: string
searchPlaceholder: string
noResults: string
}
settings: {

View file

@ -21,7 +21,9 @@ export const zhHant = defineLocale({
description: '選擇桌面介面的語言。',
saving: '正在儲存語言…',
saveError: '語言更新失敗',
switchTo: '切換語言'
switchTo: '切換語言',
searchPlaceholder: '搜尋語言…',
noResults: '找不到語言'
},
settings: {

View file

@ -128,7 +128,9 @@ export const zh: Translations = {
description: '选择桌面界面的语言。',
saving: '正在保存语言…',
saveError: '语言更新失败',
switchTo: '切换语言'
switchTo: '切换语言',
searchPlaceholder: '搜索语言…',
noResults: '未找到语言'
},
settings: {