"use client"; import { createPortal } from "react-dom"; import { useState, useRef, useMemo, useCallback, useId, type ReactNode } from "react"; import { useDebounce } from "~/hooks/useDebounce.js"; import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; interface EntityComboboxProps { value: string | null; onChange: (id: string | null) => void; placeholder?: string; disabled?: boolean; className?: string; /** Hook that returns search results when the dropdown is open. */ useSearchQuery: (search: string, enabled: boolean) => { data: T[] | undefined }; /** Hook that returns a broader list so the selected item's label can be resolved when the dropdown is closed. */ useSelectedQuery: (id: string | null, enabled: boolean) => { data: T[] | undefined }; /** Derive the display label from an item (shown in the input when closed). */ getLabel: (item: T) => string; /** Optional custom renderer for each dropdown row. Falls back to `getLabel`. */ renderItem?: (item: T, isSelected: boolean) => ReactNode; } export function EntityCombobox({ value, onChange, placeholder = "Search\u2026", disabled = false, className = "", useSearchQuery, useSelectedQuery, getLabel, renderItem, }: EntityComboboxProps) { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const debouncedSearch = useDebounce(search, 300); const containerRef = useRef(null); const inputRef = useRef(null); const listboxId = useId(); const closeDropdown = useCallback(() => { setOpen(false); setSearch(""); }, []); const { data: searchItems } = useSearchQuery(debouncedSearch, open); const items = searchItems ?? []; const { data: selectedItems } = useSelectedQuery(value, !!value && !open); const { panelRef, position } = useAnchoredOverlay({ open, onClose: closeDropdown, align: "start", matchTriggerWidth: true, triggerRef: containerRef, }); const selectedLabel = useMemo(() => { if (!value) return ""; const fromOpen = items.find((i) => i.id === value); if (fromOpen) return getLabel(fromOpen); const fromSelected = selectedItems?.find((i) => i.id === value); if (fromSelected) return getLabel(fromSelected); return value; }, [value, items, selectedItems, getLabel]); function handleFocus() { if (disabled) return; setOpen(true); setSearch(""); } function select(id: string | null) { onChange(id); setOpen(false); setSearch(""); inputRef.current?.blur(); } return (
setSearch(e.target.value)} onFocus={handleFocus} placeholder={placeholder} disabled={disabled} className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${ open ? "border-brand-500 ring-2 ring-brand-500" : "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500" } bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`} readOnly={!open} /> {value && !disabled && !open && ( )}
{open && (typeof document !== "undefined" ? createPortal(
    {items.length === 0 ? (
  • No results
  • ) : ( items.map((item) => (
  • )) )}
, document.body, ) : null)}
); }