diff --git a/src/client/components/IconPicker.tsx b/src/client/components/IconPicker.tsx new file mode 100644 index 0000000..80c8ab6 --- /dev/null +++ b/src/client/components/IconPicker.tsx @@ -0,0 +1,243 @@ +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, + )} + + ); +}