Files
AI_Translator/apps/web/components/LanguageSelect.tsx
2025-12-29 15:52:50 +08:00

213 lines
8.0 KiB
TypeScript
Raw Permalink 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 { createPortal } from 'react-dom'
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 languageCount = options.length
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 select-none will-change-transform',
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out hover:shadow-sm active:translate-y-px active:scale-[0.98]',
'motion-reduce:transform-none motion-reduce:transition-none',
'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 &&
typeof document !== 'undefined' &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in-0 duration-150 motion-reduce:animate-none"
onMouseDown={(e) => {
if (e.target === e.currentTarget) setOpen(false)
}}
>
<Card className="w-full max-w-3xl border-muted shadow-lg will-change-transform animate-in fade-in-0 zoom-in-95 duration-200 motion-reduce:animate-none">
<CardHeader className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle></CardTitle>
<CardDescription>
{languageCount} {allowAuto ? ' + 自动检测' : ''}
</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 select-none will-change-transform',
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out active:scale-[0.98]',
'motion-reduce:transform-none motion-reduce:transition-none',
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 active:bg-accent/80 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>,
document.body
)}
</>
)
}