import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { isEmpty } from 'lodash'; import { ChevronDown, X } from 'lucide-react'; import * as React from 'react'; import { useTranslation } from 'react-i18next'; import { Popover, PopoverContent, PopoverTrigger } from './popover'; /** * Extracts text content from a ReactNode for filtering purposes. * Handles strings, numbers, JSX elements with nested text, and arrays. */ const getNodeText = (node: React.ReactNode): string => { if (typeof node === 'string' || typeof node === 'number') { return String(node); } if (React.isValidElement(node)) { const children = (node.props as { children?: React.ReactNode }).children; if (children) { return getNodeText(children); } return ''; } if (Array.isArray(node)) { return node.map(getNodeText).join(''); } return ''; }; /** Interface for tag select options */ export interface InputSelectOption { /** Value of the option */ value: string; /** Display label of the option */ label: string | React.ReactNode; } /** Properties for the InputSelect component */ export interface InputSelectProps { /** Options for the select component */ options?: InputSelectOption[]; /** Selected values - type depends on the input type */ value?: string | string[] | number | number[] | Date | Date[]; /** Callback when value changes */ onChange?: ( value: string | string[] | number | number[] | Date | Date[], ) => void; /** Placeholder text */ placeholder?: string; /** Additional class names */ className?: string; /** Style object */ style?: React.CSSProperties; /** Whether to allow multiple selections */ multi?: boolean; /** Type of input: text, number, date, or datetime */ type?: 'text' | 'number' | 'date' | 'datetime'; } /** Internal display for single-select selected value. Click label to re-edit (string labels only). */ const SingleSelectDisplay: React.FC<{ value: string | number | Date; options: InputSelectOption[]; type: 'text' | 'number' | 'date' | 'datetime'; onEdit: (editText: string) => void; onRemove: () => void; }> = ({ value, options, type, onEdit, onRemove }) => { const selectedOption = options.find((opt) => type === 'number' ? Number(opt.value) === Number(value) : type === 'date' || type === 'datetime' ? new Date(opt.value).getTime() === new Date(value as any).getTime() : String(opt.value) === String(value), ); const label = selectedOption?.label ?? (type === 'number' ? String(value) : type === 'date' || type === 'datetime' ? new Date(value as any).toLocaleString() : String(value)); const canEdit = typeof label === 'string'; return (
{ if (!canEdit) return; e.stopPropagation(); onEdit(getNodeText(label)); }} > {label}
); }; const InputSelect = React.forwardRef( ( { options = [], value = [], onChange, placeholder = 'Select tags...', className, style, multi = false, type = 'text', }, 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(); React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement, [ inputRef, ]); // Normalize value to array for consistent handling based on type const normalizedValue = React.useMemo(() => { if (Array.isArray(value)) { return value; } else if (value !== undefined && value !== null) { if (type === 'number') { return typeof value === 'number' ? [value] : [Number(value)]; } else if (type === 'date' || type === 'datetime') { return value instanceof Date ? [value] : [new Date(value as any)]; } else { return typeof value === 'string' ? [value] : [String(value)]; } } else { return []; } }, [value, type]); /** * Removes a tag from the selected values * @param tagValue - The value of the tag to remove */ const handleRemoveTag = (tagValue: any) => { let newValue: any[]; if (type === 'number') { newValue = (normalizedValue as number[]).filter((v) => v !== tagValue); } else if (type === 'date' || type === 'datetime') { newValue = (normalizedValue as Date[]).filter( (v) => v.getTime() !== tagValue.getTime(), ); } else { newValue = (normalizedValue as string[]).filter((v) => v !== tagValue); } // Return single value if not multi-select, otherwise return array let result: string | number | Date | string[] | number[] | Date[]; if (multi) { result = newValue; } else { if (type === 'number') { result = newValue[0] || 0; } else if (type === 'date' || type === 'datetime') { result = newValue[0] || new Date(); } else { result = newValue[0] || ''; } } onChange?.(result); }; /** * Adds a tag to the selected values * @param optionValue - The value of the tag to add */ const handleAddTag = (optionValue: any) => { let newValue: any[]; if (multi) { // For multi-select, add to array if not already included if (type === 'number') { const numValue = typeof optionValue === 'number' ? optionValue : Number(optionValue); if ( !(normalizedValue as number[]).includes(numValue) && !isNaN(numValue) ) { newValue = [...(normalizedValue as number[]), numValue]; onChange?.(newValue as number[]); } } else if (type === 'date' || type === 'datetime') { const dateValue = optionValue instanceof Date ? optionValue : new Date(optionValue); if ( !(normalizedValue as Date[]).some( (d) => d.getTime() === dateValue.getTime(), ) ) { newValue = [...(normalizedValue as Date[]), dateValue]; onChange?.(newValue as Date[]); } } else { if (!(normalizedValue as string[]).includes(optionValue)) { newValue = [...(normalizedValue as string[]), optionValue]; onChange?.(newValue as string[]); } } } else { // For single-select, replace the value if (type === 'number') { const numValue = typeof optionValue === 'number' ? optionValue : Number(optionValue); if (!isNaN(numValue)) { onChange?.(numValue); } } else if (type === 'date' || type === 'datetime') { const dateValue = optionValue instanceof Date ? optionValue : new Date(optionValue); onChange?.(dateValue); } else { 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); // Open popover when there's input }; /** * Commits the current inputValue to the selected values, matching by label first, * then falling back to the typed value. No-op when inputValue is empty/whitespace. * Used by Enter key handler and blur handler. */ const commitInputValue = () => { if (inputValue.trim() === '') return; // Match by label text first const matchedOption = options.find( (opt) => getNodeText(opt.label).toLowerCase() === inputValue.toLowerCase(), ); if (matchedOption) { handleAddTag(matchedOption.value); return; } // Otherwise, validate by type and add as a new value let valueToAdd: any; if (type === 'number') { const numValue = Number(inputValue); if (isNaN(numValue)) return; valueToAdd = numValue; } else if (type === 'date' || type === 'datetime') { const dateValue = new Date(inputValue); if (isNaN(dateValue.getTime())) return; valueToAdd = dateValue; } else { valueToAdd = inputValue; } // Skip if value is already selected const isAlreadySelected = normalizedValue.some((v) => type === 'number' ? Number(v) === Number(valueToAdd) : type === 'date' || type === 'datetime' ? new Date(v as any).getTime() === valueToAdd.getTime() : String(v) === valueToAdd, ); if (!isAlreadySelected) { handleAddTag(valueToAdd); } }; 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 let result: string | number | Date | string[] | number[] | Date[]; if (multi) { if (type === 'number') { result = newValue as number[]; } else if (type === 'date' || type === 'datetime') { result = newValue as Date[]; } else { result = newValue as string[]; } } else { if (type === 'number') { result = newValue[0] || 0; } else if (type === 'date' || type === 'datetime') { result = newValue[0] || new Date(); } else { result = newValue[0] || ''; } } onChange?.(result); } else if (e.key === 'Enter' && inputValue.trim() !== '') { e.preventDefault(); commitInputValue(); } 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 to register setTimeout(() => { commitInputValue(); setOpen(false); setIsFocused(false); }, 150); }; // Filter options to exclude already selected ones (only for multi-select) const availableOptions = multi ? options.filter( (option) => !normalizedValue.some((v) => type === 'number' ? Number(v) === Number(option.value) : type === 'date' || type === 'datetime' ? new Date(v as any).getTime() === new Date(option.value).getTime() : String(v) === option.value, ), ) : options; const filteredOptions = availableOptions.filter( (option) => !inputValue || getNodeText(option.label) .toLowerCase() .includes(inputValue.toString().toLowerCase()), ); // If there are no matching options but there is an input value, create a new option with the input value const showInputAsOption = React.useMemo(() => { if (!inputValue) return false; const hasLabelMatch = options.some( (option) => getNodeText(option.label).toLowerCase() === inputValue.toString().toLowerCase(), ); let isAlreadySelected = false; if (type === 'number') { const numValue = Number(inputValue); isAlreadySelected = !isNaN(numValue) && (normalizedValue as number[]).includes(numValue); } else if (type === 'date' || type === 'datetime') { const dateValue = new Date(inputValue); isAlreadySelected = !isNaN(dateValue.getTime()) && (normalizedValue as Date[]).some( (d) => d.getTime() === dateValue.getTime(), ); } else { isAlreadySelected = (normalizedValue as string[]).includes(inputValue); } return ( !hasLabelMatch && !isAlreadySelected && inputValue.toString().trim() !== '' ); }, [inputValue, options, normalizedValue, type]); const triggerElement = (
{/* Wrapper for tags and input - this part wraps */}
{/* Render selected tags - only show tags if multi is true or if single select has a value */} {multi && normalizedValue.map((tagValue, index) => { const option = options.find((opt) => type === 'number' ? Number(opt.value) === Number(tagValue) : type === 'date' || type === 'datetime' ? new Date(opt.value).getTime() === new Date(tagValue).getTime() : String(opt.value) === String(tagValue), ) || { value: String(tagValue), label: String(tagValue), }; return (
{option.label}
); })} {/* For single select, show the selected value as text instead of a tag */} {!multi && !isEmpty(normalizedValue[0]) && ( { handleRemoveTag(normalizedValue[0]); setInputValue(editText); setIsFocused(true); setOpen(true); requestAnimationFrame(() => { const input = inputRef.current; if (input) { input.focus(); input.setSelectionRange(editText.length, editText.length); } }); }} onRemove={() => handleRemoveTag(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 || isEmpty(normalizedValue[0])) && ( e.stopPropagation()} onFocus={handleInputFocus} onBlur={handleInputBlur} /> )}
); return ( {triggerElement} e.preventDefault()} // Prevent auto focus on content >
{filteredOptions.length > 0 && filteredOptions.map((option) => (
{ let optionValue: any; if (type === 'number') { optionValue = Number(option.value); if (isNaN(optionValue)) return; // Skip invalid numbers } else if (type === 'date' || type === 'datetime') { optionValue = new Date(option.value); if (isNaN(optionValue.getTime())) return; // Skip invalid dates } else { optionValue = option.value; } handleAddTag(optionValue); }} > {option.label}
))} {showInputAsOption && (
handleAddTag( type === 'number' ? Number(inputValue) : type === 'date' || type === 'datetime' ? new Date(inputValue) : inputValue, ) } > {t('common.add')} "{inputValue}"
)} {filteredOptions.length === 0 && !showInputAsOption && (
{t('common.noResults')}
)}
); }, ); InputSelect.displayName = 'InputSelect'; export { InputSelect };