diff --git a/src/components/shared/CategoryCombobox.tsx b/src/components/shared/CategoryCombobox.tsx new file mode 100644 index 0000000..e982363 --- /dev/null +++ b/src/components/shared/CategoryCombobox.tsx @@ -0,0 +1,189 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import type { Category } from "../../shared/types"; + +interface CategoryComboboxProps { + categories: Category[]; + value: number | null; + onChange: (id: number | null) => void; + placeholder?: string; + compact?: boolean; + /** 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; +} + +export default function CategoryCombobox({ + categories, + value, + onChange, + placeholder = "", + compact = false, + 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); + + // Build display label + const selectedCategory = categories.find((c) => c.id === value); + const displayLabel = + activeExtra != null + ? extraOptions?.find((o) => o.value === activeExtra)?.label ?? "" + : selectedCategory?.name ?? ""; + + // Filter categories + const lowerQuery = query.toLowerCase(); + const filtered = query + ? categories.filter((c) => c.name.toLowerCase().includes(lowerQuery)) + : categories; + + const filteredExtras = extraOptions + ? query + ? extraOptions.filter((o) => o.label.toLowerCase().includes(lowerQuery)) + : extraOptions + : []; + + const totalItems = filteredExtras.length + filtered.length; + + // Scroll highlighted item into view + useEffect(() => { + if (open && listRef.current) { + const el = listRef.current.children[highlightIndex] as HTMLElement | undefined; + el?.scrollIntoView({ block: "nearest" }); + } + }, [highlightIndex, open]); + + // Close on outside click + 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"; + + 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; + 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)]" + }`} + > + {cat.name} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/transactions/TransactionFilterBar.tsx b/src/components/transactions/TransactionFilterBar.tsx index 817346c..1fcad90 100644 --- a/src/components/transactions/TransactionFilterBar.tsx +++ b/src/components/transactions/TransactionFilterBar.tsx @@ -1,6 +1,8 @@ +import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Search } from "lucide-react"; import type { TransactionFilters, Category, ImportSource } from "../../shared/types"; +import CategoryCombobox from "../shared/CategoryCombobox"; interface TransactionFilterBarProps { filters: TransactionFilters; @@ -17,6 +19,14 @@ export default function TransactionFilterBar({ }: TransactionFilterBarProps) { const { t } = useTranslation(); + const categoryExtras = useMemo( + () => [ + { value: "", label: t("transactions.filters.allCategories") }, + { value: "uncategorized", label: t("transactions.filters.uncategorized") }, + ], + [t] + ); + const activeCount = [ filters.search, filters.categoryId !== null || filters.uncategorizedOnly, @@ -44,34 +54,34 @@ export default function TransactionFilterBar({ {/* Category */} - + onExtraSelect={(val) => { + if (val === "uncategorized") { + onFilterChange("uncategorizedOnly", true); + onFilterChange("categoryId", null); + } else { + onFilterChange("uncategorizedOnly", false); + onFilterChange("categoryId", null); + } + }} + /> + {/* Source */} - onCategoryChange( - row.id, - e.target.value ? Number(e.target.value) : null - ) - } - className="w-full px-2 py-1 text-sm rounded border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" - > - - {categories.map((c) => ( - - ))} - + onCategoryChange(row.id, id)} + placeholder={t("transactions.table.noCategory")} + compact + extraOptions={noCategoryExtra} + activeExtra={row.category_id === null ? "" : null} + onExtraSelect={() => onCategoryChange(row.id, null)} + />