import React, { forwardRef, Ref, useEffect, useRef, useState } from "react"; import { Button, ContentMessage, Icon, Input, PopoverContent, PopoverRoot, PopoverTrigger, ScrollArea, ScrollBar, Spinner, } from "@sparkle/components"; import { ContentMessageProps } from "@sparkle/components/ContentMessage"; import { ListCheckIcon, MagnifyingGlassIcon, XMarkIcon, } from "@sparkle/icons/app"; import { cn } from "@sparkle/lib/utils"; export interface SearchInputProps { placeholder?: string; value: string | null; onChange: (value: string) => void; onKeyDown?: (e: React.KeyboardEvent) => void; onFocus?: () => void; name: string; disabled?: boolean; isLoading?: boolean; className?: string; } export const SearchInput = forwardRef( ( { placeholder = "Search", value, onChange, onKeyDown, onFocus, name, disabled = false, isLoading = false, className, }, ref ) => { const clearInputField = () => { onChange(""); }; return (
{ onChange(e.target.value); }} onFocus={onFocus} onKeyDown={onKeyDown} disabled={disabled} ref={ref} />
{isLoading ? (
) : value ? (
); } ); SearchInput.displayName = "SearchInput"; type SearchInputWithPopoverBaseProps = SearchInputProps & { contentClassName?: string; open: boolean; onOpenChange: (open: boolean) => void; mountPortal?: boolean; mountPortalContainer?: HTMLElement; items: T[]; renderItem: (item: T, selected: boolean) => React.ReactNode; onItemSelect?: (item: T) => void; onSelectAll?: () => void; noResults?: string; isLoading?: boolean; contentMessage?: ContentMessageProps; displayItemCount?: boolean; totalItems?: number; }; function BaseSearchInputWithPopover( { items, renderItem, onItemSelect, onSelectAll, contentClassName, className, open, onOpenChange, value, onChange, mountPortal, mountPortalContainer, noResults, isLoading, contentMessage, displayItemCount = false, totalItems, ...searchInputProps }: SearchInputWithPopoverBaseProps, ref: Ref ) { const [selectedIndex, setSelectedIndex] = useState(0); const itemRefs = useRef<(HTMLElement | null)[]>([]); useEffect(() => { itemRefs.current = new Array(items.length).fill(null); }, [items.length]); useEffect(() => { setSelectedIndex(0); }, [items]); const handleKeyDown = (e: React.KeyboardEvent) => { if (!open || !items.length) { return; } switch (e.key) { case "ArrowUp": e.preventDefault(); setSelectedIndex((current) => { const newIndex = (current - 1 + items.length) % items.length; itemRefs.current[newIndex]?.scrollIntoView({ block: "nearest" }); return newIndex; }); break; case "ArrowDown": e.preventDefault(); setSelectedIndex((current) => { const newIndex = (current + 1) % items.length; itemRefs.current[newIndex]?.scrollIntoView({ block: "nearest" }); return newIndex; }); break; case "Enter": e.preventDefault(); onOpenChange(false); if (items[selectedIndex] && onItemSelect) { onItemSelect(items[selectedIndex]); } break; } }; return ( { onChange?.(newValue); if (newValue && !open) { onOpenChange(true); } }} {...searchInputProps} onKeyDown={handleKeyDown} aria-expanded={open} aria-haspopup="listbox" aria-controls="search-popover-content" /> e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} onInteractOutside={() => onOpenChange(false)} mountPortal={mountPortal} mountPortalContainer={mountPortalContainer} >
{items.length > 0 && (displayItemCount || onSelectAll) && (
{displayItemCount && ( {items.length} search results {totalItems && ` (out of ${totalItems})`}. )}
{onSelectAll && (
)} {items.length > 0 ? ( items.map((item, index) => (
(itemRefs.current[index] = el)}> {renderItem(item, selectedIndex === index)}
)) ) : isLoading ? (
) : (
{noResults ?? ""}
)}
{contentMessage && (
)}
); } export const SearchInputWithPopover = forwardRef( BaseSearchInputWithPopover ) as ( props: SearchInputWithPopoverBaseProps & { ref?: Ref } ) => ReturnType;