From 9e1a875581da421c3632bab5adf4d8c16b97a2ba Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 14:07:34 +0100 Subject: [PATCH] feat(08-02): create CategoryFilterDropdown component - Searchable dropdown with Lucide icons per category option - "All categories" as first option with null value - Click-outside and Escape key dismissal - Clear button on trigger when category selected - Auto-focus search input when dropdown opens - State reset (search text) when dropdown closes --- .../components/CategoryFilterDropdown.tsx | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 src/client/components/CategoryFilterDropdown.tsx diff --git a/src/client/components/CategoryFilterDropdown.tsx b/src/client/components/CategoryFilterDropdown.tsx new file mode 100644 index 0000000..b2bd1ee --- /dev/null +++ b/src/client/components/CategoryFilterDropdown.tsx @@ -0,0 +1,198 @@ +import { useEffect, useRef, useState } from "react"; +import { LucideIcon } from "../lib/iconData"; + +interface CategoryFilterDropdownProps { + value: number | null; + onChange: (value: number | null) => void; + categories: Array<{ id: number; name: string; icon: string }>; +} + +export function CategoryFilterDropdown({ + value, + onChange, + categories, +}: CategoryFilterDropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const [searchText, setSearchText] = useState(""); + const containerRef = useRef(null); + const searchInputRef = useRef(null); + + const selectedCategory = categories.find((c) => c.id === value); + + const filteredCategories = categories.filter((c) => + c.name.toLowerCase().includes(searchText.toLowerCase()), + ); + + // Click-outside dismiss + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + setSearchText(""); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Escape key dismiss + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" && isOpen) { + setIsOpen(false); + setSearchText(""); + } + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); + + // Auto-focus search input when dropdown opens + useEffect(() => { + if (isOpen && searchInputRef.current) { + searchInputRef.current.focus(); + } + }, [isOpen]); + + function handleSelect(categoryId: number | null) { + onChange(categoryId); + setIsOpen(false); + setSearchText(""); + } + + return ( +
+ {/* Trigger button */} + + ) : ( + + + + )} + + + {/* Dropdown panel */} + {isOpen && ( +
+ {/* Search input */} +
+ setSearchText(e.target.value)} + className="w-full px-2.5 py-1.5 border border-gray-200 rounded text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + /> +
+ + {/* Option list */} +
    + {/* All categories option */} + {(searchText === "" || + "all categories".includes(searchText.toLowerCase())) && ( +
  • + +
  • + )} + + {/* Category options */} + {filteredCategories.map((cat) => ( +
  • + +
  • + ))} + + {/* No results */} + {filteredCategories.length === 0 && + !( + searchText === "" || + "all categories".includes(searchText.toLowerCase()) + ) && ( +
  • + No categories found +
  • + )} +
+
+ )} +
+ ); +}