"use client" import { Check, ChevronsUpDown, Loader2, X } from 'lucide-react'; import * as React from 'react'; import { useAppT } from '@djangocfg/i18n'; import { cn } from '../../lib/utils'; import { Badge } from '../data/badge'; import { Button } from '../forms/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '../navigation/command'; import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover'; export interface ComboboxAsyncOption { value: string label: string description?: string icon?: React.ComponentType<{ className?: string }> badge?: string | React.ReactNode disabled?: boolean } export interface ComboboxAsyncProps { /** * Options currently visible in the dropdown. * * The parent owns this list — typically a memoised projection of the * latest server response keyed off ``searchValue``. The component does * NOT filter ``options`` further; whatever you pass in is what shows. */ options: ComboboxAsyncOption[] /** * Selected option id, or ``null`` when nothing is selected. * Always controlled — there's no internal state, because the canonical * value lives upstream (a form, a parent context, query string, …). */ value: string | null /** Fires with ``null`` when the user clears the selection. */ onValueChange: (value: string | null) => void /** * Controlled search input. Parent debounces and feeds it back into the * data hook that produces ``options`` — same shape as * ``MultiSelectProAsync``. */ searchValue: string onSearchChange: (value: string) => void /** Show a spinner row + "Searching…" affordance while the parent fetch is in flight. */ isLoading?: boolean /** * Labels for ids that are selected but might not be present in * ``options`` right now (e.g. dialog opened with a pre-filled value * whose row isn't in the latest search response). * * Without this the trigger would show the raw id. Provide one entry per * id you'd like resolved; the component falls back to the id only if * neither ``options`` nor ``seedOptions`` contain it. */ seedOptions?: ComboboxAsyncOption[] placeholder?: string searchPlaceholder?: string emptyText?: string loadingText?: string className?: string disabled?: boolean /** Whether to render an inline ``×`` button on the trigger to clear. */ clearable?: boolean renderOption?: (option: ComboboxAsyncOption) => React.ReactNode renderValue?: (option: ComboboxAsyncOption | undefined) => React.ReactNode } /** * Single-select combobox with parent-owned async search. * * Pairs with ``MultiSelectProAsync`` — same controlled-search / * isLoading / seedOptions contract, just for "pick one" cases. Use * this when the dataset is too large to ship to the browser up-front * (typeaheads against a server-side search endpoint). * * For static / pre-loaded option lists use the plain ``Combobox`` — * its built-in client-side filtering is the right tool there. */ export function ComboboxAsync({ options, value, onValueChange, searchValue, onSearchChange, isLoading = false, seedOptions, placeholder, searchPlaceholder, emptyText, loadingText, className, disabled = false, clearable = true, renderOption, renderValue, }: ComboboxAsyncProps) { const t = useAppT() const [open, setOpen] = React.useState(false) const scrollRef = React.useRef(null) const resolvedPlaceholder = placeholder ?? t('ui.select.placeholder') const resolvedSearchPlaceholder = searchPlaceholder ?? t('ui.select.search') const resolvedEmptyText = emptyText ?? t('ui.select.noResults') const resolvedLoadingText = loadingText ?? t('ui.select.loading') // Resolve the selected option from options first (freshest label), // fall back to seedOptions for the case where the value's row isn't // in the current search response. const selectedOption = React.useMemo(() => { if (!value) return undefined return ( options.find((o) => o.value === value) ?? seedOptions?.find((o) => o.value === value) ) }, [options, seedOptions, value]) React.useEffect(() => { if (scrollRef.current && open) { const el = scrollRef.current el.style.cssText = ` max-height: 300px !important; overflow-y: auto !important; overflow-x: hidden !important; -webkit-overflow-scrolling: touch !important; overscroll-behavior: contain !important; ` } }, [open]) const handleSelect = React.useCallback( (currentValue: string) => { // Click again on the selected row → clear. const next = currentValue === value ? null : currentValue onValueChange(next) setOpen(false) }, [value, onValueChange], ) const handleClear = React.useCallback( (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() onValueChange(null) }, [onValueChange], ) return ( { setOpen(isOpen) if (!isOpen) onSearchChange('') }} >
e.stopPropagation()} > {isLoading && options.length === 0 ? (
{resolvedLoadingText}
) : options.length === 0 ? ( {resolvedEmptyText} ) : ( {options.map((option) => ( { if (!option.disabled) handleSelect(currentValue) }} disabled={option.disabled} > {option.icon && } {renderOption ? ( renderOption(option) ) : (
{option.label} {option.badge && ( {option.badge} )}
{option.description && ( {option.description} )}
)}
))}
)}
) } function SelectedTriggerLabel({ option }: { option: ComboboxAsyncOption }) { const Icon = option.icon return (
{Icon && } {option.label} {option.badge && ( {option.badge} )}
) }