"use client"; import { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import Chip from '../common/Chip'; import { getNodeIcon } from '@/utils/nodeIcons'; import { useDimensionIcons } from '@/context/DimensionIconsContext'; interface SearchModalProps { isOpen: boolean; onClose: () => void; onNodeSelect: (nodeId: number) => void; existingFilters: {type: 'dimension' | 'title', value: string}[]; } interface NodeSuggestion { id: number; title: string; dimensions?: string[]; link?: string; } export default function SearchModal({ isOpen, onClose, onNodeSelect, existingFilters }: SearchModalProps) { const { dimensionIcons } = useDimensionIcons(); 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=20`); const result = await response.json(); if (result.success) { const nodeSuggestions: NodeSuggestion[] = result.data.map((node: any) => ({ id: node.id, title: node.title, dimensions: node.dimensions || [], link: node.link || undefined, })); setSuggestions(nodeSuggestions); setSelectedIndex(0); } } catch (error) { console.error('Error fetching suggestions:', error); setSuggestions([]); } }; const timeoutId = setTimeout(fetchSuggestions, 200); return () => clearTimeout(timeoutId); }, [searchQuery, existingFilters]); // 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) => { onNodeSelect(suggestion.id); setSearchQuery(''); setSuggestions([]); onClose(); }; const handleBackdropClick = (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }; if (!isOpen) return null; const modalContent = (
{/* Search Input */}
{/* Selected Filters */} {existingFilters.map((filter, index) => ( ))} setSearchQuery(e.target.value)} onKeyDown={handleKeyDown} placeholder={existingFilters.length === 0 ? "Search nodes..." : ""} className="search-input" />
esc
{/* Results */} {suggestions.length > 0 && (
{suggestions.map((suggestion, index) => ( ))}
)} {/* Empty state */} {searchQuery && suggestions.length === 0 && (
No results for "{searchQuery}"
)}
); return typeof window !== 'undefined' ? createPortal(modalContent, document.body) : null; }