diff --git a/src/components/react/SearchDialog.tsx b/src/components/react/SearchDialog.tsx index 4de62c6..0fd1445 100644 --- a/src/components/react/SearchDialog.tsx +++ b/src/components/react/SearchDialog.tsx @@ -1,6 +1,13 @@ -import { motion } from 'motion/react'; +import { AnimatePresence, motion } from 'motion/react'; import type React from 'react'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; import { createPortal } from 'react-dom'; import ShinyText from '@/components/ShinyText'; import StarBorder from '@/components/StarBorder'; @@ -199,546 +206,591 @@ export interface SearchDialogProps { onClose: () => void; } -export const SearchDialog: React.FC = ({ - isOpen, - onClose, -}) => { - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [selectedIndex, setSelectedIndex] = useState(0); - const [topGradientOpacity, setTopGradientOpacity] = useState(0); - const [bottomGradientOpacity, setBottomGradientOpacity] = useState(0); - const [pagefindReady, setPagefindReady] = useState(false); - - const inputRef = useRef(null); - const listRef = useRef(null); - const dialogRef = useRef(null); - const searchIdRef = useRef(0); - const listboxId = 'search-results-listbox'; - const getOptionId = (index: number) => `search-option-${index}`; - - // Load Pagefind once on mount - useEffect(() => { - const loadPagefind = async () => { - if (window.pagefind) { - setPagefindReady(true); - return; - } +export interface SearchDialogHandle { + focusInput: () => void; +} - try { - const pagefindPath = '/pagefind/pagefind.js'; - const module = await (Function( - `return import("${pagefindPath}")`, - )() as Promise); - window.pagefind = module; - - // Configure options - await module.options({ - excerptLength: 20, - highlightParam: 'highlight', - }); +export const SearchDialog = forwardRef( + ({ isOpen, onClose }, ref) => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(0); + const [topGradientOpacity, setTopGradientOpacity] = useState(0); + const [bottomGradientOpacity, setBottomGradientOpacity] = useState(0); + const [pagefindReady, setPagefindReady] = useState(false); + const [isMounted, setIsMounted] = useState(false); + + const proxyInputRef = useRef(null); + const visibleInputRef = useRef(null); + const listRef = useRef(null); + const dialogRef = useRef(null); + const searchIdRef = useRef(0); + const listboxId = 'search-results-listbox'; + const getOptionId = (index: number) => `search-option-${index}`; + + // Expose focusInput method to parent for iOS keyboard activation + // This focuses the hidden proxy input which is always in DOM + useImperativeHandle(ref, () => ({ + focusInput: () => { + if (proxyInputRef.current) { + proxyInputRef.current.focus(); + proxyInputRef.current.setSelectionRange(0, 0); + } + }, + })); + + // Track client-side mounting for SSR safety + useEffect(() => { + setIsMounted(true); + }, []); + + // Load Pagefind once on mount + useEffect(() => { + const loadPagefind = async () => { + if (window.pagefind) { + setPagefindReady(true); + return; + } - setPagefindReady(true); - } catch (error) { - console.error('Failed to load Pagefind:', error); - } - }; + try { + const pagefindPath = '/pagefind/pagefind.js'; + const module = await (Function( + `return import("${pagefindPath}")`, + )() as Promise); + window.pagefind = module; + + // Configure options + await module.options({ + excerptLength: 20, + highlightParam: 'highlight', + }); + + setPagefindReady(true); + } catch (error) { + console.error('Failed to load Pagefind:', error); + } + }; + + loadPagefind(); + }, []); + + // Flatten results into selectable items + const selectableItems = useMemo((): SelectableItem[] => { + const items: SelectableItem[] = []; + + results.forEach((result) => { + // Get sub_results, filtering out the main page URL if it appears first + const subResults = result.sub_results || []; + const sections = subResults.filter((sub, idx) => { + // Keep if URL has a hash (it's a section link) + if (sub.url.includes('#')) return true; + // Skip first item if it's just the page URL without hash + if (idx === 0 && sub.url === result.url) return false; + return true; + }); - loadPagefind(); - }, []); - - // Flatten results into selectable items - const selectableItems = useMemo((): SelectableItem[] => { - const items: SelectableItem[] = []; - - results.forEach((result) => { - // Get sub_results, filtering out the main page URL if it appears first - const subResults = result.sub_results || []; - const sections = subResults.filter((sub, idx) => { - // Keep if URL has a hash (it's a section link) - if (sub.url.includes('#')) return true; - // Skip first item if it's just the page URL without hash - if (idx === 0 && sub.url === result.url) return false; - return true; - }); + // Add main page result + items.push({ + id: result.id, + url: result.url, + title: result.meta.title || 'Untitled', + excerpt: '', + type: 'page', + isLastInGroup: sections.length === 0, + }); - // Add main page result - items.push({ - id: result.id, - url: result.url, - title: result.meta.title || 'Untitled', - excerpt: '', - type: 'page', - isLastInGroup: sections.length === 0, + // Add section results (limit to 3) + sections.slice(0, 3).forEach((sub, idx) => { + items.push({ + id: `${result.id}-${idx}`, + url: sub.url, + title: sub.title || sub.anchor?.text || 'Section', + excerpt: sub.excerpt || '', + type: 'section', + isLastInGroup: idx === Math.min(sections.length, 3) - 1, + }); + }); }); - // Add section results (limit to 3) - sections.slice(0, 3).forEach((sub, idx) => { - items.push({ - id: `${result.id}-${idx}`, - url: sub.url, - title: sub.title || sub.anchor?.text || 'Section', - excerpt: sub.excerpt || '', - type: 'section', - isLastInGroup: idx === Math.min(sections.length, 3) - 1, + return items; + }, [results]); + + // Focus input and blur page content when dialog opens + useEffect(() => { + const unlockScroll = () => { + const scrollY = document.body.style.top; + document.body.classList.remove('search-dialog-open'); + document.body.style.top = ''; + if (scrollY) { + window.scrollTo(0, Number.parseInt(scrollY, 10) * -1); + } + }; + + if (isOpen) { + // Save scroll position before locking + const scrollY = window.scrollY; + document.body.style.top = `-${scrollY}px`; + document.body.classList.add('search-dialog-open'); + // Transfer focus from proxy input to visible input after render + // The proxy input was already focused synchronously to activate iOS keyboard + requestAnimationFrame(() => { + visibleInputRef.current?.focus(); }); - }); - }); - - return items; - }, [results]); - - // Focus input and blur page content when dialog opens - useEffect(() => { - const unlockScroll = () => { - const scrollY = document.body.style.top; - document.body.classList.remove('search-dialog-open'); - document.body.style.top = ''; - if (scrollY) { - window.scrollTo(0, Number.parseInt(scrollY, 10) * -1); + setQuery(''); + setResults([]); + setSelectedIndex(0); + } else { + unlockScroll(); } - }; - if (isOpen) { - // Save scroll position before locking - const scrollY = window.scrollY; - document.body.style.top = `-${scrollY}px`; - document.body.classList.add('search-dialog-open'); - // Use requestAnimationFrame to focus after render but within user gesture chain for mobile keyboards - requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - setQuery(''); - setResults([]); - setSelectedIndex(0); - } else { - unlockScroll(); - } - - return unlockScroll; - }, [isOpen]); - - // Focus trap for modal accessibility - useEffect(() => { - if (!isOpen || !dialogRef.current) return; - - const dialog = dialogRef.current; - const focusableElements = dialog.querySelectorAll( - 'input, button, [tabindex]:not([tabindex="-1"])', - ); - const firstElement = focusableElements[0]; - const lastElement = focusableElements[focusableElements.length - 1]; + return unlockScroll; + }, [isOpen]); - const handleTabKey = (e: KeyboardEvent) => { - if (e.key !== 'Tab') return; + // Focus trap for modal accessibility + useEffect(() => { + if (!isOpen || !dialogRef.current) return; - if (e.shiftKey) { - if (document.activeElement === firstElement) { - e.preventDefault(); - lastElement?.focus(); - } - } else { - if (document.activeElement === lastElement) { - e.preventDefault(); - firstElement?.focus(); + const dialog = dialogRef.current; + const focusableElements = dialog.querySelectorAll( + 'input, button, [tabindex]:not([tabindex="-1"])', + ); + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + + const handleTabKey = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + + if (e.shiftKey) { + if (document.activeElement === firstElement) { + e.preventDefault(); + lastElement?.focus(); + } + } else { + if (document.activeElement === lastElement) { + e.preventDefault(); + firstElement?.focus(); + } } + }; + + dialog.addEventListener('keydown', handleTabKey); + return () => dialog.removeEventListener('keydown', handleTabKey); + }, [isOpen]); + + // Search effect with manual debouncing + useEffect(() => { + // Increment search ID to invalidate any pending searches + const currentSearchId = ++searchIdRef.current; + + // Clear results immediately when query is empty + if (!query.trim()) { + setResults([]); + setIsLoading(false); + setSelectedIndex(0); + return; } - }; - dialog.addEventListener('keydown', handleTabKey); - return () => dialog.removeEventListener('keydown', handleTabKey); - }, [isOpen]); - - // Search effect with manual debouncing - useEffect(() => { - // Increment search ID to invalidate any pending searches - const currentSearchId = ++searchIdRef.current; - - // Clear results immediately when query is empty - if (!query.trim()) { - setResults([]); - setIsLoading(false); - setSelectedIndex(0); - return; - } - - if (!window.pagefind) { - return; - } - - setIsLoading(true); - - // Preload indexes for faster search - if (query.length >= 2) { - window.pagefind.preload(query); - } - - // Debounce the actual search - const timeoutId = setTimeout(async () => { - // Check if this search is still valid - if (searchIdRef.current !== currentSearchId) { + if (!window.pagefind) { return; } - try { - const search = await window.pagefind?.search(query); + setIsLoading(true); - // Check again after async operation + // Preload indexes for faster search + if (query.length >= 2) { + window.pagefind.preload(query); + } + + // Debounce the actual search + const timeoutId = setTimeout(async () => { + // Check if this search is still valid if (searchIdRef.current !== currentSearchId) { return; } - if (!search || search.results.length === 0) { - setResults([]); - setSelectedIndex(0); - setIsLoading(false); - return; - } + try { + const search = await window.pagefind?.search(query); - // Load first 10 page results - const loadedResults = await Promise.all( - search.results.slice(0, 10).map((r) => r.data()), - ); + // Check again after async operation + if (searchIdRef.current !== currentSearchId) { + return; + } - // Final check before setting state - if (searchIdRef.current !== currentSearchId) { - return; - } + if (!search || search.results.length === 0) { + setResults([]); + setSelectedIndex(0); + setIsLoading(false); + return; + } - setResults(loadedResults); - setSelectedIndex(0); - setIsLoading(false); - } catch (error) { - console.error('Search error:', error); - if (searchIdRef.current === currentSearchId) { - setResults([]); - setIsLoading(false); - } - } - }, 150); + // Load first 10 page results + const loadedResults = await Promise.all( + search.results.slice(0, 10).map((r) => r.data()), + ); - // Cleanup: clear timeout if query changes before it fires - return () => { - clearTimeout(timeoutId); - }; - }, [query]); - - // Handle scroll for gradients - const handleScroll = (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = - e.target as HTMLDivElement; - setTopGradientOpacity(Math.min(scrollTop / 50, 1)); - const bottomDistance = scrollHeight - (scrollTop + clientHeight); - setBottomGradientOpacity( - scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1), - ); - }; + // Final check before setting state + if (searchIdRef.current !== currentSearchId) { + return; + } - // Check initial scroll state - useEffect(() => { - if (!listRef.current || selectableItems.length === 0) { - setBottomGradientOpacity(0); - return; - } - const { scrollHeight, clientHeight } = listRef.current; - setBottomGradientOpacity(scrollHeight <= clientHeight ? 0 : 1); - }, [selectableItems]); - - // Keyboard navigation - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - switch (e.key) { - case 'ArrowDown': - e.preventDefault(); - setSelectedIndex((prev) => - Math.min(prev + 1, selectableItems.length - 1), - ); - break; - case 'ArrowUp': - e.preventDefault(); - setSelectedIndex((prev) => Math.max(prev - 1, 0)); - break; - case 'Enter': - e.preventDefault(); - if (selectableItems[selectedIndex]) { - window.location.href = selectableItems[selectedIndex].url; - onClose(); + setResults(loadedResults); + setSelectedIndex(0); + setIsLoading(false); + } catch (error) { + console.error('Search error:', error); + if (searchIdRef.current === currentSearchId) { + setResults([]); + setIsLoading(false); } - break; - case 'Escape': - e.preventDefault(); - onClose(); - break; - } + } + }, 150); + + // Cleanup: clear timeout if query changes before it fires + return () => { + clearTimeout(timeoutId); + }; + }, [query]); + + // Handle scroll for gradients + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = + e.target as HTMLDivElement; + setTopGradientOpacity(Math.min(scrollTop / 50, 1)); + const bottomDistance = scrollHeight - (scrollTop + clientHeight); + setBottomGradientOpacity( + scrollHeight <= clientHeight ? 0 : Math.min(bottomDistance / 50, 1), + ); }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isOpen, selectableItems, selectedIndex, onClose]); - - // Scroll selected item into view - useEffect(() => { - if (!listRef.current || selectedIndex < 0) return; + // Check initial scroll state + useEffect(() => { + if (!listRef.current || selectableItems.length === 0) { + setBottomGradientOpacity(0); + return; + } + const { scrollHeight, clientHeight } = listRef.current; + setBottomGradientOpacity(scrollHeight <= clientHeight ? 0 : 1); + }, [selectableItems]); + + // Keyboard navigation + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((prev) => + Math.min(prev + 1, selectableItems.length - 1), + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + break; + case 'Enter': + e.preventDefault(); + if (selectableItems[selectedIndex]) { + window.location.href = selectableItems[selectedIndex].url; + onClose(); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }; - const selectedItem = listRef.current.querySelector( - `[data-index="${selectedIndex}"]`, - ) as HTMLElement | null; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, selectableItems, selectedIndex, onClose]); - if (selectedItem) { - selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - } - }, [selectedIndex]); + // Scroll selected item into view + useEffect(() => { + if (!listRef.current || selectedIndex < 0) return; - // Click outside to close - useEffect(() => { - if (!isOpen) return; + const selectedItem = listRef.current.querySelector( + `[data-index="${selectedIndex}"]`, + ) as HTMLElement | null; - const handleClick = (e: MouseEvent) => { - if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) { - onClose(); + if (selectedItem) { + selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } - }; - - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [isOpen, onClose]); + }, [selectedIndex]); - if (!isOpen) return null; + // Click outside to close + useEffect(() => { + if (!isOpen) return; - // Generate status message for screen readers - const getStatusMessage = () => { - if (isLoading) return 'Searching...'; - if (!query) return ''; - if (selectableItems.length === 0) return `No results found for "${query}"`; - return `${results.length} result${results.length !== 1 ? 's' : ''} found for "${query}"`; - }; + const handleClick = (e: MouseEvent) => { + if ( + dialogRef.current && + !dialogRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isOpen, onClose]); + + // Generate status message for screen readers + const getStatusMessage = () => { + if (isLoading) return 'Searching...'; + if (!query) return ''; + if (selectableItems.length === 0) + return `No results found for "${query}"`; + return `${results.length} result${results.length !== 1 ? 's' : ''} found for "${query}"`; + }; - return createPortal( -
- {/* Backdrop */} -