new typeahead
This commit is contained in:
175
src/components/typeahead.tsx
Normal file
175
src/components/typeahead.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useRef, useEffect, ReactNode } from "react";
|
||||
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
|
||||
export interface TypeaheadOption<T = any> {
|
||||
id: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface TypeaheadProps<T> {
|
||||
onSelect: (option: TypeaheadOption<T>) => void;
|
||||
searchFn: (query: string) => Promise<TypeaheadOption<T>[]>;
|
||||
renderOption: (option: TypeaheadOption<T>, isSelected?: boolean) => ReactNode;
|
||||
format?: (option: TypeaheadOption<T>) => string;
|
||||
placeholder?: string;
|
||||
debounceMs?: number;
|
||||
disabled?: boolean;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
const Typeahead = <T,>({
|
||||
onSelect,
|
||||
searchFn,
|
||||
renderOption,
|
||||
format,
|
||||
placeholder = "Search...",
|
||||
debounceMs = 300,
|
||||
disabled = false,
|
||||
initialValue = ""
|
||||
}: TypeaheadProps<T>) => {
|
||||
const [searchQuery, setSearchQuery] = useState(initialValue);
|
||||
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const results = await searchFn(query);
|
||||
setSearchResults(results);
|
||||
setIsOpen(results.length > 0);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults([]);
|
||||
setIsOpen(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, debounceMs);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
debouncedSearch(value);
|
||||
};
|
||||
|
||||
const handleOptionSelect = (option: TypeaheadOption<T>) => {
|
||||
onSelect(option);
|
||||
const displayValue = format ? format(option) : String(option.data);
|
||||
setSearchQuery(displayValue);
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen || searchResults.length === 0) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
||||
handleOptionSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box ref={containerRef} pos="relative" w="100%">
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
value={searchQuery}
|
||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => {
|
||||
if (searchResults.length > 0) setIsOpen(true);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Paper
|
||||
shadow="md"
|
||||
p={0}
|
||||
bd="1px solid var(--mantine-color-dimmed)"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
maxHeight: '160px',
|
||||
overflowY: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pan-y',
|
||||
borderTop: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0
|
||||
}}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
<Stack gap={0}>
|
||||
{searchResults.map((option, index) => (
|
||||
<Box
|
||||
key={option.id}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||
}}
|
||||
onClick={() => handleOptionSelect(option)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
{renderOption(option, selectedIndex === index)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box p="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Typeahead;
|
||||
Reference in New Issue
Block a user