mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
317 lines
9.9 KiB
TypeScript
317 lines
9.9 KiB
TypeScript
//
|
|
// 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 <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
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 = <div>No items available</div>,
|
|
multiple,
|
|
}: VirtualizedCommandProps) => {
|
|
const [filteredOptions, setFilteredOptions] = React.useState<Option[]>(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 (
|
|
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
|
|
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
|
<CommandList
|
|
ref={parentRef}
|
|
style={{
|
|
height: height,
|
|
width: '100%',
|
|
overflow: 'auto',
|
|
}}
|
|
onMouseDown={() => setIsKeyboardNavActive(false)}
|
|
onMouseMove={() => setIsKeyboardNavActive(false)}
|
|
>
|
|
<CommandEmpty>{noItemsComponent}</CommandEmpty>
|
|
<CommandGroup>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualOptions.map((virtualOption) => {
|
|
const option = filteredOptions[virtualOption.index];
|
|
const isSelected = selectedOptions.includes(option.value);
|
|
return (
|
|
<CommandItem
|
|
key={option.value}
|
|
value={option.value}
|
|
disabled={isKeyboardNavActive}
|
|
className={cn(
|
|
'absolute left-0 top-0 w-full bg-transparent',
|
|
focusedIndex === virtualOption.index && 'bg-accent text-accent-foreground',
|
|
isKeyboardNavActive &&
|
|
focusedIndex !== virtualOption.index &&
|
|
'aria-selected:bg-transparent aria-selected:text-primary',
|
|
)}
|
|
style={{
|
|
height: `${virtualOption.size}px`,
|
|
transform: `translateY(${virtualOption.start}px)`,
|
|
}}
|
|
onMouseEnter={() => !isKeyboardNavActive && setFocusedIndex(virtualOption.index)}
|
|
onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)}
|
|
onSelect={() => handleSelect(option.value)}
|
|
>
|
|
{multiple && (
|
|
<div
|
|
className={cn(
|
|
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
|
|
isSelected ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible',
|
|
)}
|
|
>
|
|
<CheckIcon className="h-4 w-4" />
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col">
|
|
{option.label}
|
|
{option.description && (
|
|
<span className="text-xs text-gray-500">
|
|
{option.description}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</CommandItem>
|
|
);
|
|
})}
|
|
</div>
|
|
</CommandGroup>
|
|
</CommandList>
|
|
</Command>
|
|
);
|
|
};
|
|
|
|
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<string[]>(
|
|
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 (
|
|
<div className="w-full">
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
size={size}
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className={cn('justify-between', className)}
|
|
disabled={isLoading || disabled}
|
|
>
|
|
<span className='truncate'>{getDisplayText()}</span>
|
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
{!isLoading && (
|
|
<PopoverContent className="p-0" align="start" side="bottom">
|
|
<VirtualizedCommand
|
|
height={height}
|
|
options={options}
|
|
placeholder={placeholder}
|
|
selectedOptions={selectedOptions}
|
|
onSelectOption={(newSelectedOptions) => {
|
|
setSelectedOptions(newSelectedOptions);
|
|
onSelectOption?.(newSelectedOptions);
|
|
if (!multiple) setOpen(false);
|
|
}}
|
|
noItemsComponent={noItemsComponent}
|
|
multiple={multiple}
|
|
/>
|
|
</PopoverContent>
|
|
)}
|
|
</Popover>
|
|
</div>
|
|
);
|
|
} |