From bacb7f2e45e648e93246800ea71060ddc338ae2f Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Tue, 6 Jan 2026 13:03:19 +0200 Subject: [PATCH 1/2] Improve mobile search dialog keyboard activation Refactors SearchDialog to use a hidden proxy input and forwardRef to reliably trigger the iOS keyboard when opening the search dialog. Updates SearchDialogWrapper to synchronously focus the proxy input before opening, ensuring mobile keyboard appears. Adjusts CSS to use 'touch-action: manipulation' for better input handling on mobile devices. --- src/components/react/SearchDialog.tsx | 1044 +++++++++--------- src/components/react/SearchDialogWrapper.tsx | 21 +- src/styles/custom.css | 3 +- 3 files changed, 567 insertions(+), 501 deletions(-) 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 */} -