import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Box, Button, Divider, Grid, Input, Loader, Popover} from "@mantine/core"; import Clipboard from "clipboard"; import {useDebouncedValue} from "@mantine/hooks"; export interface PopoverSearchIconConfig { resultsPerPage?: number; availableStyles?: string[]; contentColor?: string; contentSize?: number; searchLabel?: string; paginationLabel?: string; buttonNode?: React.ReactNode; buttonLabel?: string; buttonIconName?: string; buttonIconSize?: number; buttonIconColor?: string; buttonColor?: string; showBothIconAndText?: boolean; noIconsFoundText?: string; } interface PopoverSearchIconProps { onSelect?: (iconName: string) => void; config?: PopoverSearchIconConfig; } interface IconProps { name: string; type: string; subtypes: string[]; currentSubtype: string; size?: number; color?: string; clipboard: { copier: (text: string) => void; }; } interface IconType { name: string; type: string; subtypes: string[]; } const DEFAULT_ICON_SUBTYPES = ["solid", "regular", "light", "thin", "duotone"]; const DEFAULT_ICONS_PER_PAGE = 48; // Move this outside component to avoid recreation on each render const copier = (icon: string) => { new Clipboard("#copy", { text: () => icon, }); }; // Cache promise to avoid duplicate requests let iconsPromise: Promise<{ generic: IconType[]; brands: IconType[] }> | null = null; const getIcons = async (): Promise<{ generic: IconType[]; brands: IconType[] }> => { if (!iconsPromise) { iconsPromise = Promise.all([ import("./dist/icons/generic_icons.json"), import("./dist/icons/brand_icons.json") ]).then(([genericModule, brandModule]) => { const generic_icons = genericModule.default; const brand_icons = brandModule.default; const convertToArray = (icons: { [key: string]: string }, type: string): IconType[] => { return Object.keys(icons).map((name) => ({ name, type, subtypes: [], })); }; return { generic: convertToArray(generic_icons, "generic"), brands: convertToArray(brand_icons, "brands"), }; }); } return iconsPromise; }; const Icon: React.FC = ({ name, type, subtypes, currentSubtype, size = 18, color = "black", clipboard: {copier} }) => { const copyIconTag = (type: string, subtype: string, name: string) => { copier(`${type === "sharp" ? "fa-sharp " : ""}fa-${subtype} fa-${name}`); }; return ( copyIconTag(type, currentSubtype, name)} title={name} > ); }; const PopoverSearchIcon: React.FC = ({onSelect, config}) => { const [allIcons, setAllIcons] = useState([]); const [search, setSearch] = useState(""); const [debouncedSearch] = useDebouncedValue(search, 500); const [popoverOpened, setPopoverOpened] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [loading, setLoading] = useState(true); const [iconClicked, setIconClicked] = useState(null); const iconSubtypes = config?.availableStyles || DEFAULT_ICON_SUBTYPES; const defaultSubtype = iconSubtypes[0] || DEFAULT_ICON_SUBTYPES[0]; const [selectedSubtype, setSelectedSubtype] = useState(defaultSubtype); const iconsPerPage = config?.resultsPerPage || DEFAULT_ICONS_PER_PAGE; // Memoize filtered icons const filteredIcons = useMemo(() => { const searchTerm = debouncedSearch.toLowerCase().replace(/[-_\s]/g, ""); return allIcons.filter(({name}) => name.toLowerCase().includes(searchTerm)); }, [debouncedSearch, allIcons]); // Memoize grouped icons by subtype const groupedIcons = useMemo(() => { return filteredIcons.reduce((acc, icon) => { icon.subtypes.forEach(subtype => { if (!acc[subtype]) { acc[subtype] = []; } acc[subtype].push(icon); }); return acc; }, {} as { [key: string]: IconType[] }); }, [filteredIcons]); // Memoize current page icons const currentPageIcons = useMemo(() => { const start = (currentPage - 1) * iconsPerPage; const end = currentPage * iconsPerPage; return groupedIcons[selectedSubtype]?.slice(start, end) || []; }, [groupedIcons, selectedSubtype, currentPage, iconsPerPage]); // Use useCallback for handlers const handleIconClick = useCallback((icon: IconType) => { const className = `${icon.type === "sharp" ? "fa-sharp " : ""}fa-${selectedSubtype} fa-${icon.name}`; setIconClicked(icon.name); setTimeout(() => { setIconClicked(null); onSelect?.(className); // Return the full className setPopoverOpened(false); }, 300); // Adjust the timeout duration as needed }, [onSelect, selectedSubtype]); const paginate = useCallback((pageNumber: number) => { setCurrentPage(pageNumber); }, []); const handleSubtypeChange = useCallback((subtype: string) => { setCurrentPage(1); setSelectedSubtype(subtype); }, []); const handlePopoverOpen = useCallback(() => { setSearch(""); setSelectedSubtype(defaultSubtype); setTimeout(() => { setPopoverOpened(true); }, 300); }, [defaultSubtype]); const handlePopoverClose = useCallback(() => { setPopoverOpened(false); }, []); const handleSearchChange = useCallback((e: React.ChangeEvent) => { setSearch(e.currentTarget.value); setCurrentPage(1); }, []); // Load icons when popover opens useEffect(() => { if (!popoverOpened) return; setLoading(true); const fetchIcons = async () => { try { const icons = await getIcons(); const flattenIcons: IconType[] = [ ...icons.generic.map(icon => ({ ...icon, type: "classic", subtypes: DEFAULT_ICON_SUBTYPES })), ...icons.brands.map(icon => ({ ...icon, type: "brands", subtypes: ["brands"] })) ]; setAllIcons(flattenIcons); } catch (error) { console.error("Failed to load icons:", error); } finally { setLoading(false); } }; fetchIcons(); }, [popoverOpened]); // Update selected subtype when search changes useEffect(() => { if (filteredIcons.length < 1) return; const availableSubtypes = Object.keys(groupedIcons); if (availableSubtypes.length > 0 && !availableSubtypes.includes(selectedSubtype)) { setSelectedSubtype(availableSubtypes[0]); } }, [debouncedSearch, groupedIcons, selectedSubtype, filteredIcons.length]); // Memoize the button content const buttonContent = useMemo(() => { if (config?.buttonNode) { return config?.buttonNode; } else if (config?.showBothIconAndText && config?.buttonIconName && !config?.buttonNode) { return ( <> {config?.buttonLabel || "Select Icon"} ); } else if (!config?.showBothIconAndText && config?.buttonIconName) { return ( ); } return config?.buttonLabel || "Select Icon"; }, [config]); // Fixed icons for navigation and subtype indicators const subtypeIndicatorIcons = useMemo(() => { const indicators: Record = { brands: "chrome", default: "apartment" }; const availableSubtypes = config?.availableStyles || Object.keys(groupedIcons); return availableSubtypes.map(subtype => ({ subtype, iconName: subtype === "brands" ? indicators.brands : indicators.default })); }, [groupedIcons, config?.availableStyles]); return ( {config?.buttonNode ? ( setPopoverOpened((o) => !o)}>{buttonContent} ) : ( )} {loading && ( )} {!loading && Object.keys(groupedIcons).length > 0 && groupedIcons[selectedSubtype]?.length > 0 && ( <>

{selectedSubtype.charAt(0).toUpperCase() + selectedSubtype.slice(1)}

{currentPageIcons.map((icon, i) => ( handleIconClick(icon)} style={{ cursor: "pointer", textAlign: "center", transition: "transform 0.2s", transform: iconClicked === icon.name ? "scale(0.9)" : "scale(1)" }} > ))} )} {!loading && (filteredIcons.length === 0 || allIcons.length === 0) && (

{config?.noIconsFoundText || "No icons found"}

)} {!loading && Object.keys(groupedIcons).length > 0 && ( {subtypeIndicatorIcons.map(({subtype, iconName}) => ( handleSubtypeChange(subtype)} > {allIcons .filter(icon => icon.name === iconName && icon.subtypes.includes(subtype)) .map((icon, i) => ( ))} ))} )} {!loading && filteredIcons.length > iconsPerPage && (
{config?.paginationLabel?.replace("%d", currentPage.toString()).replace("%s", Math.ceil(filteredIcons.length / iconsPerPage).toString()) || `Page ${currentPage} of ${Math.ceil(filteredIcons.length / iconsPerPage)}`}
)}
); }; export default PopoverSearchIcon;