platform-codebase/@packages/@providers/attribute-ui/src/components/VirtualizedCheckboxList.tsx
2026-01-18 09:20:17 -08:00

244 lines
7.8 KiB
TypeScript
Executable file

import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef, useState, useMemo, useCallback } from 'react';
/**
* Option shape for checkbox list items
*/
export interface CheckboxOption {
label: string;
value: string;
}
/**
* Props for VirtualizedCheckboxList component
*/
export interface VirtualizedCheckboxListProps {
/** Array of checkbox options to render */
options: CheckboxOption[];
/** Currently selected values */
selectedValues: string[];
/** Callback when selection changes */
onSelectionChange: (selectedValues: string[]) => void;
/** Height of each item in pixels (default: 40) */
itemHeight?: number;
/** Total height of the scrollable container in pixels (default: 400) */
containerHeight?: number;
/** Placeholder text for search input (default: "Search...") */
searchPlaceholder?: string;
/** Label for "Select All" button (default: "Select All") */
selectAllLabel?: string;
/** Label for "Clear All" button (default: "Clear All") */
clearAllLabel?: string;
/** Class name for the container element */
className?: string;
/** Whether to show the selected count (default: true) */
showSelectedCount?: boolean;
}
/**
* VirtualizedCheckboxList - Windowed checkbox list for large datasets (200+ items)
*
* Features:
* - Virtualized rendering using @tanstack/react-virtual for optimal performance
* - Built-in search/filter functionality
* - Select All / Clear All actions
* - Selected count display
* - Accessible keyboard navigation
* - Tailwind CSS styling support
*
* @example
* ```tsx
* const [selectedValues, setSelectedValues] = useState<string[]>([]);
*
* <VirtualizedCheckboxList
* options={enumOptions}
* selectedValues={selectedValues}
* onSelectionChange={setSelectedValues}
* containerHeight={500}
* />
* ```
*/
export function VirtualizedCheckboxList({
options,
selectedValues,
onSelectionChange,
itemHeight = 40,
containerHeight = 400,
searchPlaceholder = 'Search...',
selectAllLabel = 'Select All',
clearAllLabel = 'Clear All',
className = '',
showSelectedCount = true,
}: VirtualizedCheckboxListProps) {
const [searchTerm, setSearchTerm] = useState('');
const parentRef = useRef<HTMLDivElement>(null);
// Filter options based on search term
const filteredOptions = useMemo(() => {
if (!searchTerm.trim()) {
return options;
}
const lowerSearch = searchTerm.toLowerCase();
return options.filter((option) =>
option.label.toLowerCase().includes(lowerSearch)
);
}, [options, searchTerm]);
// Set up virtualizer for windowed rendering
const virtualizer = useVirtualizer({
count: filteredOptions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => itemHeight,
overscan: 5, // Render 5 extra items above/below viewport
});
// Toggle individual checkbox
const handleToggle = useCallback(
(value: string) => {
const isSelected = selectedValues.includes(value);
if (isSelected) {
onSelectionChange(selectedValues.filter((v) => v !== value));
} else {
onSelectionChange([...selectedValues, value]);
}
},
[selectedValues, onSelectionChange]
);
// Select all filtered options
const handleSelectAll = useCallback(() => {
const allFilteredValues = filteredOptions.map((opt) => opt.value);
const uniqueValues = Array.from(
new Set([...selectedValues, ...allFilteredValues])
);
onSelectionChange(uniqueValues);
}, [filteredOptions, selectedValues, onSelectionChange]);
// Clear all selections
const handleClearAll = useCallback(() => {
onSelectionChange([]);
}, [onSelectionChange]);
// Clear search
const handleClearSearch = useCallback(() => {
setSearchTerm('');
}, []);
const selectedCount = selectedValues.length;
const totalCount = options.length;
const isAllSelected = filteredOptions.length > 0 && filteredOptions.every((opt) =>
selectedValues.includes(opt.value)
);
return (
<div className={`flex flex-col gap-3 ${className}`}>
{/* Header: Search + Actions */}
<div className="flex flex-col gap-2">
{/* Search input */}
<div className="relative">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={searchPlaceholder}
className="w-full px-3 py-2 pr-8 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
aria-label="Filter options"
/>
{searchTerm && (
<button
onClick={handleClearSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
aria-label="Clear search"
type="button"
>
</button>
)}
</div>
{/* Action buttons and count */}
<div className="flex items-center justify-between gap-2">
<div className="flex gap-2">
<button
onClick={handleSelectAll}
disabled={isAllSelected}
className="px-3 py-1 text-sm font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
>
{selectAllLabel}
</button>
<button
onClick={handleClearAll}
disabled={selectedCount === 0}
className="px-3 py-1 text-sm font-medium text-gray-600 hover:text-gray-700 hover:bg-gray-50 rounded disabled:opacity-50 disabled:cursor-not-allowed"
type="button"
>
{clearAllLabel}
</button>
</div>
{showSelectedCount && (
<span className="text-sm text-gray-600" aria-live="polite">
{selectedCount} / {totalCount} selected
</span>
)}
</div>
</div>
{/* Virtualized checkbox list */}
<div
ref={parentRef}
className="border border-gray-200 rounded-md overflow-auto"
style={{ height: `${containerHeight}px` }}
role="group"
aria-label="Checkbox options"
>
{filteredOptions.length === 0 ? (
<div className="flex items-center justify-center h-full text-gray-500">
No options found
</div>
) : (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const option = filteredOptions[virtualItem.index];
const isChecked = selectedValues.includes(option.value);
return (
<label
key={option.value}
className="flex items-center gap-3 px-4 py-2 hover:bg-gray-50 cursor-pointer"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggle(option.value)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500"
aria-label={option.label}
/>
<span className="text-sm text-gray-900 select-none">
{option.label}
</span>
</label>
);
})}
</div>
)}
</div>
</div>
);
}