mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Changed the click handler on category autocomplete options from onClick to onMouseDown with preventDefault(). This fixes a race condition where the mousedown event (used by the outside click handler) was firing before the click event, causing the dropdown to close before the selection was processed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
215 lines
6.3 KiB
TypeScript
215 lines
6.3 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useState, useRef, useEffect, useCallback } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
import { Input } from "./input";
|
|
import { Check, ChevronDown } from "lucide-react";
|
|
|
|
interface CategoryAutocompleteProps {
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
suggestions: string[];
|
|
placeholder?: string;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
"data-testid"?: string;
|
|
}
|
|
|
|
export function CategoryAutocomplete({
|
|
value,
|
|
onChange,
|
|
suggestions,
|
|
placeholder = "Select or type a category...",
|
|
className,
|
|
disabled = false,
|
|
"data-testid": testId,
|
|
}: CategoryAutocompleteProps) {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [inputValue, setInputValue] = useState(value);
|
|
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const listRef = useRef<HTMLUListElement>(null);
|
|
|
|
// Update internal state when value prop changes
|
|
useEffect(() => {
|
|
setInputValue(value);
|
|
}, [value]);
|
|
|
|
// Filter suggestions based on input
|
|
useEffect(() => {
|
|
const searchTerm = inputValue.toLowerCase().trim();
|
|
if (searchTerm === "") {
|
|
setFilteredSuggestions(suggestions);
|
|
} else {
|
|
const filtered = suggestions.filter((s) =>
|
|
s.toLowerCase().includes(searchTerm)
|
|
);
|
|
setFilteredSuggestions(filtered);
|
|
}
|
|
setHighlightedIndex(-1);
|
|
}, [inputValue, suggestions]);
|
|
|
|
// 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 item into view
|
|
useEffect(() => {
|
|
if (highlightedIndex >= 0 && listRef.current) {
|
|
const items = listRef.current.querySelectorAll("li");
|
|
const highlightedItem = items[highlightedIndex];
|
|
if (highlightedItem) {
|
|
highlightedItem.scrollIntoView({ block: "nearest" });
|
|
}
|
|
}
|
|
}, [highlightedIndex]);
|
|
|
|
const handleInputChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newValue = e.target.value;
|
|
setInputValue(newValue);
|
|
onChange(newValue);
|
|
setIsOpen(true);
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(suggestion: string) => {
|
|
setInputValue(suggestion);
|
|
onChange(suggestion);
|
|
setIsOpen(false);
|
|
inputRef.current?.focus();
|
|
},
|
|
[onChange]
|
|
);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (!isOpen) {
|
|
if (e.key === "ArrowDown" || e.key === "Enter") {
|
|
e.preventDefault();
|
|
setIsOpen(true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) =>
|
|
prev < filteredSuggestions.length - 1 ? prev + 1 : prev
|
|
);
|
|
break;
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : -1));
|
|
break;
|
|
case "Enter":
|
|
e.preventDefault();
|
|
if (highlightedIndex >= 0 && filteredSuggestions[highlightedIndex]) {
|
|
handleSelect(filteredSuggestions[highlightedIndex]);
|
|
} else {
|
|
setIsOpen(false);
|
|
}
|
|
break;
|
|
case "Escape":
|
|
e.preventDefault();
|
|
setIsOpen(false);
|
|
break;
|
|
case "Tab":
|
|
setIsOpen(false);
|
|
break;
|
|
}
|
|
},
|
|
[isOpen, highlightedIndex, filteredSuggestions, handleSelect]
|
|
);
|
|
|
|
const handleFocus = useCallback(() => {
|
|
setIsOpen(true);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={containerRef} className={cn("relative", className)}>
|
|
<div className="relative">
|
|
<Input
|
|
ref={inputRef}
|
|
value={inputValue}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={handleFocus}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
data-testid={testId}
|
|
className="pr-8"
|
|
/>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors",
|
|
disabled && "pointer-events-none opacity-50"
|
|
)}
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
tabIndex={-1}
|
|
>
|
|
<ChevronDown
|
|
className={cn(
|
|
"h-4 w-4 transition-transform duration-200",
|
|
isOpen && "rotate-180"
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{isOpen && filteredSuggestions.length > 0 && (
|
|
<ul
|
|
ref={listRef}
|
|
className="absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-md border bg-background p-1 shadow-md animate-in fade-in-0 zoom-in-95"
|
|
role="listbox"
|
|
data-testid="category-autocomplete-list"
|
|
>
|
|
{filteredSuggestions.map((suggestion, index) => (
|
|
<li
|
|
key={suggestion}
|
|
role="option"
|
|
aria-selected={highlightedIndex === index}
|
|
className={cn(
|
|
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
|
|
highlightedIndex === index && "bg-accent text-accent-foreground",
|
|
inputValue === suggestion && "font-medium"
|
|
)}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
handleSelect(suggestion);
|
|
}}
|
|
onMouseEnter={() => setHighlightedIndex(index)}
|
|
data-testid={`category-option-${suggestion.toLowerCase().replace(/\s+/g, "-")}`}
|
|
>
|
|
{inputValue === suggestion && (
|
|
<Check className="mr-2 h-4 w-4 text-primary" />
|
|
)}
|
|
<span className={cn(inputValue !== suggestion && "ml-6")}>
|
|
{suggestion}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|