diff --git a/src/components/fuzzy-search-input.tsx b/src/components/fuzzy-search-input.tsx index ae6db1463..7e210eecc 100644 --- a/src/components/fuzzy-search-input.tsx +++ b/src/components/fuzzy-search-input.tsx @@ -1,8 +1,8 @@ -/* eslint-disable tailwindcss/no-custom-classname */ import FuzzySearch from 'fuzzy-search'; import React, { useState, useRef, useCallback, useEffect, useId } from 'react'; -// (Keep the FuzzySearchInputProps interface and defaultSearch function as before) +import Input from 'soapbox/components/ui/input.tsx'; + interface FuzzySearchInputProps { /** The array of objects or strings to search through. */ data: T[]; @@ -14,35 +14,34 @@ interface FuzzySearchInputProps { displayKey?: keyof T; /** Optional: Placeholder text for the input field. */ placeholder?: string; - /** Optional: Custom search function to override the default fuzzy search. */ - searchFn?: (data: T[], query: string, keys: (keyof T)[]) => T[]; + /** + * Optional: Custom search function to override the default fuzzy search. */ + searchFn?: SearchImpl; /** Optional: Custom class name for the main container div */ className?: string; - /** Optional: Custom class name for the input element */ - inputClassName?: string; - /** Optional: Custom class name for the suggestions dropdown ul element */ - suggestionsClassName?: string; - /** Optional: Custom class name for individual suggestion li elements */ - suggestionItemClassName?: string; - /** Optional: Custom class name for the active suggestion li element */ - activeSuggestionItemClassName?: string; - /** Optional: Base ID for accessibility attributes. A unique ID will be generated if not provided. */ baseId?: string; + /** Component to use to optionally override suggestion rendering. */ + renderSuggestion?: React.ComponentType<{ item: T }>; } -// Default fuzzy search implementation -const defaultSearch = (data: T[], query: string, keys: (keyof T)[]): T[] => { - if (!query) { - return []; - } +interface SearchImpl { + /** + * @param data The data to search through. Should be an array of Records + * @param query The query to search for. + * @param keys The keys in the record to search through + * @returns A list of search results. + */ + | string>(data: T[], query: string, keys: (keyof T)[]): T[]; +} + +const defaultSearch: SearchImpl = (data, query, keys) => { const searcher = new FuzzySearch(data as any[], keys as string[], { caseSensitive: false, sort: true, }); - return searcher.search(query); + return query ? searcher.search(query) : data; }; - function FuzzySearchInput | string>({ data, keys, @@ -51,11 +50,8 @@ function FuzzySearchInput | string>({ placeholder = 'Search...', searchFn = defaultSearch, className = '', - inputClassName = '', - suggestionsClassName = '', - suggestionItemClassName = '', - activeSuggestionItemClassName = 'active', - baseId, // Optional base ID from props + baseId, + renderSuggestion: FuzzySearchSuggestion, }: FuzzySearchInputProps) { const [inputValue, setInputValue] = useState(''); const [suggestions, setSuggestions] = useState([]); @@ -78,25 +74,15 @@ function FuzzySearchInput | string>({ return key ? String(item[key]) : 'Invalid displayKey'; }, [displayKey, keys]); - - // --- Event Handlers --- - const handleInputChange = useCallback((event: React.ChangeEvent) => { const query = event.target.value; setInputValue(query); setActiveIndex(-1); - if (query.trim()) { - const results = searchFn(data, query, keys); - setSuggestions(results); - // Only show suggestions if there are results - setShowSuggestions(results.length > 0); - } else { - setSuggestions([]); - setShowSuggestions(false); - onSelection(null); - } - }, [searchFn, data, keys, onSelection]); + const results = searchFn(data, query, keys); + setSuggestions(results); + setShowSuggestions(results.length > 0 || query.trim() === ''); + }, [searchFn, data, keys]); const handleSelectSuggestion = useCallback((suggestion: T) => { setInputValue(getDisplayText(suggestion)); @@ -108,21 +94,18 @@ function FuzzySearchInput | string>({ // containerRef.current?.querySelector('input')?.focus(); }, [getDisplayText, onSelection]); - // This function now primarily handles keyboard navigation *within* the list const handleKeyDown = useCallback((event: React.KeyboardEvent) => { const listIsVisible = showSuggestions && suggestions.length > 0; if (event.key === 'ArrowDown') { event.preventDefault(); - if (!listIsVisible) { + if (!listIsVisible && inputValue.trim()) { // If list isn't visible but there's input, try searching/showing - if (inputValue.trim()) { - const results = searchFn(data, inputValue.trim(), keys); - if (results.length > 0) { - setSuggestions(results); - setShowSuggestions(true); - setActiveIndex(0); // Start at the first item - } + const results = searchFn(data, inputValue.trim(), keys); + if (results.length > 0) { + setSuggestions(results); + setShowSuggestions(true); + setActiveIndex(0); // Start at the first item } } else { setActiveIndex((prevIndex) => @@ -148,8 +131,17 @@ function FuzzySearchInput | string>({ } }, [showSuggestions, suggestions, activeIndex, handleSelectSuggestion, inputValue, searchFn, data, keys]); + const handleFocus = useCallback(() => { + if (inputValue.trim() === '') { + const results = searchFn(data, '', keys); + setSuggestions(results); + setShowSuggestions(results.length > 0); + } + }, [inputValue, searchFn, data, keys]); + const handleBlur = useCallback(() => { - // Delay hiding to allow clicks on options + + // Delay hiding to allow clicking on options setTimeout(() => { // Check if focus is still somehow within the container (e.g., clicked an option) // If focus moved outside, hide the list. @@ -160,8 +152,7 @@ function FuzzySearchInput | string>({ }, 150); }, []); - // --- Click Outside Detection --- - // (Keep the useEffect for handleClickOutside as before) + // Check if user clicked outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { @@ -175,79 +166,46 @@ function FuzzySearchInput | string>({ }; }, []); - // --- Rendering --- - const listIsVisible = showSuggestions && suggestions.length > 0; - return ( -
- + 0 ? listboxId : undefined} + aria-expanded={showSuggestions && suggestions.length > 0} aria-haspopup='listbox' - aria-activedescendant={activeIndex > -1 ? getOptionId(activeIndex) : undefined} // Point to active option + aria-activedescendant={activeIndex > -1 ? getOptionId(activeIndex) : undefined} /> - {listIsVisible && ( + {showSuggestions && suggestions.length > 0 && (
    {suggestions.map((item, index) => { - const displayText = getDisplayText(item); const isActive = index === activeIndex; const optionId = getOptionId(index); return (
  • handleSelectSuggestion(item)} // onMouseEnter helps sync visual hover with keyboard activeIndex for usability onMouseEnter={() => setActiveIndex(index)} - className={`${suggestionItemClassName} ${isActive ? activeSuggestionItemClassName : ''}`} - style={{ - padding: '8px 12px', - cursor: 'pointer', - backgroundColor: isActive ? '#eee' : 'transparent', - }} + className={`cursor-pointer p-2 ${isActive ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}`} > - {displayText} + {FuzzySearchSuggestion ? : getDisplayText(item)}
  • ); })}