Add category typeahead with autocomplete suggestions

Changed the category input to a typeahead component in both Add Feature and
Edit Feature dialogs. The component suggests existing categories from
features and allows typing new ones. Categories are saved to feature_list.json.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Cody Seibert
2025-12-08 23:35:05 -05:00
parent 7bfc489efa
commit adad2262c2
4 changed files with 265 additions and 21 deletions

View File

@@ -0,0 +1,211 @@
"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-popover p-1 text-popover-foreground 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"
)}
onClick={() => 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>
);
}