feat: 重写图标选择器 — 全量 lucide 图标 + 虚拟滚动 + 可清除

- 动态获取所有 ~1500 个 lucide-react 图标,替代硬编码 57 个
- 引入 @tanstack/react-virtual 虚拟滚动,流畅渲染大量图标
- 使用 useState callback ref 解决 Dialog 内 virtualizer 初始化问题
- 新增清除图标按钮,允许将图标置空
- 搜索覆盖全量图标,输入时自动滚回顶部
This commit is contained in:
2026-03-31 17:25:27 +08:00
parent 46e4486d7d
commit 001d171111
4 changed files with 116 additions and 92 deletions
+1
View File
@@ -36,6 +36,7 @@
"@orpc/zod": "catalog:", "@orpc/zod": "catalog:",
"@t3-oss/env-core": "catalog:", "@t3-oss/env-core": "catalog:",
"@tanstack/react-query": "catalog:", "@tanstack/react-query": "catalog:",
"@tanstack/react-virtual": "catalog:",
"@tanstack/react-router": "catalog:", "@tanstack/react-router": "catalog:",
"@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:", "@tanstack/react-start": "catalog:",
@@ -1,5 +1,6 @@
import { useVirtualizer } from '@tanstack/react-virtual'
import * as icons from 'lucide-react' import * as icons from 'lucide-react'
import { Search } from 'lucide-react' import { Search, X } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
@@ -13,119 +14,134 @@ import {
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const ICON_NAMES = [
'Globe',
'Home',
'Code',
'Database',
'Mail',
'MessageSquare',
'Music',
'Video',
'Image',
'FileText',
'Folder',
'Star',
'Heart',
'Bookmark',
'Search',
'Settings',
'User',
'Shield',
'Key',
'Terminal',
'Github',
'Chrome',
'Cpu',
'Server',
'Cloud',
'Wifi',
'Zap',
'Coffee',
'BookOpen',
'Briefcase',
'Calendar',
'Clock',
'Download',
'Edit',
'ExternalLink',
'Eye',
'Film',
'Gift',
'Headphones',
'Layout',
'Link',
'Map',
'Monitor',
'Package',
'Phone',
'ShoppingCart',
'Smartphone',
'Tv',
'Upload',
'Box',
'Compass',
'Rss',
'Camera',
'Printer',
'Layers',
'Activity',
] as const
const allIcons = icons as unknown as Record<string, React.ComponentType<{ className?: string }>> 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 { interface IconPickerDialogProps {
value: string | null value: string | null
onChange: (iconName: string) => void onChange: (iconName: string | null) => void
} }
export const IconPickerDialog = ({ value, onChange }: IconPickerDialogProps) => { export const IconPickerDialog = ({ value, onChange }: IconPickerDialogProps) => {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [filter, setFilter] = useState('') 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 filteredIcons = ICON_NAMES.filter((name) => name.toLowerCase().includes(filter.toLowerCase()))
const CurrentIcon = (value && allIcons[value]) || null const CurrentIcon = (value && allIcons[value]) || null
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog
<DialogTrigger> open={open}
<Button variant="outline" className="w-full justify-start gap-2"> onOpenChange={(isOpen) => {
{CurrentIcon ? <CurrentIcon className="size-4" /> : <Search className="size-4 text-muted-foreground" />} setOpen(isOpen)
<span className={cn('truncate', !value && 'text-muted-foreground')}>{value ?? '选择图标'}</span> if (!isOpen) setFilter('')
</Button> }}
>
<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> </DialogTrigger>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> lucide </DialogDescription> <DialogDescription> · {ALL_ICON_NAMES.length} </DialogDescription>
</DialogHeader> </DialogHeader>
<Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="搜索图标..." /> <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 className="grid max-h-80 grid-cols-8 gap-2 overflow-y-auto rounded-lg border p-2"> <div ref={setScrollElement} className="max-h-80 overflow-y-auto rounded-lg border p-2">
{filteredIcons.map((name) => { <div
const Icon = allIcons[name] style={{
if (!Icon) { height: virtualizer.getTotalSize(),
return null width: '100%',
} position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * COLUMNS
const rowIcons = filteredIcons.slice(startIndex, startIndex + COLUMNS)
return ( return (
<Button <div
key={name} key={virtualRow.key}
type="button" className="grid grid-cols-8 gap-1"
size="icon" style={{
variant={value === name ? 'secondary' : 'ghost'} position: 'absolute',
title={name} top: 0,
onClick={() => { left: 0,
onChange(name) width: '100%',
setOpen(false) height: ROW_HEIGHT,
}} transform: `translateY(${virtualRow.start}px)`,
> }}
<Icon className="size-4" /> >
</Button> {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> </div>
{filteredIcons.length === 0 && <p className="text-center text-sm text-muted-foreground"></p>} {filteredIcons.length === 0 && <p className="text-center text-sm text-muted-foreground"></p>}
+6
View File
@@ -30,6 +30,7 @@
"@tanstack/react-router": "catalog:", "@tanstack/react-router": "catalog:",
"@tanstack/react-router-ssr-query": "catalog:", "@tanstack/react-router-ssr-query": "catalog:",
"@tanstack/react-start": "catalog:", "@tanstack/react-start": "catalog:",
"@tanstack/react-virtual": "catalog:",
"better-auth": "catalog:", "better-auth": "catalog:",
"class-variance-authority": "catalog:", "class-variance-authority": "catalog:",
"clsx": "catalog:", "clsx": "catalog:",
@@ -90,6 +91,7 @@
"@tanstack/react-router-devtools": "^1.166.11", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-router-ssr-query": "^1.166.10", "@tanstack/react-router-ssr-query": "^1.166.10",
"@tanstack/react-start": "^1.167.6", "@tanstack/react-start": "^1.167.6",
"@tanstack/react-virtual": "^3.13.6",
"@types/bun": "^1.3.11", "@types/bun": "^1.3.11",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
@@ -501,6 +503,8 @@
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="], "@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.23", "", { "dependencies": { "@tanstack/virtual-core": "3.13.23" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ=="],
"@tanstack/router-core": ["@tanstack/router-core@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ=="], "@tanstack/router-core": ["@tanstack/router-core@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.2", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w=="], "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.2", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w=="],
@@ -525,6 +529,8 @@
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="], "@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
"@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.23", "", {}, "sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg=="],
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="], "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
"@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="], "@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="],
+1
View File
@@ -36,6 +36,7 @@
"@tanstack/react-devtools": "^0.10.0", "@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2",
"@tanstack/react-virtual": "^3.13.6",
"@tanstack/react-router": "^1.168.3", "@tanstack/react-router": "^1.168.3",
"@tanstack/react-router-devtools": "^1.166.11", "@tanstack/react-router-devtools": "^1.166.11",
"@tanstack/react-router-ssr-query": "^1.166.10", "@tanstack/react-router-ssr-query": "^1.166.10",