kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
less custom styling, allow overriding suggestion rendering
rodzic
9153c5e4a6
commit
86f840f15d
|
@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
Ładowanie…
Reference in New Issue