- 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
306 lines
9.5 KiB
TypeScript
306 lines
9.5 KiB
TypeScript
/**
|
|
* Searchable Select Component
|
|
* Combo box with search/filter functionality for large option lists
|
|
*/
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { ChevronDown, Search, X } from 'lucide-react';
|
|
|
|
interface SearchableSelectProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
options: string[];
|
|
placeholder?: string;
|
|
required?: boolean;
|
|
className?: string;
|
|
zIndex?: number; // Custom z-index for the dropdown
|
|
}
|
|
|
|
export const SearchableSelect: React.FC<SearchableSelectProps> = ({
|
|
value,
|
|
onChange,
|
|
options,
|
|
placeholder = 'Select...',
|
|
required = false,
|
|
className = '',
|
|
zIndex = 110, // Default z-index for dropdown
|
|
}) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const listRef = useRef<HTMLUListElement>(null);
|
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Filter options based on search term
|
|
const filteredOptions = options.filter((option) =>
|
|
option.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
// 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);
|
|
setSearchTerm('');
|
|
}
|
|
};
|
|
|
|
if (isOpen) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}
|
|
}, [isOpen]);
|
|
|
|
// Handle keyboard navigation
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (!isOpen) {
|
|
if (e.key === 'Enter' || e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
setIsOpen(true);
|
|
e.preventDefault();
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case 'ArrowDown':
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) =>
|
|
prev < filteredOptions.length - 1 ? prev + 1 : prev
|
|
);
|
|
break;
|
|
case 'ArrowUp':
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : 0));
|
|
break;
|
|
case 'Enter':
|
|
e.preventDefault();
|
|
if (filteredOptions[highlightedIndex]) {
|
|
handleSelect(filteredOptions[highlightedIndex]);
|
|
}
|
|
break;
|
|
case 'Escape':
|
|
setIsOpen(false);
|
|
setSearchTerm('');
|
|
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]);
|
|
|
|
// Reset highlighted index when filtered options change
|
|
useEffect(() => {
|
|
setHighlightedIndex(0);
|
|
}, [searchTerm]);
|
|
|
|
const handleSelect = (option: string) => {
|
|
onChange(option);
|
|
setIsOpen(false);
|
|
setSearchTerm('');
|
|
};
|
|
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onChange('');
|
|
setSearchTerm('');
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
const handleToggle = () => {
|
|
setIsOpen(!isOpen);
|
|
if (!isOpen) {
|
|
// Focus input when opening
|
|
setTimeout(() => inputRef.current?.focus(), 0);
|
|
}
|
|
};
|
|
|
|
// Get display text
|
|
const displayText = value || '';
|
|
|
|
// Render dropdown in portal (to avoid being clipped by Modal overflow)
|
|
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(32rem, 80vh)',
|
|
zIndex: zIndex,
|
|
}}
|
|
role="dialog"
|
|
>
|
|
{/* Search Input */}
|
|
<div className="p-2 border-b border-slate-200 bg-white flex-shrink-0">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Type to search..."
|
|
className="w-full pl-9 pr-3 py-2 bg-slate-50 border border-slate-300 text-slate-900 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Options List */}
|
|
<ul
|
|
ref={listRef}
|
|
className="overflow-y-auto overflow-x-hidden overscroll-contain scroll-smooth flex-1"
|
|
style={{
|
|
maxHeight: '400px',
|
|
minHeight: '280px', // Display at least 5-6 options
|
|
WebkitOverflowScrolling: 'touch', // Enable momentum scrolling on iOS
|
|
}}
|
|
role="listbox"
|
|
>
|
|
{filteredOptions.length === 0 ? (
|
|
<li className="px-3 py-2 text-slate-500 text-sm text-center">
|
|
No results found
|
|
</li>
|
|
) : (
|
|
filteredOptions.map((option, index) => (
|
|
<li
|
|
key={option}
|
|
onClick={() => handleSelect(option)}
|
|
className={`px-4 py-3 text-sm cursor-pointer transition border-b border-slate-100 last:border-0 ${
|
|
index === highlightedIndex
|
|
? 'bg-blue-600 text-white'
|
|
: value === option
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'text-slate-700 hover:bg-slate-50'
|
|
}`}
|
|
role="option"
|
|
aria-selected={value === option}
|
|
>
|
|
<div className="truncate" title={option}>
|
|
{option}
|
|
</div>
|
|
</li>
|
|
))
|
|
)}
|
|
</ul>
|
|
|
|
{/* Result Count - Always show for large lists */}
|
|
{(searchTerm || options.length > 20) && (
|
|
<div className="px-3 py-2 bg-slate-50 border-t border-slate-200 text-xs text-slate-500 flex items-center justify-between flex-shrink-0">
|
|
<span>
|
|
{searchTerm
|
|
? `${filteredOptions.length} of ${options.length} options`
|
|
: `${options.length} total options`
|
|
}
|
|
</span>
|
|
{!searchTerm && options.length > 20 && (
|
|
<span className="text-blue-600">Type to search</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div ref={containerRef} className={`relative ${className}`}>
|
|
{/* Trigger Button/Input */}
|
|
<div
|
|
onClick={handleToggle}
|
|
className="w-full px-3 py-2 bg-white border border-slate-300 text-slate-900 rounded-md cursor-pointer hover:border-slate-400 transition flex items-center justify-between"
|
|
>
|
|
<span className={value ? 'text-slate-900' : 'text-slate-400'}>
|
|
{displayText || placeholder}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
{value && (
|
|
<button
|
|
onClick={handleClear}
|
|
className="p-0.5 hover:bg-slate-100 rounded transition"
|
|
type="button"
|
|
>
|
|
<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 to avoid Modal overflow clipping) */}
|
|
{renderDropdown()}
|
|
</div>
|
|
);
|
|
};
|