Files
AI_Translator/apps/web/components/LanguageSelect.tsx
2025-12-26 16:03:12 +08:00

200 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import type { LanguageOption } from '@/lib/languages'
type Props = {
value: string
onValueChange: (value: string) => void
options: LanguageOption[]
commonOptions?: LanguageOption[]
placeholder?: string
searchPlaceholder?: string
disabled?: boolean
allowAuto?: boolean
autoOption?: LanguageOption
triggerClassName?: string
}
export default function LanguageSelect({
value,
onValueChange,
options,
commonOptions,
placeholder = '选择语言',
searchPlaceholder = '搜索语言(名称 / 代码)',
disabled,
allowAuto,
autoOption,
triggerClassName,
}: Props) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const inputRef = useRef<HTMLInputElement | null>(null)
const allOptions = useMemo(() => {
const list = [...options]
if (allowAuto && autoOption) {
return [autoOption, ...list]
}
return list
}, [allowAuto, autoOption, options])
const selected = useMemo(() => {
return allOptions.find((o) => o.code === value) || null
}, [allOptions, value])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return allOptions
return allOptions.filter((o) => (o.search || `${o.name} ${o.code}`).toLowerCase().includes(q))
}, [allOptions, query])
useEffect(() => {
if (!open) return
setQuery('')
const id = window.setTimeout(() => inputRef.current?.focus(), 0)
return () => window.clearTimeout(id)
}, [open])
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [open])
const handleSelect = (code: string) => {
onValueChange(code)
setOpen(false)
}
return (
<>
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
triggerClassName
)}
>
<span className={cn('line-clamp-1', selected ? 'text-foreground' : 'text-muted-foreground')}>
{selected?.name || placeholder}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</button>
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setOpen(false)
}}
>
<Card className="w-full max-w-3xl border-muted shadow-lg">
<CardHeader className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription> 200+ </CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder}
className="pl-9"
/>
</div>
{query && (
<Button variant="outline" onClick={() => setQuery('')}>
</Button>
)}
</div>
{!query.trim() && commonOptions && commonOptions.length > 0 && (
<div className="flex flex-wrap gap-2">
{commonOptions.map((o) => (
<button
key={o.code}
type="button"
onClick={() => handleSelect(o.code)}
className={cn(
'rounded-full border px-3 py-1 text-sm transition-colors',
o.code === value
? 'bg-primary text-primary-foreground border-transparent'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
{o.name}
</button>
))}
</div>
)}
</CardHeader>
<CardContent>
<div className="rounded-md border bg-background max-h-[60vh] overflow-y-auto">
{filtered.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground"></div>
) : (
<div className="divide-y">
{filtered.map((o) => (
<button
key={o.code}
type="button"
onClick={() => handleSelect(o.code)}
className={cn(
'w-full text-left px-4 py-3 hover:bg-accent transition-colors',
'flex items-center justify-between gap-4'
)}
>
<div className="min-w-0">
<div className="font-medium truncate">{o.name}</div>
<div className="text-xs text-muted-foreground font-mono">{o.code}</div>
</div>
<div className="shrink-0">
{o.code === value ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</div>
</button>
))}
</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</>
)
}