/** * TagInput - Chip-based combobox for tag entry with autocomplete. * * Aesthetic: Vintage specimen labels / card catalog tabs * - Tags styled as library index cards with subtle brass accents * - Monospace typography for technical/archival feel * - Hierarchical tags shown with subtle prefix grouping */ import { TagIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { apiFetch } from "../hooks/use-api"; import { cn } from "../lib/utils"; /** Tag suggestion from API */ interface TagSuggestion { tag: string; count: number; } interface TagsResponse { tags: TagSuggestion[]; meta: { total: number }; } export interface TagInputProps { /** Current tags */ value: string[]; /** Callback when tags change */ onChange: (tags: string[]) => void; /** Disabled state */ disabled?: boolean; /** Additional classes */ className?: string; /** Input placeholder */ placeholder?: string; /** Aria label for the input */ "aria-label"?: string; } // Tag grammar validation (matches src/core/tags.ts) const SEGMENT_REGEX = /^[\p{Ll}\p{Lo}\p{N}][\p{Ll}\p{Lo}\p{N}\-.]*$/u; function normalizeTag(tag: string): string { return tag.trim().normalize("NFC").toLowerCase(); } function validateTag(tag: string): boolean { if (tag.length === 0) return false; if (tag.startsWith("/") || tag.endsWith("/")) return false; const segments = tag.split("/"); for (const segment of segments) { if (segment.length === 0) return false; if (!SEGMENT_REGEX.test(segment)) return false; } return true; } /** Flattened option for rendering and keyboard nav */ interface FlatOption { tag: string; count: number; prefix: string; displayName: string; isFirstInGroup: boolean; } /** Build flat list with grouping metadata */ function flattenSuggestions(suggestions: TagSuggestion[]): FlatOption[] { // Group by prefix const groups = new Map(); for (const s of suggestions) { const slashIdx = s.tag.indexOf("/"); const prefix = slashIdx > 0 ? s.tag.slice(0, slashIdx) : ""; if (!groups.has(prefix)) groups.set(prefix, []); groups.get(prefix)!.push(s); } // Flatten with stable ordering: root first, then prefixes alphabetically const flat: FlatOption[] = []; const prefixes = Array.from(groups.keys()).sort((a, b) => { if (a === "") return -1; if (b === "") return 1; return a.localeCompare(b); }); for (const prefix of prefixes) { const groupTags = groups.get(prefix)!; for (let i = 0; i < groupTags.length; i++) { const s = groupTags[i]; if (!s) continue; flat.push({ tag: s.tag, count: s.count, prefix, displayName: prefix ? s.tag.slice(prefix.length + 1) : s.tag, isFirstInGroup: i === 0, }); } } return flat; } export function TagInput({ value, onChange, disabled = false, className, placeholder = "Add tags...", "aria-label": ariaLabel = "Tag input", }: TagInputProps) { const inputRef = useRef(null); const containerRef = useRef(null); const instanceId = useId(); // Input state const [inputValue, setInputValue] = useState(""); const [error, setError] = useState(null); // Autocomplete state const [suggestions, setSuggestions] = useState([]); const [isOpen, setIsOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const [isLoading, setIsLoading] = useState(false); // Live region for screen reader announcements const [announcement, setAnnouncement] = useState(""); // Request sequencing to prevent race conditions const requestSeqRef = useRef(0); const fetchTimeoutRef = useRef | null>(null); const listboxId = `${instanceId}-listbox`; const getOptionId = (index: number) => `${instanceId}-option-${index}`; // Cache all tags for client-side filtering const [allTags, setAllTags] = useState([]); const hasFetchedAllTags = useRef(false); // Max suggestions to show (perf cap) const MAX_SUGGESTIONS = 100; // Fetch all tags once on first interaction const fetchAllTags = useCallback(async () => { if (hasFetchedAllTags.current) return; const { data, error } = await apiFetch("/api/tags"); if (error || !data) { // Allow retry on failure return; } hasFetchedAllTags.current = true; setAllTags(data.tags); }, []); // Filter suggestions client-side for substring matching const filterSuggestions = useCallback( (query: string, seq: number) => { if (query.length === 0) { setSuggestions([]); setIsOpen(false); return; } setIsLoading(true); // Ignore stale responses if (seq !== requestSeqRef.current) { setIsLoading(false); return; } const normalizedQuery = normalizeTag(query); // Filter tags that contain the query (substring match), cap for perf const filtered = allTags .filter( (s) => s.tag.includes(normalizedQuery) && !value.includes(s.tag) ) .slice(0, MAX_SUGGESTIONS); setSuggestions(filtered); setIsOpen(filtered.length > 0); setActiveIndex(-1); setIsLoading(false); // Announce results if (filtered.length > 0) { setAnnouncement(`${filtered.length} tag suggestions available`); } }, [allTags, value] ); // Debounce input changes with sequencing useEffect(() => { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current); } if (inputValue.length > 0) { const seq = ++requestSeqRef.current; // Short debounce for client-side filtering (no network latency) fetchTimeoutRef.current = setTimeout(() => { filterSuggestions(inputValue, seq); }, 50); } else { // Increment seq so stale responses are ignored ++requestSeqRef.current; setSuggestions([]); setIsOpen(false); } return () => { if (fetchTimeoutRef.current) { clearTimeout(fetchTimeoutRef.current); } }; }, [inputValue, filterSuggestions]); // Re-filter when allTags arrives (handles type-before-fetch-completes) useEffect(() => { if (allTags.length > 0 && inputValue.length > 0) { const seq = ++requestSeqRef.current; filterSuggestions(inputValue, seq); } }, [allTags]); // eslint-disable-line react-hooks/exhaustive-deps // Flatten suggestions for keyboard navigation const flatOptions = useMemo( () => flattenSuggestions(suggestions), [suggestions] ); // Add a tag const addTag = useCallback( (tag: string) => { const normalized = normalizeTag(tag); if (!normalized) { setError("Tag cannot be empty"); return false; } if (!validateTag(normalized)) { setError( "Invalid: use lowercase, alphanumeric, hyphens, dots, slashes" ); return false; } if (value.includes(normalized)) { setError("Tag already added"); return false; } onChange([...value, normalized]); setInputValue(""); setError(null); setSuggestions([]); setIsOpen(false); setAnnouncement(`Added tag: ${normalized}`); return true; }, [value, onChange] ); // Remove a tag const removeTag = useCallback( (tag: string) => { onChange(value.filter((t) => t !== tag)); setAnnouncement(`Removed tag: ${tag}`); inputRef.current?.focus(); }, [value, onChange] ); // Keyboard navigation const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case "ArrowDown": e.preventDefault(); if (!isOpen && flatOptions.length > 0) { setIsOpen(true); } setActiveIndex((prev) => prev < flatOptions.length - 1 ? prev + 1 : prev ); break; case "ArrowUp": e.preventDefault(); setActiveIndex((prev) => (prev > 0 ? prev - 1 : -1)); break; case "Enter": e.preventDefault(); if (activeIndex >= 0 && flatOptions[activeIndex]) { addTag(flatOptions[activeIndex].tag); } else if (inputValue) { addTag(inputValue); } break; case "Escape": e.preventDefault(); if (isOpen) { setIsOpen(false); setActiveIndex(-1); } else { setInputValue(""); setError(null); } break; case "Backspace": if (inputValue === "" && value.length > 0) { e.preventDefault(); const lastTag = value.at(-1); if (lastTag) { removeTag(lastTag); } } break; } }, [isOpen, flatOptions, activeIndex, inputValue, value, addTag, removeTag] ); // Click outside to close useEffect(() => { const handleClickOutside = (e: MouseEvent) => { const target = e.target as Node; if (containerRef.current && !containerRef.current.contains(target)) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Scroll active option into view using getElementById useEffect(() => { if (activeIndex >= 0) { const optionEl = document.getElementById(getOptionId(activeIndex)); optionEl?.scrollIntoView({ block: "nearest" }); } }, [activeIndex, getOptionId]); // Track current group for header rendering let currentPrefix: string | null = null; return (
{/* Tag chips container with input */}
inputRef.current?.focus()} role="presentation" > {/* Tag chips - specimen label style */} {value.map((tag) => ( {tag} ))} {/* Input field */}
{value.length === 0 && ( )} = 0 ? getOptionId(activeIndex) : undefined } aria-autocomplete="list" aria-controls={isOpen ? listboxId : undefined} aria-expanded={isOpen} aria-haspopup="listbox" aria-invalid={!!error} aria-label={ariaLabel} autoComplete="off" className={cn( "flex-1 bg-transparent font-mono text-sm", "placeholder:text-muted-foreground/50", "outline-none", disabled && "cursor-not-allowed" )} disabled={disabled} onChange={(e) => { setInputValue(e.target.value); setError(null); }} onFocus={() => { // Fetch all tags on first focus for client-side filtering void fetchAllTags(); if (inputValue.length > 0 && flatOptions.length > 0) { setIsOpen(true); } }} onKeyDown={handleKeyDown} placeholder={value.length === 0 ? placeholder : ""} ref={inputRef} role="combobox" type="text" value={inputValue} /> {/* Loading indicator */} {isLoading && (
)}
{/* Error message */} {error && (

{error}

)} {/* Autocomplete dropdown - vintage index card drawer */} {isOpen && flatOptions.length > 0 && (
    {flatOptions.map((opt, idx) => { // Show group header when prefix changes const showHeader = opt.prefix && opt.prefix !== currentPrefix; currentPrefix = opt.prefix; return (
  • {/* Group header for hierarchical tags */} {showHeader && (
    {opt.prefix}/
    )} {/* Option */}
    addTag(opt.tag)} onMouseEnter={() => setActiveIndex(idx)} role="option" > {/* Tag name with hierarchy highlight */} {opt.prefix ? ( <> {opt.prefix}/ {opt.displayName} ) : ( {opt.tag} )} {/* Document count - brass pill */} {opt.count}
  • ); })}
)} {/* Screen reader live region */}
{announcement}
); }