car_mms/app/components/ui/AutocompleteInput.tsx
2025-09-11 14:22:27 +03:00

214 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}