import { Check, ChevronsUpDown } from "lucide-react"; import { useMemo, useRef, useState, type ReactNode } from "react"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { fuzzyTextMatchesQuery, normalizeSearchText, scoreFuzzyTextFields } from "@/lib/searchable-select"; import { cn } from "@/lib/utils"; export interface SearchableSelectOption { key: string; value: TValue; label: string; title?: string; searchText?: string; disabled?: boolean; } export interface SearchableSelectGroup = SearchableSelectOption> { id: string; label?: string; options: readonly TOption[]; } interface SearchableSelectRenderState { selected: boolean; } export interface SearchableSelectProps< TValue extends string = string, TOption extends SearchableSelectOption = SearchableSelectOption, > { value: TValue | ""; groups: readonly SearchableSelectGroup[]; onValueChange: (value: TValue, option: TOption) => void | boolean | { close?: boolean }; placeholder: string; searchPlaceholder?: string; emptyMessage?: string; loadingMessage?: string; loading?: boolean; disabled?: boolean; className?: string; triggerClassName?: string; contentClassName?: string; align?: "start" | "center" | "end"; contentWidth?: "trigger" | "auto"; renderValue?: (option: TOption | null) => ReactNode; renderOption?: (option: TOption, state: SearchableSelectRenderState) => ReactNode; deriveGroups?: ( query: string, groups: readonly SearchableSelectGroup[], ) => readonly SearchableSelectGroup[]; filterOption?: (option: TOption, query: string) => boolean; scoreOption?: (option: TOption, query: string) => number | null; disablePortal?: boolean; /** * Optional pinned "creatable" item rendered at the bottom of the list, * regardless of the query (used e.g. by the secret picker's * `+ Create secret ""…` affordance). `render` receives the live * query so the label can echo it; `onSelect` fires with the query and the * popover closes. */ createItem?: { render: (query: string) => ReactNode; onSelect: (query: string) => void; }; } function defaultFilterOption(option: SearchableSelectOption, query: string) { return fuzzyTextMatchesQuery(`${option.label} ${option.searchText ?? ""}`, query); } function defaultScoreOption(option: SearchableSelectOption, query: string) { return scoreFuzzyTextFields([ { text: option.label, weight: 0 }, { text: option.searchText, weight: 20 }, ], query); } export function SearchableSelect< TValue extends string = string, TOption extends SearchableSelectOption = SearchableSelectOption, >({ value, groups, onValueChange, placeholder, searchPlaceholder = "Search...", emptyMessage = "No options found.", loadingMessage = "Loading...", loading = false, disabled = false, className, triggerClassName, contentClassName, align = "start", contentWidth = "trigger", renderValue, renderOption, deriveGroups, filterOption = defaultFilterOption, scoreOption, disablePortal, createItem, }: SearchableSelectProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const pointerFocusRef = useRef(false); const suppressNextTriggerFocusRef = useRef(false); const selectedOption = useMemo(() => { for (const group of groups) { const option = group.options.find((candidate) => candidate.value === value); if (option) return option; } return null; }, [groups, value]); const visibleGroups = useMemo( () => (deriveGroups ? deriveGroups(query, groups) : groups), [deriveGroups, groups, query], ); const filteredGroups = useMemo(() => { if (loading) return []; const normalizedQuery = normalizeSearchText(query); return visibleGroups .map((group) => { const options = group.options .map((option, index) => { if (!normalizedQuery) return { option, index, score: 0 }; if (!filterOption(option, query)) return null; if (scoreOption) { const score = scoreOption(option, query); return score === null ? null : { option, index, score }; } return { option, index, score: defaultScoreOption(option, query) ?? Number.MAX_SAFE_INTEGER, }; }) .filter((entry): entry is { option: TOption; index: number; score: number } => entry !== null); if (normalizedQuery) { options.sort((a, b) => a.score - b.score || a.index - b.index); } return { ...group, options: options.map((entry) => entry.option), }; }) .filter((group) => group.options.length > 0); }, [filterOption, loading, query, scoreOption, visibleGroups]); const hasOptions = filteredGroups.some((group) => group.options.length > 0); function closePopover({ suppressTriggerFocus = false }: { suppressTriggerFocus?: boolean } = {}) { if (suppressTriggerFocus) { suppressNextTriggerFocusRef.current = true; } setOpen(false); setQuery(""); } function selectOption(option: TOption) { if (option.disabled) return; suppressNextTriggerFocusRef.current = true; const result = onValueChange(option.value, option); if (result === false || (typeof result === "object" && result?.close === false)) { return; } closePopover(); } return ( { setOpen(next); if (!next) setQuery(""); }} > { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closePopover({ suppressTriggerFocus: true }); } }} > { if (event.currentTarget.scrollHeight > event.currentTarget.clientHeight) { event.stopPropagation(); } }} > {loading ? (
{loadingMessage}
) : ( <> {!hasOptions && !createItem ? {emptyMessage} : null} {!hasOptions && createItem ? (
{emptyMessage}
) : null} {filteredGroups.map((group) => ( {group.options.map((option) => { const selected = option.value === value; return ( selectOption(option)} > {renderOption ? renderOption(option, { selected }) : {option.label}} ); })} ))} {createItem ? ( { const q = query; suppressNextTriggerFocusRef.current = true; closePopover(); // Defer to the next macrotask so this popover fully // closes (and Radix returns focus to the trigger) // before the consumer opens its own popover. Firing // synchronously lets the close's focus-return land as a // `focusOutside` on the newly-opened popover, which // Radix would immediately dismiss (see PAP-12476). window.setTimeout(() => createItem.onSelect(q), 0); }} > {createItem.render(query)} ) : null} )}
); }