/* Copyright (C) 2023-2026 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import * as React from 'react' import { createPortal } from 'react-dom' import { Check, ChevronsUpDown } from 'lucide-react' import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { Input } from '@/components/ui/input' export type ComboboxInputOption = { value: string label: string icon?: React.ReactNode } interface ComboboxInputProps { options: ComboboxInputOption[] value?: string onValueChange: (value: string) => void placeholder?: string emptyText?: string className?: string id?: string allowCustomValue?: boolean } export function ComboboxInput({ options, value = '', onValueChange, placeholder = 'Select or type...', emptyText = 'No option found.', className, id, allowCustomValue = false, }: ComboboxInputProps) { const { t } = useTranslation() const [open, setOpen] = React.useState(false) const [searchValue, setSearchValue] = React.useState('') const [highlightedIndex, setHighlightedIndex] = React.useState(-1) const containerRef = React.useRef(null) const inputRef = React.useRef(null) const listRef = React.useRef(null) const selectedOption = React.useMemo( () => options.find((option) => option.value === value), [options, value] ) const displayValue = open ? searchValue : (selectedOption?.label ?? value) const filteredOptions = React.useMemo(() => { if (!searchValue.trim()) return options const search = searchValue.toLowerCase().trim() return options.filter( (option) => option.label.toLowerCase().includes(search) || option.value.toLowerCase().includes(search) ) }, [options, searchValue]) // Reset highlight when filtered options change React.useEffect(() => { setHighlightedIndex(-1) }, [filteredOptions]) // Handle click outside to close React.useEffect(() => { if (!open) return const handleClickOutside = (e: MouseEvent) => { if ( containerRef.current && !containerRef.current.contains(e.target as Node) ) { setOpen(false) setSearchValue('') } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [open]) const handleSelect = (selectedValue: string) => { onValueChange(selectedValue) setOpen(false) setSearchValue('') inputRef.current?.focus() } const handleKeyDown = (e: React.KeyboardEvent) => { if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { setOpen(true) return } if (!open) return switch (e.key) { case 'ArrowDown': e.preventDefault() setHighlightedIndex((prev) => prev < filteredOptions.length - 1 ? prev + 1 : 0 ) break case 'ArrowUp': e.preventDefault() setHighlightedIndex((prev) => prev > 0 ? prev - 1 : filteredOptions.length - 1 ) break case 'Enter': e.preventDefault() if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) { handleSelect(filteredOptions[highlightedIndex].value) } else if (allowCustomValue && searchValue.trim()) { handleSelect(searchValue.trim()) } else { // No highlighted option, just close the dropdown and keep current value setOpen(false) setSearchValue('') } break case 'Escape': e.preventDefault() setOpen(false) setSearchValue('') break } } // Scroll highlighted item into view React.useEffect(() => { if (highlightedIndex < 0 || !listRef.current) return const item = listRef.current.children[highlightedIndex] as HTMLElement item?.scrollIntoView({ block: 'nearest' }) }, [highlightedIndex]) const [dropdownPos, setDropdownPos] = React.useState<{ top: number left: number width: number } | null>(null) const updateDropdownPos = React.useCallback(() => { if (!containerRef.current) return const rect = containerRef.current.getBoundingClientRect() setDropdownPos({ top: rect.bottom + 4, left: rect.left, width: rect.width, }) }, []) // Update dropdown position when open React.useEffect(() => { if (!open) { setDropdownPos(null) return } updateDropdownPos() const handleScroll = () => updateDropdownPos() window.addEventListener('scroll', handleScroll, true) window.addEventListener('resize', handleScroll) return () => { window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('resize', handleScroll) } }, [open, updateDropdownPos]) const showDropdown = open && (filteredOptions.length > 0 || (allowCustomValue && searchValue.trim())) const dropdownContent = showDropdown && dropdownPos ? ( {filteredOptions.length > 0 ? ( {filteredOptions.map((option, index) => ( setHighlightedIndex(index)} onMouseDown={(e) => { e.preventDefault() // Prevent blur handleSelect(option.value) }} > {option.icon && {option.icon}} {option.label} ))} ) : ( {t(emptyText)} {allowCustomValue && searchValue.trim() && ( {t('Press Enter to use "{{value}}"', { value: searchValue.trim(), })} )} )} ) : null return ( { const nextValue = e.target.value setSearchValue(nextValue) if (allowCustomValue) { onValueChange(nextValue) } if (!open) setOpen(true) }} onFocus={() => { setSearchValue(allowCustomValue && !selectedOption ? value : '') setOpen(true) }} onKeyDown={handleKeyDown} className={cn('pr-9', className)} /> {dropdownContent && createPortal(dropdownContent, document.body)} ) }