import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { Check, Search, X } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { orderItemsBySelectedAndRecent } from "../lib/recent-selections"; import { cn } from "../lib/utils"; import { useMobileEntityPickerViewportStyle } from "../hooks/useMobileEntityPickerViewportStyle"; export interface InlineEntityOption { id: string; label: string; searchText?: string; } interface InlineEntitySelectorProps { value: string; options: InlineEntityOption[]; placeholder: string; noneLabel: string; /** Keep the no-selection action before the selected and recent choices. */ noneAtTop?: boolean; /** Keep the no-selection action after the project choices. */ noneAtEnd?: boolean; searchPlaceholder: string; emptyMessage: string; onChange: (id: string) => void; onConfirm?: () => void; className?: string; renderTriggerValue?: (option: InlineEntityOption | null) => ReactNode; renderOption?: (option: InlineEntityOption, isSelected: boolean) => ReactNode; recentOptionIds?: string[]; /** Skip the Portal so the popover stays in the DOM tree (fixes scroll inside Dialogs). */ disablePortal?: boolean; /** Open the popover when the trigger receives keyboard/programmatic focus. */ openOnFocus?: boolean; /** Disable the trigger and prevent the popover from opening. */ disabled?: boolean; /** Optional test id forwarded to the trigger button. */ triggerTestId?: string; /** Optional slot name used by consuming surfaces for scoped presentation rules. */ triggerDataSlot?: string; /** Runtime geometry variables for the portalled mobile picker sheet. */ contentStyle?: CSSProperties; /** Heading for the large mobile selector modal. Defaults to the placeholder. */ mobileTitle?: string; /** Own the scroll lock when this picker portals outside a parent dialog. */ modal?: boolean; } const EMPTY_RECENT_OPTION_IDS: string[] = []; function useMobileSelectorModal() { const [mobile, setMobile] = useState(() => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(max-width: 40rem)").matches, ); useEffect(() => { if (typeof window.matchMedia !== "function") return; const media = window.matchMedia("(max-width: 40rem)"); const update = () => setMobile(media.matches); update(); media.addEventListener("change", update); return () => media.removeEventListener("change", update); }, []); return mobile; } export const InlineEntitySelector = forwardRef( function InlineEntitySelector( { value, options, placeholder, noneLabel, noneAtTop = false, noneAtEnd = false, searchPlaceholder, emptyMessage, onChange, onConfirm, className, renderTriggerValue, renderOption, recentOptionIds = EMPTY_RECENT_OPTION_IDS, disablePortal, openOnFocus = true, disabled = false, triggerTestId, triggerDataSlot, contentStyle, mobileTitle, modal = false, }, ref, ) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [highlightedIndex, setHighlightedIndex] = useState(0); const mobileSelectorModal = useMobileSelectorModal(); const mobileViewportStyle = useMobileEntityPickerViewportStyle(); const highlightedIndexRef = useRef(0); const inputRef = useRef(null); const shouldPreventCloseAutoFocusRef = useRef(false); const suppressNextTriggerFocusRef = useRef(false); const isPointerDownRef = useRef(false); const allOptions = useMemo(() => { const baseOptions = [{ id: "", label: noneLabel, searchText: noneLabel }, ...options]; const ordered = orderItemsBySelectedAndRecent(baseOptions, value, recentOptionIds); if (noneAtTop) return [baseOptions[0]!, ...ordered.filter((option) => option.id)]; return noneAtEnd ? [...ordered.filter((option) => option.id), baseOptions[0]!] : ordered; }, [noneAtEnd, noneAtTop, noneLabel, options, recentOptionIds, value]); const filteredOptions = useMemo(() => { const term = query.trim().toLowerCase(); if (!term) return allOptions; return allOptions.filter((option) => { if (noneAtTop && !option.id) return true; const haystack = `${option.label} ${option.searchText ?? ""}`.toLowerCase(); return haystack.includes(term); }); }, [allOptions, noneAtTop, query]); const currentOption = options.find((option) => option.id === value) ?? null; const setHighlightedIndexValue = useCallback((next: number | ((current: number) => number)) => { const resolved = typeof next === "function" ? next(highlightedIndexRef.current) : next; highlightedIndexRef.current = resolved; setHighlightedIndex(resolved); }, []); useEffect(() => { if (!open) return; const firstSearchResultIndex = noneAtTop && query.trim() ? filteredOptions.findIndex((option) => option.id) : -1; const selectedIndex = filteredOptions.findIndex((option) => option.id === value); setHighlightedIndexValue( firstSearchResultIndex >= 0 ? firstSearchResultIndex : selectedIndex >= 0 ? selectedIndex : 0, ); }, [filteredOptions, noneAtTop, open, query, setHighlightedIndexValue, value]); const commitSelection = (index: number, moveNext: boolean) => { const option = filteredOptions[index] ?? filteredOptions[0]; if (option) onChange(option.id); shouldPreventCloseAutoFocusRef.current = moveNext; setOpen(false); setQuery(""); if (moveNext && onConfirm) { requestAnimationFrame(() => { onConfirm(); }); } }; return ( { if (disabled) return; setOpen(next); if (!next) setQuery(""); }} > { event.preventDefault(); inputRef.current?.focus(); }} onCloseAutoFocus={(event) => { if (!shouldPreventCloseAutoFocusRef.current) { // Radix returns focus to the trigger on Escape/outside dismissal. // That focus must not immediately reopen the picker. suppressNextTriggerFocusRef.current = true; // Non-modal outside dismissal may keep focus on the clicked // element instead. Limit suppression to Radix's synchronous restore. queueMicrotask(() => { suppressNextTriggerFocusRef.current = false; }); return; } event.preventDefault(); shouldPreventCloseAutoFocusRef.current = false; }} >
{mobileTitle ?? placeholder}
{ setQuery(event.target.value); }} onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); event.stopPropagation(); setHighlightedIndexValue((current) => filteredOptions.length === 0 ? 0 : (current + 1) % filteredOptions.length, ); return; } if (event.key === "ArrowUp") { event.preventDefault(); event.stopPropagation(); setHighlightedIndexValue((current) => { if (filteredOptions.length === 0) return 0; return current <= 0 ? filteredOptions.length - 1 : current - 1; }); return; } if (event.key === "Enter") { event.preventDefault(); event.stopPropagation(); commitSelection(highlightedIndexRef.current, true); return; } if (event.key === "Tab" && !event.shiftKey) { event.preventDefault(); event.stopPropagation(); commitSelection(highlightedIndexRef.current, true); return; } if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setOpen(false); } }} />
{filteredOptions.length === 0 ? (

{emptyMessage}

) : ( filteredOptions.map((option, index) => { const isSelected = option.id === value; const isHighlighted = index === highlightedIndex; return ( ); }) )}
); }, );