Files
ocdp-go/frontend/src/shared/components/form/DropdownSelect.tsx
Ivan087 7f238a3168 refactor: full-stack restructure with multi-tenancy, workspace management, and K8s diagnostics
- Add Workspace domain (entity, repository, service, handler, DTO)
- Add multi-tenant K8s client with tenant binding and quota management
- Add K8s diagnostics client (instance diagnostics)
- Add authorization middleware (authz package)
- Restructure frontend to feature-based architecture (features/)
- Add User Management page in configuration
- Add AccessDenied page and route guards
- Refactor shared components (form inputs, layout, UI)
- Update Tailwind config for new design system
- Add comprehensive documentation (docs/, tasks/, plans)
- Improve cluster service with better kubeconfig handling
- Add tests for crypto, config, helm client, tenant binding
2026-05-12 16:15:14 +08:00

288 lines
8.7 KiB
TypeScript

/**
* 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-white border border-slate-200 rounded-md 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-slate-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-slate-100 last:border-0 ${
option.disabled
? 'text-slate-400 cursor-not-allowed bg-slate-50'
: isHighlighted
? 'bg-blue-600 text-white cursor-pointer'
: value === option.value
? 'bg-blue-50 text-blue-700 cursor-pointer'
: 'text-slate-700 hover:bg-slate-50 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-white border border-slate-300 text-slate-900 rounded-md transition flex items-center justify-between ${
disabled
? 'opacity-50 cursor-not-allowed'
: 'cursor-pointer hover:border-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500'
}`}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<span className={value ? 'text-slate-900' : 'text-slate-400'}>
{displayText || placeholder}
</span>
<div className="flex items-center gap-1">
{value && !required && !disabled && (
<button
onClick={handleClear}
className="p-0.5 hover:bg-slate-100 rounded transition"
type="button"
tabIndex={-1}
>
<X className="w-4 h-4 text-slate-400" />
</button>
)}
<ChevronDown
className={`w-4 h-4 text-slate-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>
);
};