import { useState, useRef, useEffect, useCallback, useId, useMemo } from "react"; import { useTranslation } from "react-i18next"; 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; } /** * Order a flat list of categories in hierarchical DFS order: each root is * emitted immediately followed by its descendants (depth-first, parent before * children). Siblings within a group are ordered by `sort_order` ascending, * then by `resolveName(cat)` for stable tiebreaking. * * A plain `ORDER BY sort_order, name` in SQL mixes parents and children from * different sub-trees that happen to share the same `sort_order`, producing * the scrambled indentation we saw in the by-category report combobox. * Doing the DFS client-side keeps rendering correct regardless of query shape. * * Orphans (category whose parent is missing or inactive / filtered out) are * emitted at the end, each treated as a pseudo-root, so nothing disappears. */ export function sortHierarchical( categories: Category[], resolveName: (cat: Category) => string, ): Category[] { if (categories.length === 0) return []; const ids = new Set(); for (const c of categories) ids.add(c.id); // Group by parent bucket: root (`null`) or parent id. const childrenByParent = new Map(); const orphans: Category[] = []; for (const c of categories) { if (c.parent_id == null) { const bucket = childrenByParent.get(null) ?? []; bucket.push(c); childrenByParent.set(null, bucket); } else if (ids.has(c.parent_id)) { const bucket = childrenByParent.get(c.parent_id) ?? []; bucket.push(c); childrenByParent.set(c.parent_id, bucket); } else { orphans.push(c); } } const compare = (a: Category, b: Category) => { if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order; return resolveName(a).localeCompare(resolveName(b)); }; for (const bucket of childrenByParent.values()) bucket.sort(compare); orphans.sort(compare); const out: Category[] = []; const visited = new Set(); const visit = (cat: Category) => { if (visited.has(cat.id)) return; // defensive against cycles visited.add(cat.id); out.push(cat); const kids = childrenByParent.get(cat.id); if (kids) for (const child of kids) visit(child); }; const roots = childrenByParent.get(null) ?? []; for (const root of roots) visit(root); // Append orphans last, still treated as pseudo-roots so their own children // (if any were pulled in) follow them. for (const orphan of orphans) visit(orphan); return out; } export default function CategoryCombobox({ categories, value, onChange, placeholder = "", compact = false, ariaLabel, extraOptions, onExtraSelect, activeExtra, }: CategoryComboboxProps) { const { t } = useTranslation(); 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]); // Resolve the display name for a category: seed rows carry an i18n_key that // we translate with name as defaultValue; user-created rows just use name. const displayName = useCallback( (c: Category) => (c.i18n_key ? t(c.i18n_key, { defaultValue: c.name }) : c.name), [t] ); // Re-order the (potentially sort_order-globally-sorted) input into proper // hierarchical DFS order so parents always precede their children and // siblings stay grouped under the same ancestor. const orderedCategories = useMemo( () => sortHierarchical(categories, displayName), [categories, displayName], ); const selectedCategory = orderedCategories.find((c) => c.id === value); const displayLabel = activeExtra != null ? extraOptions?.find((o) => o.value === activeExtra)?.label ?? "" : selectedCategory ? displayName(selectedCategory) : ""; const normalizedQuery = normalize(query); const filtered = query ? orderedCategories.filter((c) => normalize(displayName(c)).includes(normalizedQuery)) : orderedCategories; 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} {displayName(cat)}
  • ); })}
)}
); }