import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { X } from 'lucide-react'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; /** Interface for tag select options */ export interface InputSelectOption { /** Value of the option */ value: string; /** Display label of the option */ label: string; } /** Properties for the InputSelect component */ export interface InputSelectProps { /** Options for the select component */ options?: InputSelectOption[]; /** Selected values - string for single select, array for multi select */ value?: string | string[]; /** Callback when value changes */ onChange?: (value: string | string[]) => void; /** Placeholder text */ placeholder?: string; /** Additional class names */ className?: string; /** Style object */ style?: React.CSSProperties; /** Whether to allow multiple selections */ multi?: boolean; } const InputSelect = React.forwardRef( ( { options = [], value = [], onChange, placeholder = 'Select tags...', className, style, multi = false, }, ref, ) => { const [inputValue, setInputValue] = React.useState(''); const [open, setOpen] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false); const inputRef = React.useRef(null); const { t } = useTranslation(); // Normalize value to array for consistent handling const normalizedValue = Array.isArray(value) ? value : value ? [value] : []; /** * Removes a tag from the selected values * @param tagValue - The value of the tag to remove */ const handleRemoveTag = (tagValue: string) => { const newValue = normalizedValue.filter((v) => v !== tagValue); // Return single value if not multi-select, otherwise return array onChange?.(multi ? newValue : newValue[0] || ''); }; /** * Adds a tag to the selected values * @param optionValue - The value of the tag to add */ const handleAddTag = (optionValue: string) => { let newValue: string[]; if (multi) { // For multi-select, add to array if not already included if (!normalizedValue.includes(optionValue)) { newValue = [...normalizedValue, optionValue]; onChange?.(newValue); } } else { // For single-select, replace the value newValue = [optionValue]; onChange?.(optionValue); } setInputValue(''); setOpen(false); // Close the popover after adding a tag }; const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setInputValue(newValue); setOpen(newValue.length > 0); // Open popover when there's input // If input matches an option exactly, add it const matchedOption = options.find( (opt) => opt.label.toLowerCase() === newValue.toLowerCase(), ); if (matchedOption && !normalizedValue.includes(matchedOption.value)) { handleAddTag(matchedOption.value); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if ( e.key === 'Backspace' && inputValue === '' && normalizedValue.length > 0 ) { // Remove last tag when pressing backspace on empty input const newValue = [...normalizedValue]; newValue.pop(); // Return single value if not multi-select, otherwise return array onChange?.(multi ? newValue : newValue[0] || ''); } else if (e.key === 'Enter' && inputValue.trim() !== '') { e.preventDefault(); // Add input value as a new tag if it doesn't exist in options const matchedOption = options.find( (opt) => opt.label.toLowerCase() === inputValue.toLowerCase(), ); if (matchedOption) { handleAddTag(matchedOption.value); } else { // If not in options, create a new tag with the input value if ( !normalizedValue.includes(inputValue) && inputValue.trim() !== '' ) { handleAddTag(inputValue); } } } else if (e.key === 'Escape') { inputRef.current?.blur(); setOpen(false); } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { // Allow navigation in the dropdown return; } }; const handleContainerClick = () => { inputRef.current?.focus(); setOpen(true); setIsFocused(true); }; const handleInputFocus = () => { setOpen(true); setIsFocused(true); }; const handleInputBlur = () => { // Delay closing to allow click on options setTimeout(() => { setOpen(false); setIsFocused(false); }, 150); }; // Filter options to exclude already selected ones (only for multi-select) const availableOptions = multi ? options.filter((option) => !normalizedValue.includes(option.value)) : options; const filteredOptions = availableOptions.filter( (option) => !inputValue || option.label.toLowerCase().includes(inputValue.toLowerCase()), ); // If there are no matching options but there is an input value, create a new option with the input value const hasMatchingOptions = filteredOptions.length > 0; const showInputAsOption = inputValue && !hasMatchingOptions && !normalizedValue.includes(inputValue); const triggerElement = (
{/* Render selected tags - only show tags if multi is true or if single select has a value */} {multi && normalizedValue.map((tagValue) => { const option = options.find((opt) => opt.value === tagValue) || { value: tagValue, label: tagValue, }; return (
{option.label}
); })} {/* For single select, show the selected value as text instead of a tag */} {!multi && normalizedValue[0] && (
{options.find((opt) => opt.value === normalizedValue[0])?.label || normalizedValue[0]}
)} {/* Input field for adding new tags - hide if single select and value is already selected, or in multi select when not focused */} {(multi ? isFocused : multi || !normalizedValue[0]) && ( e.stopPropagation()} onFocus={handleInputFocus} onBlur={handleInputBlur} /> )}
); return ( {triggerElement} e.preventDefault()} // Prevent auto focus on content >
{filteredOptions.length > 0 && filteredOptions.map((option) => (
handleAddTag(option.value)} > {option.label}
))} {showInputAsOption && (
handleAddTag(inputValue)} > {t('common.add')} "{inputValue}"
)} {filteredOptions.length === 0 && !showInputAsOption && (
{t('common.noResults')}
)}
); }, ); InputSelect.displayName = 'InputSelect'; export { InputSelect };