'use client' import * as React from 'react' import type { IconData } from './icons-data' import type { VirtualItem } from '@tanstack/react-virtual' import type { IconName } from 'lucide-react/dynamic' import { Button } from '@admin/components/ui/button' import { Input } from '@admin/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@admin/components/ui/popover' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@admin/components/ui/tooltip' import { cn } from '@admin/utils/shared/cn' import { useVirtualizer } from '@tanstack/react-virtual' import Fuse from 'fuse.js' import { DynamicIcon, dynamicIconImports } from 'lucide-react/dynamic' import { useCallback, useMemo, useState } from 'react' import { useDebounceValue } from 'usehooks-ts' import { MemoizedIcon } from './icon-picker/memoized-icon' import { IconsColumnSkeleton } from './icons-column-skeleton' import { iconsData } from './icons-data' const DEFAULT_ICONS = iconsData.filter((icon: IconData) => { return icon.name in dynamicIconImports }) interface IconPickerProps extends Omit< React.ComponentPropsWithoutRef, 'onSelect' | 'onOpenChange' > { value?: string defaultValue?: string onValueChange?: (value: string) => void open?: boolean defaultOpen?: boolean onOpenChange?: (open: boolean) => void searchable?: boolean searchPlaceholder?: string triggerPlaceholder?: string iconsList?: IconData[] categorized?: boolean modal?: boolean } const IconPicker = React.forwardRef, IconPickerProps>( ( { value, defaultValue, onValueChange, open, defaultOpen, onOpenChange, children, searchable = true, searchPlaceholder = 'Search for an icon...', triggerPlaceholder = 'Select an Icon', iconsList, categorized = true, modal = false, ...props }, ref ) => { const [selectedIcon, setSelectedIcon] = useState(defaultValue) const [isOpen, setIsOpen] = useState(defaultOpen || false) const [search, setSearch] = useDebounceValue('', 100) const [isPopoverVisible, setIsPopoverVisible] = useState(false) const iconsToUse = useMemo(() => iconsList || DEFAULT_ICONS, [iconsList]) const showIconSkeleton = iconsToUse.length === 0 const fuseInstance = useMemo(() => { return new Fuse(iconsToUse, { keys: ['name', 'tags', 'categories'], threshold: 0.3, ignoreLocation: true, includeScore: true }) }, [iconsToUse]) const filteredIcons = useMemo(() => { if (search.trim() === '') { return iconsToUse } const results = fuseInstance.search(search.toLowerCase().trim()) return results.map((result) => result.item) }, [search, iconsToUse, fuseInstance]) const categorizedIcons = useMemo(() => { if (!categorized || search.trim() !== '') { return [{ name: 'All Icons', icons: filteredIcons }] } const categories = new Map() filteredIcons.forEach((icon) => { if (icon.categories && icon.categories.length > 0) { icon.categories.forEach((category) => { if (!categories.has(category)) { categories.set(category, []) } categories.get(category)?.push(icon) }) } else { const category = 'Other' if (!categories.has(category)) { categories.set(category, []) } categories.get(category)?.push(icon) } }) return Array.from(categories.entries()) .map(([name, icons]) => ({ name, icons })) .sort((a, b) => a.name.localeCompare(b.name)) }, [filteredIcons, categorized, search]) const virtualItems = useMemo(() => { const items: Array<{ type: 'category' | 'row' categoryIndex: number rowIndex?: number icons?: IconData[] }> = [] categorizedIcons.forEach((category, categoryIndex) => { items.push({ type: 'category', categoryIndex }) const rows = [] for (let i = 0; i < category.icons.length; i += 5) { rows.push(category.icons.slice(i, i + 5)) } rows.forEach((rowIcons, rowIndex) => { items.push({ type: 'row', categoryIndex, rowIndex, icons: rowIcons }) }) }) return items }, [categorizedIcons]) const categoryIndices = useMemo(() => { const indices: Record = {} virtualItems.forEach((item, index) => { if (item.type === 'category') { indices[categorizedIcons[item.categoryIndex].name] = index } }) return indices }, [virtualItems, categorizedIcons]) const parentRef = React.useRef(null) const virtualizer = useVirtualizer({ count: virtualItems.length, getScrollElement: () => parentRef.current, estimateSize: (index) => (virtualItems[index].type === 'category' ? 25 : 40), paddingEnd: 2, gap: 10, overscan: 5 }) const handleValueChange = useCallback( (icon: IconName) => { if (value === undefined) { setSelectedIcon(icon) } onValueChange?.(icon) }, [value, onValueChange] ) const handleOpenChange = useCallback( (newOpen: boolean) => { setSearch('') if (open === undefined) { setIsOpen(newOpen) } onOpenChange?.(newOpen) setIsPopoverVisible(newOpen) if (newOpen) { setTimeout(() => { virtualizer.measure() }, 1) } }, [open, onOpenChange, virtualizer, setSearch] ) const handleIconClick = useCallback( (iconName: IconName) => { handleValueChange(iconName) setIsOpen(false) setSearch('') }, [handleValueChange, setSearch] ) const handleSearchChange = useCallback( (e: React.ChangeEvent) => { setSearch(e.target.value) if (parentRef.current) { parentRef.current.scrollTop = 0 } virtualizer.scrollToOffset(0) }, [virtualizer, setSearch] ) const scrollToCategory = useCallback( (categoryName: string) => { const categoryIndex = categoryIndices[categoryName] if (categoryIndex !== undefined && virtualizer) { virtualizer.scrollToIndex(categoryIndex, { align: 'start', behavior: 'smooth' }) } }, [categoryIndices, virtualizer] ) const categoryButtons = useMemo(() => { if (!categorized || search.trim() !== '') return null return categorizedIcons.map((category) => ( )) }, [categorizedIcons, scrollToCategory, categorized, search]) const renderIcon = useCallback( (icon: IconData) => ( handleIconClick(icon.name as IconName)} >

{icon.name}

), [handleIconClick] ) const renderVirtualContent = useCallback(() => { if (filteredIcons.length === 0) { return
No icon found
} return (
{virtualizer.getVirtualItems().map((virtualItem: VirtualItem) => { const item = virtualItems[virtualItem.index] if (!item) return null const itemStyle = { position: 'absolute' as const, top: 0, left: 0, width: '100%', height: `${virtualItem.size}px`, transform: `translateY(${virtualItem.start}px)` } if (item.type === 'category') { return (

{categorizedIcons[item.categoryIndex].name}

) } return (
{item.icons?.map(renderIcon)}
) })}
) }, [virtualizer, virtualItems, categorizedIcons, filteredIcons, renderIcon]) React.useEffect(() => { if (isPopoverVisible) { const timer = setTimeout(() => { virtualizer.measure() }, 10) const resizeObserver = new ResizeObserver(() => { virtualizer.measure() }) if (parentRef.current) { resizeObserver.observe(parentRef.current) } return () => { clearTimeout(timer) resizeObserver.disconnect() } } }, [isPopoverVisible, virtualizer]) const selectedIconName = (value || selectedIcon) as IconName | undefined const trigger = React.isValidElement(children) ? ( children ) : ( ) return ( {searchable && ( )} {categorized && search.trim() === '' && (
{categoryButtons}
)}
{showIconSkeleton ? : renderVirtualContent()}
) } ) IconPicker.displayName = 'IconPicker' export { IconPicker, type IconData, type IconName }