import React, { useState, useEffect, useCallback, useRef } from 'react'; interface PropertySuggestion { id: string; label: string; availableInItems: string[]; // Wikidata IDs that have this property } interface PropertyInputProps { propertyCache: Record>; // wikidataId -> properties propertyLabels: Record; // propertyId -> label onAddProperty: (propertyId: string) => void; onFetchPropertyLabels: (propertyIds: string[]) => Promise>; className?: string; } export function PropertyInput({ propertyCache, propertyLabels, onAddProperty, onFetchPropertyLabels, className = '' }: PropertyInputProps) { const [inputValue, setInputValue] = useState(''); const [suggestions, setSuggestions] = useState([]); const [showSuggestions, setShowSuggestions] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); const inputRef = useRef(null); const suggestionsRef = useRef(null); // Aggregate all available properties from all Wikidata items const aggregateAvailableProperties = useCallback((): PropertySuggestion[] => { const propertyMap = new Map>(); // Collect all properties from all cached Wikidata items Object.entries(propertyCache).forEach(([wikidataId, properties]) => { Object.keys(properties).forEach(propertyId => { if (!propertyMap.has(propertyId)) { propertyMap.set(propertyId, new Set()); } propertyMap.get(propertyId)!.add(wikidataId); }); }); // Convert to PropertySuggestion array const suggestions: PropertySuggestion[] = []; propertyMap.forEach((wikidataIds, propertyId) => { const label = propertyLabels[propertyId] || propertyId; suggestions.push({ id: propertyId, label: label, availableInItems: Array.from(wikidataIds) }); }); // Sort by label for better UX return suggestions.sort((a, b) => a.label.localeCompare(b.label)); }, [propertyCache, propertyLabels]); // Update suggestions when propertyCache or propertyLabels change useEffect(() => { const availableProperties = aggregateAvailableProperties(); setSuggestions(availableProperties); // Fetch labels for properties that don't have labels yet const unlabeledProperties = availableProperties .filter(prop => !propertyLabels[prop.id] || propertyLabels[prop.id] === prop.id) .map(prop => prop.id); if (unlabeledProperties.length > 0) { setIsLoading(true); onFetchPropertyLabels(unlabeledProperties) .finally(() => setIsLoading(false)); } }, [propertyCache, propertyLabels, aggregateAvailableProperties, onFetchPropertyLabels]); // Filter suggestions based on input const filteredSuggestions = suggestions.filter(suggestion => suggestion.label.toLowerCase().includes(inputValue.toLowerCase()) || suggestion.id.toLowerCase().includes(inputValue.toLowerCase()) ); // Handle input changes const handleInputChange = useCallback((e: React.ChangeEvent) => { const value = e.target.value; setInputValue(value); setShowSuggestions(value.trim().length > 0); setSelectedIndex(-1); }, []); // Handle keyboard navigation const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (!showSuggestions || filteredSuggestions.length === 0) { if (e.key === 'Enter') { e.preventDefault(); // Try to add property directly if it looks like a property ID if (inputValue.trim()) { onAddProperty(inputValue.trim()); setInputValue(''); setShowSuggestions(false); } } return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); setSelectedIndex(prev => prev < filteredSuggestions.length - 1 ? prev + 1 : 0 ); break; case 'ArrowUp': e.preventDefault(); setSelectedIndex(prev => prev > 0 ? prev - 1 : filteredSuggestions.length - 1 ); break; case 'Enter': e.preventDefault(); if (selectedIndex >= 0 && selectedIndex < filteredSuggestions.length) { const selectedProperty = filteredSuggestions[selectedIndex]; handlePropertySelect(selectedProperty); } else if (inputValue.trim()) { // Add property directly onAddProperty(inputValue.trim()); setInputValue(''); setShowSuggestions(false); } break; case 'Escape': e.preventDefault(); setShowSuggestions(false); setSelectedIndex(-1); inputRef.current?.blur(); break; case 'Tab': setShowSuggestions(false); setSelectedIndex(-1); break; } }, [showSuggestions, filteredSuggestions, selectedIndex, inputValue, onAddProperty]); // Handle property selection const handlePropertySelect = useCallback((property: PropertySuggestion) => { console.log('Property selected:', property); onAddProperty(property.id); setInputValue(''); setShowSuggestions(false); setSelectedIndex(-1); inputRef.current?.focus(); }, [onAddProperty]); // Handle focus const handleFocus = useCallback(() => { // Show all suggestions immediately on focus setShowSuggestions(true); }, []); // Handle blur const handleBlur = useCallback((e: React.FocusEvent) => { // Delay and more robust checking setTimeout(() => { // Check if the active element is within our suggestions dropdown const activeElement = document.activeElement; const isClickingOnSuggestion = suggestionsRef.current?.contains(activeElement) || suggestionsRef.current?.contains(e.relatedTarget as HTMLElement); if (!isClickingOnSuggestion) { setShowSuggestions(false); setSelectedIndex(-1); } }, 200); }, []); // Handle suggestion click const handleSuggestionClick = useCallback((property: PropertySuggestion, event: React.MouseEvent) => { // Prevent the blur event from interfering event.preventDefault(); event.stopPropagation(); // Immediately hide suggestions and process selection setShowSuggestions(false); setSelectedIndex(-1); // Process the selection handlePropertySelect(property); }, [handlePropertySelect]); // Handle clicking outside to close suggestions useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement; if ( inputRef.current && !inputRef.current.contains(target) && suggestionsRef.current && !suggestionsRef.current.contains(target) ) { setShowSuggestions(false); setSelectedIndex(-1); } }; if (showSuggestions) { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [showSuggestions]); return (
{/* Loading indicator */} {isLoading && (
)} {/* Suggestions dropdown */} {showSuggestions && filteredSuggestions.length > 0 && (
{filteredSuggestions.map((suggestion, index) => { const isSelected = index === selectedIndex; return (
handleSuggestionClick(suggestion, event)} onMouseEnter={() => setSelectedIndex(index)} >
{suggestion.label}
ID: {suggestion.id}
Available in {suggestion.availableInItems.length} item{suggestion.availableInItems.length !== 1 ? 's' : ''}
); })}
)} {/* No suggestions message */} {showSuggestions && inputValue.trim().length > 0 && filteredSuggestions.length === 0 && (
{`No matching properties found. Press Enter to add "${inputValue}" as a custom property.`}
)}
); }