import { useEffect, useId, useRef, useState, type ChangeEvent, type KeyboardEvent, type ReactNode, } from "react"; import { cn } from "../../lib/cn"; import { TextField, type TextFieldProps } from "../text-field"; import { FloatingPortal } from "./floating-portal"; import { SearchGlyph } from "./shared"; export type SearchDropAreaResult = { description?: string; disabled?: boolean; id: string; keywords?: string[]; label: string; leading?: ReactNode; onSelect?: () => void; trailing?: ReactNode; }; export type SearchDropAreaRenderContext = { close: () => void; query: string; }; export type SearchInputProps = Omit & { emptyMessage?: ReactNode; onSearchResultSelect?: (result: SearchDropAreaResult) => void; prefixIcon?: ReactNode; searchDropArea?: | SearchDropAreaResult[] | ReactNode | ((context: SearchDropAreaRenderContext) => ReactNode); searchDropAreaAlign?: "center" | "left" | "right"; searchDropAreaClassName?: string; }; function isResultList(value: SearchInputProps["searchDropArea"]): value is SearchDropAreaResult[] { return Array.isArray(value) && value.every((item) => ( typeof item === "object" && item !== null && "id" in item && "label" in item )); } export function SearchInput({ defaultValue, emptyMessage = "No results found.", id, onBlur, onChange, onFocus, onKeyDown, onSearchResultSelect, prefixIcon, searchDropArea, searchDropAreaAlign = "left", searchDropAreaClassName, value, ...props }: SearchInputProps) { const generatedId = useId(); const fieldId = id ?? generatedId; const listboxId = `${fieldId}-search-results`; const anchorRef = useRef(null); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const [internalQuery, setInternalQuery] = useState( typeof defaultValue === "string" || typeof defaultValue === "number" ? String(defaultValue) : "", ); const query = typeof value === "string" || typeof value === "number" ? String(value) : internalQuery; const resultList = isResultList(searchDropArea) ? searchDropArea : undefined; const normalizedQuery = query.trim().toLowerCase(); const filteredResults = resultList?.filter((result) => { if (!normalizedQuery) return true; return [result.label, result.description, ...(result.keywords ?? [])] .filter(Boolean) .join(" ") .toLowerCase() .includes(normalizedQuery); }) ?? []; const hasDropArea = searchDropArea !== undefined; useEffect(() => { setActiveIndex(0); }, [query]); useEffect(() => { if (!open) return; function handlePointerDown(event: PointerEvent) { if (!anchorRef.current?.contains(event.target as Node)) { setOpen(false); } } function handleEscape(event: globalThis.KeyboardEvent) { if (event.key === "Escape") setOpen(false); } window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleEscape); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleEscape); }; }, [open]); function selectResult(result: SearchDropAreaResult) { if (result.disabled) return; result.onSelect?.(); onSearchResultSelect?.(result); setOpen(false); } function handleChange(event: ChangeEvent) { if (value === undefined) setInternalQuery(event.target.value); if (hasDropArea) setOpen(true); onChange?.(event); } function handleKeyDown(event: KeyboardEvent) { if (resultList && open && filteredResults.length) { if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); const direction = event.key === "ArrowDown" ? 1 : -1; setActiveIndex((current) => ( (current + direction + filteredResults.length) % filteredResults.length )); return; } if (event.key === "Enter") { event.preventDefault(); selectResult(filteredResults[activeIndex] ?? filteredResults[0]); return; } } if (event.key === "Escape") setOpen(false); onKeyDown?.(event); } let customContent: ReactNode = null; if (!resultList) { customContent = typeof searchDropArea === "function" ? searchDropArea({ close: () => setOpen(false), query }) : searchDropArea as ReactNode; } return (
{ if (hasDropArea) setOpen(true); onFocus?.(event); }} onKeyDown={handleKeyDown} prefixIcon={prefixIcon ?? } role={hasDropArea ? "combobox" : props.role} type="search" value={value} />
{resultList ? ( filteredResults.length ? (
{filteredResults.map((result, index) => ( ))}
) : (
{emptyMessage}
) ) : customContent}
); }