177 lines
5.2 KiB
TypeScript
177 lines
5.2 KiB
TypeScript
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;
|
|
maxHeight?: number | string;
|
|
}
|
|
|
|
const Typeahead = <T,>({
|
|
onSelect,
|
|
searchFn,
|
|
renderOption,
|
|
format,
|
|
placeholder = "Search...",
|
|
debounceMs = 300,
|
|
disabled = false,
|
|
initialValue = "",
|
|
maxHeight = 200,
|
|
}: 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 performSearch = async (query: string) => {
|
|
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);
|
|
}
|
|
};
|
|
|
|
const debouncedSearch = useDebouncedCallback(performSearch, 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={async () => {
|
|
if (searchResults.length > 0) {
|
|
setIsOpen(true);
|
|
return;
|
|
}
|
|
await performSearch(searchQuery);
|
|
}}
|
|
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,
|
|
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; |