uup
This commit is contained in:
214
app/components/ui/AutocompleteInput.tsx
Normal file
214
app/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user