- Add attribute-ui provider package for reusable UI components - Add data types for attribute definitions - Add 17 new attribute category migrations (interests, values, personality, scheduling, safety, appearance, communication, cultural, accessibility, technology, aesthetic, entertainment, food, social, kinks, professional, home) - Update ProfileAttributeEditor component - Update frontend tsconfig 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
244 lines
7.8 KiB
TypeScript
244 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|