less custom styling, allow overriding suggestion rendering

merge-requests/3361/merge^2
Siddharth Singh 2025-03-31 18:01:12 +05:30
rodzic 9153c5e4a6
commit 86f840f15d
Nie znaleziono w bazie danych klucza dla tego podpisu
1 zmienionych plików z 59 dodań i 101 usunięć

Wyświetl plik

@ -1,8 +1,8 @@
/* eslint-disable tailwindcss/no-custom-classname */
import FuzzySearch from 'fuzzy-search'; import FuzzySearch from 'fuzzy-search';
import React, { useState, useRef, useCallback, useEffect, useId } from 'react'; 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<T> { interface FuzzySearchInputProps<T> {
/** The array of objects or strings to search through. */ /** The array of objects or strings to search through. */
data: T[]; data: T[];
@ -14,35 +14,34 @@ interface FuzzySearchInputProps<T> {
displayKey?: keyof T; displayKey?: keyof T;
/** Optional: Placeholder text for the input field. */ /** Optional: Placeholder text for the input field. */
placeholder?: string; 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 */ /** Optional: Custom class name for the main container div */
className?: string; 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; baseId?: string;
/** Component to use to optionally override suggestion rendering. */
renderSuggestion?: React.ComponentType<{ item: T }>;
} }
// Default fuzzy search implementation interface SearchImpl {
const defaultSearch = <T,>(data: T[], query: string, keys: (keyof T)[]): T[] => { /**
if (!query) { * @param data The data to search through. Should be an array of Records
return []; * @param query The query to search for.
} * @param keys The keys in the record to search through
* @returns A list of search results.
*/
<T extends Record<string, any> | 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[], { const searcher = new FuzzySearch(data as any[], keys as string[], {
caseSensitive: false, caseSensitive: false,
sort: true, sort: true,
}); });
return searcher.search(query); return query ? searcher.search(query) : data;
}; };
function FuzzySearchInput<T extends Record<string, any> | string>({ function FuzzySearchInput<T extends Record<string, any> | string>({
data, data,
keys, keys,
@ -51,11 +50,8 @@ function FuzzySearchInput<T extends Record<string, any> | string>({
placeholder = 'Search...', placeholder = 'Search...',
searchFn = defaultSearch, searchFn = defaultSearch,
className = '', className = '',
inputClassName = '', baseId,
suggestionsClassName = '', renderSuggestion: FuzzySearchSuggestion,
suggestionItemClassName = '',
activeSuggestionItemClassName = 'active',
baseId, // Optional base ID from props
}: FuzzySearchInputProps<T>) { }: FuzzySearchInputProps<T>) {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [suggestions, setSuggestions] = useState<T[]>([]); const [suggestions, setSuggestions] = useState<T[]>([]);
@ -78,25 +74,15 @@ function FuzzySearchInput<T extends Record<string, any> | string>({
return key ? String(item[key]) : 'Invalid displayKey'; return key ? String(item[key]) : 'Invalid displayKey';
}, [displayKey, keys]); }, [displayKey, keys]);
// --- Event Handlers ---
const handleInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const query = event.target.value; const query = event.target.value;
setInputValue(query); setInputValue(query);
setActiveIndex(-1); setActiveIndex(-1);
if (query.trim()) {
const results = searchFn(data, query, keys); const results = searchFn(data, query, keys);
setSuggestions(results); setSuggestions(results);
// Only show suggestions if there are results setShowSuggestions(results.length > 0 || query.trim() === '');
setShowSuggestions(results.length > 0); }, [searchFn, data, keys]);
} else {
setSuggestions([]);
setShowSuggestions(false);
onSelection(null);
}
}, [searchFn, data, keys, onSelection]);
const handleSelectSuggestion = useCallback((suggestion: T) => { const handleSelectSuggestion = useCallback((suggestion: T) => {
setInputValue(getDisplayText(suggestion)); setInputValue(getDisplayText(suggestion));
@ -108,22 +94,19 @@ function FuzzySearchInput<T extends Record<string, any> | string>({
// containerRef.current?.querySelector('input')?.focus(); // containerRef.current?.querySelector('input')?.focus();
}, [getDisplayText, onSelection]); }, [getDisplayText, onSelection]);
// This function now primarily handles keyboard navigation *within* the list
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
const listIsVisible = showSuggestions && suggestions.length > 0; const listIsVisible = showSuggestions && suggestions.length > 0;
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
event.preventDefault(); event.preventDefault();
if (!listIsVisible) { if (!listIsVisible && inputValue.trim()) {
// If list isn't visible but there's input, try searching/showing // If list isn't visible but there's input, try searching/showing
if (inputValue.trim()) {
const results = searchFn(data, inputValue.trim(), keys); const results = searchFn(data, inputValue.trim(), keys);
if (results.length > 0) { if (results.length > 0) {
setSuggestions(results); setSuggestions(results);
setShowSuggestions(true); setShowSuggestions(true);
setActiveIndex(0); // Start at the first item setActiveIndex(0); // Start at the first item
} }
}
} else { } else {
setActiveIndex((prevIndex) => setActiveIndex((prevIndex) =>
prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1, prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1,
@ -148,8 +131,17 @@ function FuzzySearchInput<T extends Record<string, any> | string>({
} }
}, [showSuggestions, suggestions, activeIndex, handleSelectSuggestion, inputValue, searchFn, data, keys]); }, [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(() => { const handleBlur = useCallback(() => {
// Delay hiding to allow clicks on options
// Delay hiding to allow clicking on options
setTimeout(() => { setTimeout(() => {
// Check if focus is still somehow within the container (e.g., clicked an option) // Check if focus is still somehow within the container (e.g., clicked an option)
// If focus moved outside, hide the list. // If focus moved outside, hide the list.
@ -160,8 +152,7 @@ function FuzzySearchInput<T extends Record<string, any> | string>({
}, 150); }, 150);
}, []); }, []);
// --- Click Outside Detection --- // Check if user clicked outside
// (Keep the useEffect for handleClickOutside as before)
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
@ -175,58 +166,31 @@ function FuzzySearchInput<T extends Record<string, any> | string>({
}; };
}, []); }, []);
// --- Rendering ---
const listIsVisible = showSuggestions && suggestions.length > 0;
return ( return (
<div <div ref={containerRef} className={`relative ${className}`}>
ref={containerRef} <Input
className={`fuzzy-search-input-container ${className}`} id={inputId}
style={{ position: 'relative' }}
>
<input
id={inputId} // ID for input
type='text' type='text'
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} // Keyboard interactions handled here onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
placeholder={placeholder} placeholder={placeholder}
autoComplete='off' autoComplete='off'
className={`fuzzy-search-input ${inputClassName}`}
style={{ width: '100%' }}
// ARIA attributes for Combobox pattern
role='combobox'
aria-autocomplete='list' aria-autocomplete='list'
aria-controls={listIsVisible ? listboxId : undefined} // Control listbox only when visible aria-controls={showSuggestions && suggestions.length > 0 ? listboxId : undefined}
aria-expanded={listIsVisible} aria-expanded={showSuggestions && suggestions.length > 0}
aria-haspopup='listbox' 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 && (
<ul <ul
id={listboxId} // ID for listbox id={listboxId}
role='listbox' // Role for the suggestions container role='listbox'
className={`fuzzy-search-suggestions ${suggestionsClassName}`} className='absolute z-10 mt-1 max-h-60 w-full overflow-y-auto rounded-md border border-gray-300 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800'
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
border: '1px solid #ccc',
background: 'white',
listStyle: 'none',
margin: 0,
padding: 0,
maxHeight: '200px',
overflowY: 'auto',
zIndex: 1000,
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
// tabIndex={-1} // List itself shouldn't be directly tabbable usually
> >
{suggestions.map((item, index) => { {suggestions.map((item, index) => {
const displayText = getDisplayText(item);
const isActive = index === activeIndex; const isActive = index === activeIndex;
const optionId = getOptionId(index); const optionId = getOptionId(index);
@ -235,19 +199,13 @@ function FuzzySearchInput<T extends Record<string, any> | string>({
key={optionId} // Use unique generated ID as key key={optionId} // Use unique generated ID as key
id={optionId} // ID for this option id={optionId} // ID for this option
role='option' // Role for each suggestion item role='option' // Role for each suggestion item
aria-selected={isActive} // Indicate selection state for screen readers aria-selected={isActive}
// onClick IS appropriate here for mouse users selecting an option
onClick={() => handleSelectSuggestion(item)} onClick={() => handleSelectSuggestion(item)}
// onMouseEnter helps sync visual hover with keyboard activeIndex for usability // onMouseEnter helps sync visual hover with keyboard activeIndex for usability
onMouseEnter={() => setActiveIndex(index)} onMouseEnter={() => setActiveIndex(index)}
className={`${suggestionItemClassName} ${isActive ? activeSuggestionItemClassName : ''}`} className={`cursor-pointer p-2 ${isActive ? 'bg-gray-100 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-700'}`}
style={{
padding: '8px 12px',
cursor: 'pointer',
backgroundColor: isActive ? '#eee' : 'transparent',
}}
> >
{displayText} {FuzzySearchSuggestion ? <FuzzySearchSuggestion item={item} /> : getDisplayText(item)}
</li> </li>
); );
})} })}