Created
March 12, 2026 12:32
-
-
Save PhantomKnight287/98df5dfaef4ae65df1c6c22091ef72b2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Icon } from "@iconify/react" | |
| import { useVirtualizer } from "@tanstack/react-virtual" | |
| import { useState, useEffect, useRef, useCallback,} from "react" | |
| import { cn } from "@/lib/utils" | |
| import { Button } from "@/components/ui/button" | |
| import { Input } from "@/components/ui/input" | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover" | |
| interface IconifySearchResponse { | |
| icons: string[] | |
| total: number | |
| limit: number | |
| start: number | |
| collections: Record< | |
| string, | |
| { | |
| name: string | |
| total: number | |
| author: { name: string; url: string } | |
| license: { title: string; spdx: string; url: string } | |
| } | |
| > | |
| } | |
| interface IconPickerProps { | |
| /** Controlled value – an Iconify icon name like `"mdi:home"` */ | |
| value?: string | |
| /** Initial value for uncontrolled usage */ | |
| defaultValue?: string | |
| /** Called when the user picks an icon */ | |
| onChange?: (value: string) => void | |
| /** Placeholder text shown when no icon is selected */ | |
| placeholder?: string | |
| /** Additional class names for the trigger button */ | |
| className?: string | |
| /** Whether the picker is disabled */ | |
| disabled?: boolean | |
| } | |
| const COLUMNS = 8 | |
| const ICON_CELL_SIZE = 40 // px – height of each row / cell | |
| const API_BASE = "https://api.iconify.design" | |
| const SEARCH_LIMIT = 999 | |
| function useDebounce<T>(value: T, delay: number): T { | |
| const [debounced, setDebounced] = useState(value) | |
| useEffect(() => { | |
| const id = setTimeout(() => setDebounced(value), delay) | |
| return () => clearTimeout(id) | |
| }, [value, delay]) | |
| return debounced | |
| } | |
| function IconPicker({ | |
| value: controlledValue, | |
| defaultValue, | |
| onChange, | |
| placeholder = "Pick an icon…", | |
| className, | |
| disabled, | |
| }: IconPickerProps) { | |
| const isControlled = controlledValue !== undefined | |
| const [internalValue, setInternalValue] = useState( | |
| defaultValue ?? "", | |
| ) | |
| const selectedIcon = isControlled ? controlledValue : internalValue | |
| const handleSelect = useCallback( | |
| (icon: string) => { | |
| if (!isControlled) setInternalValue(icon) | |
| onChange?.(icon) | |
| setOpen(false) | |
| }, | |
| [isControlled, onChange], | |
| ) | |
| const [open, setOpen] = useState(false) | |
| const [query, setQuery] = useState("") | |
| const debouncedQuery = useDebounce(query, 300) | |
| const [icons, setIcons] = useState<string[]>([]) | |
| const [loading, setLoading] = useState(false) | |
| const [total, setTotal] = useState(0) | |
| useEffect(() => { | |
| if (!open) return | |
| const trimmed = debouncedQuery.trim() | |
| if (!trimmed) { | |
| setIcons([]) | |
| setTotal(0) | |
| return | |
| } | |
| let cancelled = false | |
| setLoading(true) | |
| const url = `${API_BASE}/search?query=${encodeURIComponent(trimmed)}&limit=${SEARCH_LIMIT}` | |
| fetch(url) | |
| .then((res) => res.json() as Promise<IconifySearchResponse>) | |
| .then((data) => { | |
| if (cancelled) return | |
| setIcons(data.icons) | |
| setTotal(data.total) | |
| }) | |
| .catch(() => { | |
| if (cancelled) return | |
| setIcons([]) | |
| setTotal(0) | |
| }) | |
| .finally(() => { | |
| if (!cancelled) setLoading(false) | |
| }) | |
| return () => { | |
| cancelled = true | |
| } | |
| }, [debouncedQuery, open]) | |
| useEffect(() => { | |
| if (open) { | |
| setQuery("") | |
| setIcons([]) | |
| setTotal(0) | |
| } | |
| }, [open]) | |
| const parentRef = useRef<HTMLDivElement>(null) | |
| const rowCount = Math.ceil(icons.length / COLUMNS) | |
| const virtualizer = useVirtualizer({ | |
| count: rowCount, | |
| getScrollElement: () => parentRef.current, | |
| estimateSize: () => ICON_CELL_SIZE, | |
| overscan: 5, | |
| }) | |
| const inputRef = useRef<HTMLInputElement>(null) | |
| useEffect(() => { | |
| if (open) { | |
| // small delay to wait for popover animation | |
| const id = setTimeout(() => inputRef.current?.focus(), 50) | |
| return () => clearTimeout(id) | |
| } | |
| }, [open]) | |
| return ( | |
| <Popover open={open} onOpenChange={setOpen}> | |
| <PopoverTrigger asChild> | |
| <Button | |
| variant="outline" | |
| size="default" | |
| disabled={disabled} | |
| className={cn( | |
| "w-full justify-start gap-2 font-normal", | |
| !selectedIcon && "text-muted-foreground", | |
| className, | |
| )} | |
| > | |
| {selectedIcon ? ( | |
| <> | |
| <Icon icon={selectedIcon} className="size-4 shrink-0" /> | |
| <span className="truncate">{selectedIcon}</span> | |
| </> | |
| ) : ( | |
| <span>{placeholder}</span> | |
| )} | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent | |
| className="w-[380px] gap-2 p-3" | |
| align="start" | |
| onOpenAutoFocus={(e) => e.preventDefault()} | |
| > | |
| <Input | |
| ref={inputRef} | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| placeholder="Search icons in english..." | |
| className="h-8 text-sm" | |
| /> | |
| <div className="flex items-center justify-between px-1 text-xs text-muted-foreground"> | |
| {loading ? ( | |
| <span>Searching…</span> | |
| ) : ( <span>Type to search icons</span>)} | |
| </div> | |
| <div | |
| ref={parentRef} | |
| className="h-[280px] overflow-auto rounded-lg border" | |
| > | |
| {icons.length > 0 ? ( | |
| <div | |
| className="relative w-full" | |
| style={{ height: virtualizer.getTotalSize() }} | |
| > | |
| {virtualizer.getVirtualItems().map((virtualRow) => { | |
| const startIdx = virtualRow.index * COLUMNS | |
| const rowIcons = icons.slice(startIdx, startIdx + COLUMNS) | |
| return ( | |
| <div | |
| key={virtualRow.index} | |
| className="absolute left-0 top-0 flex w-full" | |
| style={{ | |
| height: virtualRow.size, | |
| transform: `translateY(${virtualRow.start}px)`, | |
| }} | |
| > | |
| {rowIcons.map((icon) => ( | |
| <button | |
| key={icon} | |
| type="button" | |
| title={icon} | |
| onClick={() => handleSelect(icon)} | |
| className={cn( | |
| "flex items-center justify-center rounded-md transition-colors hover:bg-accent", | |
| "size-[40px] shrink-0 cursor-pointer", | |
| selectedIcon === icon && | |
| "bg-primary/10 text-primary ring-1 ring-primary/30", | |
| )} | |
| > | |
| <Icon icon={icon} className="size-5" /> | |
| </button> | |
| ))} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| ) : !loading && query.trim() ? ( | |
| <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> | |
| No icons found | |
| </div> | |
| ) : !loading ? ( | |
| <div className="flex h-full items-center justify-center text-sm text-muted-foreground"> | |
| Start typing to search | |
| </div> | |
| ) : null} | |
| </div> | |
| </PopoverContent> | |
| </Popover> | |
| ) | |
| } | |
| export { IconPicker, type IconPickerProps } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment