214 lines
6.6 KiB
TypeScript
214 lines
6.6 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
||
import { Input } from "./Input";
|
||
|
||
interface AutocompleteOption {
|
||
value: string;
|
||
label: string;
|
||
data?: any;
|
||
}
|
||
|
||
interface AutocompleteInputProps {
|
||
name?: string;
|
||
label?: string;
|
||
placeholder?: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
onSelect?: (option: AutocompleteOption) => void;
|
||
options: AutocompleteOption[];
|
||
error?: string;
|
||
required?: boolean;
|
||
disabled?: boolean;
|
||
loading?: boolean;
|
||
}
|
||
|
||
export function AutocompleteInput({
|
||
name,
|
||
label,
|
||
placeholder,
|
||
value,
|
||
onChange,
|
||
onSelect,
|
||
options,
|
||
error,
|
||
required,
|
||
disabled,
|
||
loading
|
||
}: AutocompleteInputProps) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const listRef = useRef<HTMLUListElement>(null);
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Filter options based on input value
|
||
const filteredOptions = options.filter(option =>
|
||
option.label.toLowerCase().includes(value.toLowerCase()) ||
|
||
option.value.toLowerCase().includes(value.toLowerCase())
|
||
);
|
||
|
||
// Handle input change
|
||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const newValue = e.target.value;
|
||
onChange(newValue);
|
||
setIsOpen(true);
|
||
setHighlightedIndex(-1);
|
||
};
|
||
|
||
// Handle option selection
|
||
const handleOptionSelect = (option: AutocompleteOption) => {
|
||
onChange(option.value);
|
||
onSelect?.(option);
|
||
setIsOpen(false);
|
||
setHighlightedIndex(-1);
|
||
};
|
||
|
||
// Handle keyboard navigation
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (!isOpen) {
|
||
if (e.key === "ArrowDown" || e.key === "Enter") {
|
||
setIsOpen(true);
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
|
||
switch (e.key) {
|
||
case "ArrowDown":
|
||
e.preventDefault();
|
||
setHighlightedIndex(prev =>
|
||
prev < filteredOptions.length - 1 ? prev + 1 : 0
|
||
);
|
||
break;
|
||
case "ArrowUp":
|
||
e.preventDefault();
|
||
setHighlightedIndex(prev =>
|
||
prev > 0 ? prev - 1 : filteredOptions.length - 1
|
||
);
|
||
break;
|
||
case "Enter":
|
||
e.preventDefault();
|
||
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
|
||
handleOptionSelect(filteredOptions[highlightedIndex]);
|
||
}
|
||
break;
|
||
case "Escape":
|
||
setIsOpen(false);
|
||
setHighlightedIndex(-1);
|
||
break;
|
||
}
|
||
};
|
||
|
||
// Close dropdown when clicking outside
|
||
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);
|
||
}, []);
|
||
|
||
// Scroll highlighted option into view
|
||
useEffect(() => {
|
||
if (highlightedIndex >= 0 && listRef.current) {
|
||
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
|
||
if (highlightedElement) {
|
||
highlightedElement.scrollIntoView({
|
||
block: "nearest",
|
||
behavior: "smooth"
|
||
});
|
||
}
|
||
}
|
||
}, [highlightedIndex]);
|
||
|
||
return (
|
||
<div ref={containerRef} className="relative">
|
||
<Input
|
||
ref={inputRef}
|
||
name={name}
|
||
label={label}
|
||
placeholder={placeholder}
|
||
value={value}
|
||
onChange={handleInputChange}
|
||
onKeyDown={handleKeyDown}
|
||
onFocus={() => setIsOpen(true)}
|
||
error={error}
|
||
required={required}
|
||
disabled={disabled}
|
||
endIcon={
|
||
<div className="pointer-events-auto">
|
||
{loading ? (
|
||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||
</svg>
|
||
) : isOpen ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsOpen(false)}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||
</svg>
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsOpen(true)}
|
||
className="text-gray-400 hover:text-gray-600"
|
||
>
|
||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{/* Dropdown */}
|
||
{isOpen && filteredOptions.length > 0 && (
|
||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
|
||
<ul ref={listRef} className="py-1">
|
||
{filteredOptions.map((option, index) => (
|
||
<li
|
||
key={option.value}
|
||
className={`px-3 py-2 cursor-pointer text-sm ${
|
||
index === highlightedIndex
|
||
? "bg-blue-50 text-blue-900"
|
||
: "text-gray-900 hover:bg-gray-50"
|
||
}`}
|
||
onClick={(e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
handleOptionSelect(option);
|
||
}}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault(); // Prevent input from losing focus
|
||
}}
|
||
onMouseEnter={() => setHighlightedIndex(index)}
|
||
>
|
||
<div className="font-medium">{option.value}</div>
|
||
{option.label !== option.value && (
|
||
<div className="text-xs text-gray-500 mt-1">{option.label}</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* No results */}
|
||
{isOpen && filteredOptions.length === 0 && value.length > 0 && (
|
||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg">
|
||
<div className="px-3 py-2 text-sm text-gray-500 text-center">
|
||
لا توجد نتائج مطابقة
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |