kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
custom fuzzy search
rodzic
fee112b4f0
commit
9153c5e4a6
|
@ -0,0 +1,260 @@
|
||||||
|
/* 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)
|
||||||
|
interface FuzzySearchInputProps<T> {
|
||||||
|
/** The array of objects or strings to search through. */
|
||||||
|
data: T[];
|
||||||
|
/** An array of keys within the objects in `data` to search against. Ignored if `data` is string[]. */
|
||||||
|
keys: (keyof T)[];
|
||||||
|
/** Callback function invoked when an item is selected from the suggestions. */
|
||||||
|
onSelection: (selection: T | null) => void;
|
||||||
|
/** Optional: The key to display in the suggestion list. Defaults to the first key in the `keys` prop or the item itself if data is string[]. */
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fuzzy search implementation
|
||||||
|
const defaultSearch = <T,>(data: T[], query: string, keys: (keyof T)[]): T[] => {
|
||||||
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const searcher = new FuzzySearch(data as any[], keys as string[], {
|
||||||
|
caseSensitive: false,
|
||||||
|
sort: true,
|
||||||
|
});
|
||||||
|
return searcher.search(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function FuzzySearchInput<T extends Record<string, any> | string>({
|
||||||
|
data,
|
||||||
|
keys,
|
||||||
|
onSelection,
|
||||||
|
displayKey,
|
||||||
|
placeholder = 'Search...',
|
||||||
|
searchFn = defaultSearch,
|
||||||
|
className = '',
|
||||||
|
inputClassName = '',
|
||||||
|
suggestionsClassName = '',
|
||||||
|
suggestionItemClassName = '',
|
||||||
|
activeSuggestionItemClassName = 'active',
|
||||||
|
baseId, // Optional base ID from props
|
||||||
|
}: FuzzySearchInputProps<T>) {
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
const [suggestions, setSuggestions] = useState<T[]>([]);
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number>(-1);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
// Generate unique IDs for ARIA attributes if no baseId is provided
|
||||||
|
const generatedId = useId();
|
||||||
|
const componentBaseId = baseId ?? generatedId;
|
||||||
|
const inputId = `${componentBaseId}-input`;
|
||||||
|
const listboxId = `${componentBaseId}-listbox`;
|
||||||
|
const getOptionId = (index: number) => `${componentBaseId}-option-${index}`;
|
||||||
|
|
||||||
|
const getDisplayText = useCallback((item: T): string => {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
const key = displayKey ?? keys[0];
|
||||||
|
return key ? String(item[key]) : 'Invalid displayKey';
|
||||||
|
}, [displayKey, keys]);
|
||||||
|
|
||||||
|
|
||||||
|
// --- Event Handlers ---
|
||||||
|
|
||||||
|
const handleInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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 handleSelectSuggestion = useCallback((suggestion: T) => {
|
||||||
|
setInputValue(getDisplayText(suggestion));
|
||||||
|
setSuggestions([]);
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
onSelection(suggestion);
|
||||||
|
// Optionally focus the input again if needed, though blur might occur naturally
|
||||||
|
// containerRef.current?.querySelector('input')?.focus();
|
||||||
|
}, [getDisplayText, onSelection]);
|
||||||
|
|
||||||
|
// This function now primarily handles keyboard navigation *within* the list
|
||||||
|
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
const listIsVisible = showSuggestions && suggestions.length > 0;
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!listIsVisible) {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setActiveIndex((prevIndex) =>
|
||||||
|
prevIndex >= suggestions.length - 1 ? 0 : prevIndex + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!listIsVisible) return;
|
||||||
|
setActiveIndex((prevIndex) =>
|
||||||
|
prevIndex <= 0 ? suggestions.length - 1 : prevIndex - 1,
|
||||||
|
);
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
if (!listIsVisible || activeIndex < 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
handleSelectSuggestion(suggestions[activeIndex]);
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
if (!listIsVisible) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setShowSuggestions(false);
|
||||||
|
setActiveIndex(-1);
|
||||||
|
setSuggestions([]); // Clear suggestions on escape
|
||||||
|
}
|
||||||
|
}, [showSuggestions, suggestions, activeIndex, handleSelectSuggestion, inputValue, searchFn, data, keys]);
|
||||||
|
|
||||||
|
const handleBlur = useCallback(() => {
|
||||||
|
// Delay hiding to allow clicks on options
|
||||||
|
setTimeout(() => {
|
||||||
|
// Check if focus is still somehow within the container (e.g., clicked an option)
|
||||||
|
// If focus moved outside, hide the list.
|
||||||
|
if (containerRef.current && !containerRef.current.contains(document.activeElement)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
// Don't reset activeIndex here, selection might have happened
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Click Outside Detection ---
|
||||||
|
// (Keep the useEffect for handleClickOutside as before)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
// Don't reset activeIndex here, allows inspection if needed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Rendering ---
|
||||||
|
const listIsVisible = showSuggestions && suggestions.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`fuzzy-search-input-container ${className}`}
|
||||||
|
style={{ position: 'relative' }}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={inputId} // ID for input
|
||||||
|
type='text'
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onKeyDown={handleKeyDown} // Keyboard interactions handled here
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete='off'
|
||||||
|
className={`fuzzy-search-input ${inputClassName}`}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
// ARIA attributes for Combobox pattern
|
||||||
|
role='combobox'
|
||||||
|
aria-autocomplete='list'
|
||||||
|
aria-controls={listIsVisible ? listboxId : undefined} // Control listbox only when visible
|
||||||
|
aria-expanded={listIsVisible}
|
||||||
|
aria-haspopup='listbox'
|
||||||
|
aria-activedescendant={activeIndex > -1 ? getOptionId(activeIndex) : undefined} // Point to active option
|
||||||
|
/>
|
||||||
|
{listIsVisible && (
|
||||||
|
<ul
|
||||||
|
id={listboxId} // ID for listbox
|
||||||
|
role='listbox' // Role for the suggestions container
|
||||||
|
className={`fuzzy-search-suggestions ${suggestionsClassName}`}
|
||||||
|
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) => {
|
||||||
|
const displayText = getDisplayText(item);
|
||||||
|
const isActive = index === activeIndex;
|
||||||
|
const optionId = getOptionId(index);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={optionId} // Use unique generated ID as key
|
||||||
|
id={optionId} // ID for this option
|
||||||
|
role='option' // Role for each suggestion item
|
||||||
|
aria-selected={isActive} // Indicate selection state for screen readers
|
||||||
|
// onClick IS appropriate here for mouse users selecting an option
|
||||||
|
onClick={() => 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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayText}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FuzzySearchInput;
|
Ładowanie…
Reference in New Issue