ocdp v1
This commit is contained in:
288
frontend/src/shared/components/form/DropdownSelect.tsx
Normal file
288
frontend/src/shared/components/form/DropdownSelect.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Dropdown Select Component
|
||||
* Custom dropdown without search functionality
|
||||
* Uses Portal + Fixed positioning to avoid Modal overflow issues
|
||||
*/
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ChevronDown, X } from 'lucide-react';
|
||||
|
||||
export interface DropdownSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface DropdownSelectProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: DropdownSelectOption[];
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
zIndex?: number; // Custom z-index for the dropdown
|
||||
}
|
||||
|
||||
export const DropdownSelect: React.FC<DropdownSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = 'Select...',
|
||||
required = false,
|
||||
disabled = false,
|
||||
className = '',
|
||||
zIndex = 110, // Default z-index for dropdown
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Get enabled options
|
||||
const enabledOptions = options.filter(opt => !opt.disabled);
|
||||
|
||||
// Close dropdown on scroll (prevents it from covering modal header)
|
||||
useEffect(() => {
|
||||
if (isOpen && containerRef.current) {
|
||||
const updatePosition = () => {
|
||||
const rect = containerRef.current!.getBoundingClientRect();
|
||||
|
||||
// Always position below the trigger
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
// Close dropdown on scroll (prevents it from covering modal header)
|
||||
const handleScroll = (e: Event) => {
|
||||
// Only close if scroll is not from the dropdown itself
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update position on resize, close on scroll
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node) &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (disabled) return;
|
||||
|
||||
if (!isOpen) {
|
||||
if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === ' ') {
|
||||
setIsOpen(true);
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < enabledOptions.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
if (enabledOptions[highlightedIndex]) {
|
||||
handleSelect(enabledOptions[highlightedIndex].value);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll highlighted item into view
|
||||
useEffect(() => {
|
||||
if (isOpen && listRef.current) {
|
||||
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex, isOpen]);
|
||||
|
||||
const handleSelect = (optionValue: string) => {
|
||||
onChange(optionValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onChange('');
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!disabled) {
|
||||
setIsOpen(!isOpen);
|
||||
}
|
||||
};
|
||||
|
||||
// Get display label
|
||||
const selectedOption = options.find(opt => opt.value === value);
|
||||
const displayText = selectedOption?.label || '';
|
||||
|
||||
// Render dropdown in portal
|
||||
const renderDropdown = () => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="fixed bg-gray-800 border border-gray-700 rounded-lg shadow-2xl flex flex-col"
|
||||
style={{
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
width: `${dropdownPosition.width}px`,
|
||||
maxHeight: 'min(20rem, 60vh)',
|
||||
zIndex: zIndex,
|
||||
}}
|
||||
role="dialog"
|
||||
>
|
||||
{/* Options List */}
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="overflow-y-auto overflow-x-hidden overscroll-contain scroll-smooth"
|
||||
style={{
|
||||
maxHeight: '320px',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<li className="px-3 py-2 text-gray-500 text-sm text-center">
|
||||
No options available
|
||||
</li>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const enabledIndex = enabledOptions.findIndex(opt => opt.value === option.value);
|
||||
const isHighlighted = enabledIndex === highlightedIndex && !option.disabled;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={option.value}
|
||||
onClick={() => !option.disabled && handleSelect(option.value)}
|
||||
className={`px-4 py-3 text-sm transition border-b border-gray-700/30 last:border-0 ${
|
||||
option.disabled
|
||||
? 'text-gray-600 cursor-not-allowed bg-gray-900/50'
|
||||
: isHighlighted
|
||||
? 'bg-blue-600 text-white cursor-pointer'
|
||||
: value === option.value
|
||||
? 'bg-gray-700 text-white cursor-pointer'
|
||||
: 'text-gray-300 hover:bg-gray-700 cursor-pointer'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={value === option.value}
|
||||
aria-disabled={option.disabled}
|
||||
>
|
||||
<div className="truncate" title={option.label}>
|
||||
{option.label}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
{/* Trigger Button */}
|
||||
<div
|
||||
onClick={handleToggle}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={`w-full px-3 py-2 bg-gray-800 border border-gray-700 text-white rounded-lg transition flex items-center justify-between ${
|
||||
disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'cursor-pointer hover:border-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500'
|
||||
}`}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<span className={value ? 'text-white' : 'text-gray-500'}>
|
||||
{displayText || placeholder}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{value && !required && !disabled && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-0.5 hover:bg-gray-700 rounded transition"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${
|
||||
isOpen ? 'transform rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Required indicator (hidden input for form validation) */}
|
||||
{required && (
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={() => {}}
|
||||
required
|
||||
className="absolute opacity-0 pointer-events-none"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dropdown (rendered via portal) */}
|
||||
{renderDropdown()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user