213 lines
8.0 KiB
TypeScript
213 lines
8.0 KiB
TypeScript
'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
|
||
)}
|
||
</>
|
||
)
|
||
}
|