"use client"; import { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; interface EdgeSearchModalProps { isOpen: boolean; onClose: () => void; onEdgeCreate: (targetNodeId: number, targetNodeTitle: string) => void; sourceNodeId: number; } interface NodeSuggestion { id: number; title: string; dimensions?: string[]; } export default function EdgeSearchModal({ isOpen, onClose, onEdgeCreate, sourceNodeId }: EdgeSearchModalProps) { const [searchQuery, setSearchQuery] = useState(''); const [suggestions, setSuggestions] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); const modalRef = useRef(null); const returnFocusRef = useRef(null); // Store the element that triggered the modal for return focus useEffect(() => { if (isOpen && document.activeElement instanceof HTMLElement) { returnFocusRef.current = document.activeElement; } }, [isOpen]); // Focus trap and accessibility useEffect(() => { if (!isOpen) return; // Autofocus input inputRef.current?.focus(); // Lock body scroll document.body.style.overflow = 'hidden'; // Handle Escape key const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; // Focus trap: keep focus within modal const handleTab = (e: KeyboardEvent) => { if (e.key !== 'Tab') return; const focusableElements = modalRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (!focusableElements || focusableElements.length === 0) return; const firstElement = focusableElements[0] as HTMLElement; const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }; document.addEventListener('keydown', handleEscape); document.addEventListener('keydown', handleTab); return () => { document.body.style.overflow = ''; document.removeEventListener('keydown', handleEscape); document.removeEventListener('keydown', handleTab); // Return focus to trigger element if (returnFocusRef.current) { returnFocusRef.current.focus(); } }; }, [isOpen, onClose]); // Generate suggestions based on search query useEffect(() => { if (!searchQuery.trim()) { setSuggestions([]); return; } const fetchSuggestions = async () => { try { const response = await fetch(`/api/nodes/search?q=${encodeURIComponent(searchQuery)}&limit=10`); const result = await response.json(); if (result.success) { const nodeSuggestions: NodeSuggestion[] = result.data .filter((node: any) => node.id !== sourceNodeId) // Exclude source node .map((node: any) => ({ id: node.id, title: node.title, dimensions: node.dimensions || [] })); setSuggestions(nodeSuggestions); setSelectedIndex(0); } } catch (error) { console.error('Error fetching suggestions:', error); setSuggestions([]); } }; const timeoutId = setTimeout(fetchSuggestions, 200); return () => clearTimeout(timeoutId); }, [searchQuery, sourceNodeId]); // Handle keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(prev => Math.max(prev - 1, 0)); } else if (e.key === 'Enter' && suggestions[selectedIndex]) { e.preventDefault(); handleSelectSuggestion(suggestions[selectedIndex]); } }; const handleSelectSuggestion = (suggestion: NodeSuggestion) => { onEdgeCreate(suggestion.id, suggestion.title); setSearchQuery(''); setSuggestions([]); onClose(); }; const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }; if (!isOpen) return null; const modalContent = (
{/* Search Input */}
0 ? '1px solid #2a2a2a' : 'none' }}>
{/* Search Icon */} {/* Input */} setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder="Search node to connect..." style={{ flex: 1, background: 'none', border: 'none', outline: 'none', color: '#fff', fontSize: '14px', fontFamily: 'inherit' }} /> {/* Close Button */}
{/* Suggestions */} {suggestions.length > 0 && (
{suggestions.map((suggestion, index) => { const primaryDimension = suggestion.dimensions && suggestion.dimensions.length > 0 ? suggestion.dimensions[0] : ''; return ( ); })}
)} {/* Keyboard Hint */}
↑↓ Navigate ↵ Select Esc Close
); return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null; }