import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { iconGroups, LucideIcon } from "../lib/iconData"; interface IconPickerProps { value: string; onChange: (icon: string) => void; size?: "sm" | "md"; } export function IconPicker({ value, onChange, size = "md", }: IconPickerProps) { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [activeGroup, setActiveGroup] = useState(0); const [position, setPosition] = useState<{ top: number; left: number }>({ top: 0, left: 0, }); const triggerRef = useRef(null); const popoverRef = useRef(null); const searchRef = useRef(null); const updatePosition = useCallback(() => { if (!triggerRef.current) return; const rect = triggerRef.current.getBoundingClientRect(); const popoverHeight = 360; const spaceBelow = window.innerHeight - rect.bottom; const openUpward = spaceBelow < popoverHeight && rect.top > spaceBelow; setPosition({ top: openUpward ? rect.top - popoverHeight : rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - 288 - 8), }); }, []); // Position the popover when opened useEffect(() => { if (!isOpen) return; updatePosition(); }, [isOpen, updatePosition]); // Stop mousedown from propagating out of the portal so parent // click-outside handlers (e.g. CategoryPicker) don't close. useEffect(() => { const el = popoverRef.current; if (!isOpen || !el) return; function stopProp(e: MouseEvent) { e.stopPropagation(); } el.addEventListener("mousedown", stopProp); return () => el.removeEventListener("mousedown", stopProp); }, [isOpen]); // Close on click-outside useEffect(() => { if (!isOpen) return; function handleClickOutside(e: MouseEvent) { const target = e.target as Node; if ( triggerRef.current?.contains(target) || popoverRef.current?.contains(target) ) { return; } setIsOpen(false); setSearch(""); } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [isOpen]); // Close on Escape useEffect(() => { if (!isOpen) return; function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { setIsOpen(false); setSearch(""); } } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen]); // Focus search input when opened useEffect(() => { if (isOpen) { requestAnimationFrame(() => searchRef.current?.focus()); } }, [isOpen]); const filteredIcons = useMemo(() => { if (!search.trim()) return null; const q = search.toLowerCase(); const results = iconGroups.flatMap((group) => group.icons.filter( (icon) => icon.name.includes(q) || icon.keywords.some((kw) => kw.includes(q)), ), ); // Deduplicate by name (some icons appear in multiple groups) const seen = new Set(); return results.filter((icon) => { if (seen.has(icon.name)) return false; seen.add(icon.name); return true; }); }, [search]); function handleSelect(iconName: string) { onChange(iconName); setIsOpen(false); setSearch(""); } const buttonSize = size === "sm" ? "w-10 h-10" : "w-12 h-12"; const iconSize = size === "sm" ? 20 : 24; return ( <> {isOpen && createPortal(
{/* Search */}
{ setSearch(e.target.value); setActiveGroup(0); }} placeholder="Search icons..." className="w-full px-2 py-1.5 text-sm border border-gray-200 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
{/* Group tabs */} {!search.trim() && (
{iconGroups.map((group, i) => ( ))}
)} {/* Icon grid */}
{search.trim() ? ( filteredIcons && filteredIcons.length > 0 ? (
{filteredIcons.map((icon) => ( ))}
) : (

No icons found

) ) : (
{iconGroups[activeGroup].icons.map((icon) => ( ))}
)}
, document.body, )} ); }