// // Copyright (c) 2025 rustmailer.com (https://rustmailer.com) // // This file is part of the Bichon Email Archiving Project // // 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 . 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 { cn } from '@/lib/utils'; import { useVirtualizer } from '@tanstack/react-virtual'; import { CheckIcon, ChevronsUpDown } from 'lucide-react'; import * as React from 'react'; type Option = { value: string; label: string; description?: string; }; interface VirtualizedCommandProps { height: string; options: Option[]; placeholder: string; selectedOptions: string[]; onSelectOption?: (options: string[]) => void; noItemsComponent?: React.ReactNode; multiple: boolean; } const VirtualizedCommand = ({ height, options, placeholder, selectedOptions, onSelectOption, noItemsComponent =
No items available
, multiple, }: VirtualizedCommandProps) => { const [filteredOptions, setFilteredOptions] = React.useState(options); const [focusedIndex, setFocusedIndex] = React.useState(0); const [isKeyboardNavActive, setIsKeyboardNavActive] = React.useState(false); const parentRef = React.useRef(null); const virtualizer = useVirtualizer({ count: filteredOptions.length, getScrollElement: () => parentRef.current, estimateSize: () => 35, }); const virtualOptions = virtualizer.getVirtualItems(); const scrollToIndex = (index: number) => { virtualizer.scrollToIndex(index, { align: 'center' }); }; const handleSearch = (search: string) => { setIsKeyboardNavActive(false); setFilteredOptions( options.filter((option) => option.value.toLowerCase().includes(search.toLowerCase()) || option.label.toLowerCase().includes(search.toLowerCase()) ), ); }; const handleSelect = (value: string) => { let newSelectedOptions: string[]; if (multiple) { newSelectedOptions = selectedOptions.includes(value) ? selectedOptions.filter((v) => v !== value) : [...selectedOptions, value]; } else { newSelectedOptions = [value]; } onSelectOption?.(newSelectedOptions); }; const handleKeyDown = (event: React.KeyboardEvent) => { switch (event.key) { case 'ArrowDown': event.preventDefault(); setIsKeyboardNavActive(true); setFocusedIndex((prev) => { const newIndex = prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1); scrollToIndex(newIndex); return newIndex; }); break; case 'ArrowUp': event.preventDefault(); setIsKeyboardNavActive(true); setFocusedIndex((prev) => { const newIndex = prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0); scrollToIndex(newIndex); return newIndex; }); break; case 'Enter': event.preventDefault(); if (filteredOptions[focusedIndex]) { handleSelect(filteredOptions[focusedIndex].value); if (!multiple) { // Close the popover if not in multiple selection mode const popoverTrigger = document.activeElement?.closest('[role="combobox"]'); if (popoverTrigger) { (popoverTrigger as HTMLElement).click(); } } } break; default: break; } }; React.useEffect(() => { setFilteredOptions(options); }, [options]); return ( setIsKeyboardNavActive(false)} onMouseMove={() => setIsKeyboardNavActive(false)} > {noItemsComponent}
{virtualOptions.map((virtualOption) => { const option = filteredOptions[virtualOption.index]; const isSelected = selectedOptions.includes(option.value); return ( !isKeyboardNavActive && setFocusedIndex(virtualOption.index)} onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)} onSelect={() => handleSelect(option.value)} > {multiple && (
)}
{option.label} {option.description && ( {option.description} )}
); })}
); }; interface VirtualizedSelectProps { options: Option[]; placeholder?: string; height?: string; className?: string; isLoading: boolean; disabled?: boolean; onSelectOption?: (options: string[]) => void; value?: string | string[]; defaultValue?: string | string[]; noItemsComponent?: React.ReactNode; multiple?: boolean; size?: 'default' | 'sm' | 'lg' | 'icon'; } export function VirtualizedSelect({ options, onSelectOption, className, defaultValue, value, size = 'default', isLoading, disabled = false, placeholder = 'Search items...', height = '300px', noItemsComponent, multiple = false, }: VirtualizedSelectProps) { const [open, setOpen] = React.useState(false); const [selectedOptions, setSelectedOptions] = React.useState( value !== undefined ? Array.isArray(value) ? value : value ? [value] : [] : defaultValue !== undefined ? Array.isArray(defaultValue) ? defaultValue : defaultValue ? [defaultValue] : [] : [] ); React.useEffect(() => { if (value !== undefined) { setSelectedOptions(Array.isArray(value) ? value : value ? [value] : []); } }, [value]); const getDisplayText = () => { if (isLoading) return 'Loading...'; if (selectedOptions.length === 0) return placeholder; if (!multiple) { const selectedItem = options.find(option => option.value === selectedOptions[0]); return selectedItem?.label || placeholder; } const selectedLabels = selectedOptions .map(value => options.find(option => option.value === value)?.label) .filter(Boolean); if (selectedLabels.length === 0) return placeholder; return selectedLabels.join(", "); }; return (
{!isLoading && ( { setSelectedOptions(newSelectedOptions); onSelectOption?.(newSelectedOptions); if (!multiple) setOpen(false); }} noItemsComponent={noItemsComponent} multiple={multiple} /> )}
); }