From 95adee97102e1adcd0b5856a04021cedbabb9462 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 24 Jun 2025 14:06:09 +0300 Subject: [PATCH] feat(TypeaheadInput): implement portal-based dropdown for suggestions with dynamic positioning --- src/components/TypeaheadInput.tsx | 218 ++++++++++++++++++++++++------ 1 file changed, 173 insertions(+), 45 deletions(-) diff --git a/src/components/TypeaheadInput.tsx b/src/components/TypeaheadInput.tsx index 9de146f..1481332 100644 --- a/src/components/TypeaheadInput.tsx +++ b/src/components/TypeaheadInput.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { createPortal } from 'react-dom'; import { WikidataSuggestion } from '@/types/database'; interface TypeaheadInputProps { @@ -12,6 +13,12 @@ interface TypeaheadInputProps { className?: string; } +interface DropdownPosition { + top: number; + left: number; + width: number; +} + export function TypeaheadInput({ value, onInput, @@ -28,11 +35,44 @@ export function TypeaheadInput({ const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); const [isExternalUpdate, setIsExternalUpdate] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 }); const inputRef = useRef(null); const suggestionsRef = useRef(null); const timeoutRef = useRef(undefined); + // Calculate dropdown position relative to viewport + const calculateDropdownPosition = useCallback((): DropdownPosition => { + if (!inputRef.current) { + return { top: 0, left: 0, width: 0 }; + } + + const inputRect = inputRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const dropdownHeight = 384; // max-h-96 = 24rem = 384px + + // Calculate if dropdown should appear above or below input + const spaceBelow = viewportHeight - inputRect.bottom; + const spaceAbove = inputRect.top; + const shouldShowAbove = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; + + return { + top: shouldShowAbove + ? inputRect.top - Math.min(dropdownHeight, spaceAbove) + window.scrollY + : inputRect.bottom + window.scrollY, + left: inputRect.left + window.scrollX, + width: inputRect.width + }; + }, []); + + // Update dropdown position when showing suggestions + const updateDropdownPosition = useCallback(() => { + if (showSuggestions) { + const position = calculateDropdownPosition(); + setDropdownPosition(position); + } + }, [showSuggestions, calculateDropdownPosition]); + // Update local value when prop changes (including external updates) useEffect(() => { if (value !== localValue) { @@ -55,6 +95,22 @@ export function TypeaheadInput({ } }, [isExternalUpdate]); + // Update dropdown position on scroll and resize + useEffect(() => { + if (showSuggestions) { + const handleScroll = () => updateDropdownPosition(); + const handleResize = () => updateDropdownPosition(); + + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleResize); + }; + } + }, [showSuggestions, updateDropdownPosition]); + // Debounced suggestion fetching const debouncedFetchSuggestions = useCallback(async (query: string) => { if (timeoutRef.current) { @@ -74,7 +130,12 @@ export function TypeaheadInput({ console.log('Fetching suggestions for:', query); const results = await fetchSuggestions(query); setSuggestions(results); - setShowSuggestions(results.length > 0); + if (results.length > 0) { + setShowSuggestions(true); + // Calculate position when showing suggestions + const position = calculateDropdownPosition(); + setDropdownPosition(position); + } setSelectedIndex(-1); } catch (error) { console.error('Error fetching suggestions:', error); @@ -84,7 +145,7 @@ export function TypeaheadInput({ setIsLoading(false); } }, 300); // 300ms debounce - }, [fetchSuggestions]); + }, [fetchSuggestions, calculateDropdownPosition]); // Handle input changes const handleInput = useCallback((e: React.ChangeEvent) => { @@ -181,8 +242,10 @@ export function TypeaheadInput({ // Show suggestions if we have them and there's a value if (suggestions.length > 0 && localValue.trim().length >= 2) { setShowSuggestions(true); + const position = calculateDropdownPosition(); + setDropdownPosition(position); } - }, [onFocus, suggestions.length, localValue]); + }, [onFocus, suggestions.length, localValue, calculateDropdownPosition]); // Handle blur const handleBlur = useCallback((e: React.FocusEvent) => { @@ -234,11 +297,112 @@ export function TypeaheadInput({ }, []); const baseInputClassName = ` - w-full px-2 py-1 border border-gray-300 rounded - focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + w-full px-3 py-2 border border-gray-300 rounded-lg + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 + transition-all duration-200 ease-in-out ${className} `.trim(); + // Portal-based dropdown component + const DropdownPortal = () => { + if (!showSuggestions || suggestions.length === 0) return null; + + return createPortal( +
+
+ {suggestions.map((suggestion, index) => { + const isSelected = index === selectedIndex; + const label = suggestion.label || ''; + const description = suggestion.description || ''; + + return ( +
handleSuggestionClick(suggestion)} + onMouseEnter={() => setSelectedIndex(index)} + style={{ + backgroundColor: isSelected ? 'rgba(239, 246, 255, 1)' : 'rgba(255, 255, 255, 1)' + }} + > +
+
+ {/* Main label */} +
+ {label} +
+ + {/* Description */} + {description && ( +
+ {description} +
+ )} + + {/* Wikidata ID */} +
+ + + + + {suggestion.id} + +
+
+ + {/* Selection indicator */} +
+
+ + + +
+
+
+ + {/* Subtle bottom border for separation */} + {index < suggestions.length - 1 && ( +
+ )} +
+ ); + })} +
+
, + document.body + ); + }; + return (
-
+
+
)} - {/* Suggestions dropdown */} - {showSuggestions && suggestions.length > 0 && ( -
- {suggestions.map((suggestion, index) => { - const isSelected = index === selectedIndex; - const label = suggestion.label || ''; - const description = suggestion.description || ''; - - return ( -
handleSuggestionClick(suggestion)} - onMouseEnter={() => setSelectedIndex(index)} - > -
{label}
- {description && ( -
- {description} -
- )} -
- ID: {suggestion.id} -
-
- ); - })} -
- )} + {/* Portal-based suggestions dropdown */} +
); } \ No newline at end of file