001d171111
- 动态获取所有 ~1500 个 lucide-react 图标,替代硬编码 57 个 - 引入 @tanstack/react-virtual 虚拟滚动,流畅渲染大量图标 - 使用 useState callback ref 解决 Dialog 内 virtualizer 初始化问题 - 新增清除图标按钮,允许将图标置空 - 搜索覆盖全量图标,输入时自动滚回顶部
152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
import * as icons from 'lucide-react'
|
|
import { Search, X } from 'lucide-react'
|
|
import { useState } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from '@/components/ui/dialog'
|
|
import { Input } from '@/components/ui/input'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>>
|
|
|
|
const ALL_ICON_NAMES: string[] = Object.keys(icons)
|
|
.filter((key) => /^[A-Z]/.test(key) && !key.endsWith('Icon'))
|
|
.sort()
|
|
|
|
const COLUMNS = 8
|
|
const ROW_HEIGHT = 40
|
|
|
|
interface IconPickerDialogProps {
|
|
value: string | null
|
|
onChange: (iconName: string | null) => void
|
|
}
|
|
|
|
export const IconPickerDialog = ({ value, onChange }: IconPickerDialogProps) => {
|
|
const [open, setOpen] = useState(false)
|
|
const [filter, setFilter] = useState('')
|
|
const [scrollElement, setScrollElement] = useState<HTMLDivElement | null>(null)
|
|
|
|
const filteredIcons = filter
|
|
? ALL_ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
|
|
: ALL_ICON_NAMES
|
|
|
|
const rowCount = Math.ceil(filteredIcons.length / COLUMNS)
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: rowCount,
|
|
getScrollElement: () => scrollElement,
|
|
estimateSize: () => ROW_HEIGHT,
|
|
overscan: 5,
|
|
})
|
|
|
|
const CurrentIcon = (value && allIcons[value]) || null
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(isOpen) => {
|
|
setOpen(isOpen)
|
|
if (!isOpen) setFilter('')
|
|
}}
|
|
>
|
|
<DialogTrigger render={<Button type="button" variant="outline" className="w-full justify-start gap-2" />}>
|
|
{CurrentIcon ? <CurrentIcon className="size-4" /> : <Search className="size-4 text-muted-foreground" />}
|
|
<span className={cn('truncate', !value && 'text-muted-foreground')}>{value ?? '选择图标'}</span>
|
|
</DialogTrigger>
|
|
|
|
<DialogContent className="max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>选择图标</DialogTitle>
|
|
<DialogDescription>搜索并选择一个图标 · 共 {ALL_ICON_NAMES.length} 个可用图标</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={filter}
|
|
onChange={(e) => {
|
|
setFilter(e.target.value)
|
|
scrollElement?.scrollTo({ top: 0 })
|
|
}}
|
|
placeholder="搜索图标..."
|
|
className="flex-1"
|
|
/>
|
|
{value && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
title="清除图标"
|
|
onClick={() => {
|
|
onChange(null)
|
|
setOpen(false)
|
|
}}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div ref={setScrollElement} className="max-h-80 overflow-y-auto rounded-lg border p-2">
|
|
<div
|
|
style={{
|
|
height: virtualizer.getTotalSize(),
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
const startIndex = virtualRow.index * COLUMNS
|
|
const rowIcons = filteredIcons.slice(startIndex, startIndex + COLUMNS)
|
|
|
|
return (
|
|
<div
|
|
key={virtualRow.key}
|
|
className="grid grid-cols-8 gap-1"
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: ROW_HEIGHT,
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
>
|
|
{rowIcons.map((name) => {
|
|
const Icon = allIcons[name]
|
|
if (!Icon) return null
|
|
|
|
return (
|
|
<Button
|
|
key={name}
|
|
type="button"
|
|
size="icon"
|
|
variant={value === name ? 'secondary' : 'ghost'}
|
|
title={name}
|
|
onClick={() => {
|
|
onChange(name)
|
|
setOpen(false)
|
|
}}
|
|
>
|
|
<Icon className="size-4" />
|
|
</Button>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{filteredIcons.length === 0 && <p className="text-center text-sm text-muted-foreground">未找到匹配图标</p>}
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|