import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react"; import type { Category } from "../../shared/types"; interface CategoryComboboxProps { categories: Category[]; value: number | null; onChange: (id: number | null) => void; placeholder?: string; compact?: boolean; ariaLabel?: string; /** Extra options shown before the category list (e.g. "All categories", "Uncategorized") */ extraOptions?: Array<{ value: string; label: string }>; /** Called when an extra option is selected */ onExtraSelect?: (value: string) => void; /** Currently active extra option value (for display) */ activeExtra?: string | null; } // Strip accents + lowercase for accent-insensitive matching function normalize(s: string): string { return s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); } // Compute depth of each category based on parent_id chain function computeDepths(categories: Category[]): Map { const byId = new Map(); for (const c of categories) byId.set(c.id, c); const depths = new Map(); function depthOf(id: number, seen: Set): number { if (depths.has(id)) return depths.get(id)!; if (seen.has(id)) return 0; seen.add(id); const cat = byId.get(id); if (!cat || cat.parent_id == null) { depths.set(id, 0); return 0; } const d = depthOf(cat.parent_id, seen) + 1; depths.set(id, d); return d; } for (const c of categories) depthOf(c.id, new Set()); return depths; } export default function CategoryCombobox({ categories, value, onChange, placeholder = "", compact = false, ariaLabel, extraOptions, onExtraSelect, activeExtra, }: CategoryComboboxProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [highlightIndex, setHighlightIndex] = useState(0); const inputRef = useRef(null); const listRef = useRef(null); const containerRef = useRef(null); const baseId = useId(); const listboxId = `${baseId}-listbox`; const optionId = (i: number) => `${baseId}-option-${i}`; const depths = useMemo(() => computeDepths(categories), [categories]); const selectedCategory = categories.find((c) => c.id === value); const displayLabel = activeExtra != null ? extraOptions?.find((o) => o.value === activeExtra)?.label ?? "" : selectedCategory?.name ?? ""; const normalizedQuery = normalize(query); const filtered = query ? categories.filter((c) => normalize(c.name).includes(normalizedQuery)) : categories; const filteredExtras = extraOptions ? query ? extraOptions.filter((o) => normalize(o.label).includes(normalizedQuery)) : extraOptions : []; const totalItems = filteredExtras.length + filtered.length; useEffect(() => { if (open && listRef.current) { const el = listRef.current.children[highlightIndex] as HTMLElement | undefined; el?.scrollIntoView({ block: "nearest" }); } }, [highlightIndex, open]); useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOpen(false); setQuery(""); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const selectItem = useCallback( (index: number) => { if (index < filteredExtras.length) { onExtraSelect?.(filteredExtras[index].value); } else { const cat = filtered[index - filteredExtras.length]; if (cat) onChange(cat.id); } setOpen(false); setQuery(""); inputRef.current?.blur(); }, [filteredExtras, filtered, onChange, onExtraSelect] ); const handleKeyDown = (e: React.KeyboardEvent) => { if (!open) { if (e.key === "ArrowDown" || e.key === "Enter") { e.preventDefault(); setOpen(true); setHighlightIndex(0); } return; } switch (e.key) { case "ArrowDown": e.preventDefault(); setHighlightIndex((i) => (i + 1) % totalItems); break; case "ArrowUp": e.preventDefault(); setHighlightIndex((i) => (i - 1 + totalItems) % totalItems); break; case "Enter": e.preventDefault(); if (totalItems > 0) selectItem(highlightIndex); break; case "Escape": e.preventDefault(); setOpen(false); setQuery(""); inputRef.current?.blur(); break; } }; const py = compact ? "py-1" : "py-2"; const px = compact ? "px-2" : "px-3"; const activeId = open && totalItems > 0 ? optionId(highlightIndex) : undefined; return (
{ setQuery(e.target.value); setHighlightIndex(0); if (!open) setOpen(true); }} onFocus={() => { setOpen(true); setQuery(""); setHighlightIndex(0); }} onKeyDown={handleKeyDown} className={`w-full ${px} ${py} text-sm rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] placeholder:text-[var(--muted-foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]`} /> {open && totalItems > 0 && (
    {filteredExtras.map((opt, i) => (
  • e.preventDefault()} onClick={() => selectItem(i)} onMouseEnter={() => setHighlightIndex(i)} className={`${px} ${py} text-sm cursor-pointer ${ i === highlightIndex ? "bg-[var(--primary)] text-white" : "text-[var(--foreground)] hover:bg-[var(--muted)]" }`} > {opt.label}
  • ))} {filtered.map((cat, i) => { const idx = filteredExtras.length + i; const depth = depths.get(cat.id) ?? 0; const indent = depth > 0 ? " ".repeat(depth) : ""; return (
  • e.preventDefault()} onClick={() => selectItem(idx)} onMouseEnter={() => setHighlightIndex(idx)} className={`${px} ${py} text-sm cursor-pointer ${ idx === highlightIndex ? "bg-[var(--primary)] text-white" : "text-[var(--foreground)] hover:bg-[var(--muted)]" }`} style={depth > 0 ? { paddingLeft: `calc(${compact ? "0.5rem" : "0.75rem"} + ${depth * 1}rem)` } : undefined} > {indent} {cat.name}
  • ); })}
)}
); }