From 0804256b6ddbbe546c5c55c2b2cffea387e0e3eb Mon Sep 17 00:00:00 2001 From: araemer Date: Sat, 28 Feb 2026 07:59:33 +0100 Subject: [PATCH] Add filter component for tags to recipe list toolbar --- .../src/components/basics/FilterDropDown.tsx | 225 ++++++++++++++++++ .../src/components/basics/SearchField.tsx | 31 +-- .../src/components/basics/StickyHeader.tsx | 2 +- .../src/components/recipes/RecipeListPage.tsx | 35 ++- .../components/recipes/RecipeListToolbar.tsx | 64 +++-- 5 files changed, 306 insertions(+), 51 deletions(-) create mode 100644 frontend/src/components/basics/FilterDropDown.tsx diff --git a/frontend/src/components/basics/FilterDropDown.tsx b/frontend/src/components/basics/FilterDropDown.tsx new file mode 100644 index 0000000..ca62e18 --- /dev/null +++ b/frontend/src/components/basics/FilterDropDown.tsx @@ -0,0 +1,225 @@ +import {useEffect, useRef, useState} from "react" +import {Check, ChevronDown, Search, X} from "lucide-react" +import {defaultIconSize} from "./SvgIcon" +import clsx from "clsx" + +/** + * A single selectable option in the dropdown. + * Generic so the component can be reused for tags, categories, users, etc. + */ +export type FilterOption = { + id: string + label: string +} + +/** + * Self-contained model for a FilterDropdown instance. + * Pass this as a single prop to avoid threading multiple filter-related + * props through intermediate components. + */ +export type FilterDropdownModel = { + /** Label shown on the trigger button when nothing is selected, e.g. "Tags" */ + placeholder: string + /** Full list of available options */ + options: FilterOption[] + /** Currently selected option IDs */ + selectedIds: string[] + /** Called whenever the selection changes */ + onSelectionChanged: (selectedIds: string[]) => void +} + +type FilterDropdownProps = { + model: FilterDropdownModel + /** Optional additional Tailwind classes for the root element */ + className?: string +} + +/** + * A generic multi-select filter dropdown styled similarly to Jira's label filter. + * + * - Closed state: shows placeholder, single selected label, or "Multiple (n)" + * - Open state: search field + scrollable checkbox list + deselect-all footer + * - Closes on outside click or Escape + */ +export default function FilterDropdown({model, className = ""}: FilterDropdownProps) { + const {placeholder, options, selectedIds, onSelectionChanged} = model + + const [isOpen, setIsOpen] = useState(false) + const [filterText, setFilterText] = useState("") + const containerRef = useRef(null) + const searchRef = useRef(null) + + // Close on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + closeDropdown() + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + // Focus search input when opening + useEffect(() => { + if (isOpen) { + setTimeout(() => searchRef.current?.focus(), 0) + } + }, [isOpen]) + + const closeDropdown = () => { + setIsOpen(false) + setFilterText("") + } + + const toggleOpen = () => { + if (isOpen) { + closeDropdown() + } else { + setIsOpen(true) + } + } + + const toggleOption = (id: string) => { + if (selectedIds.includes(id)) { + onSelectionChanged(selectedIds.filter(sid => sid !== id)) + } else { + onSelectionChanged([...selectedIds, id]) + } + } + + const deselectAll = () => onSelectionChanged([]) + + // Filter options by search text + const visibleOptions = options.filter(opt => + opt.label.toLowerCase().includes(filterText.toLowerCase()) + ) + + // Derive button label + const buttonLabel = (() => { + if (selectedIds.length === 0) return placeholder + if (selectedIds.length === 1) { + const match = options.find(o => o.id === selectedIds[0]) + return match?.label ?? placeholder + } + return `Multiple (${selectedIds.length})` + })() + + const hasSelection = selectedIds.length > 0 + + return ( +
+ {/* Trigger button */} + + + {/* Dropdown panel */} + {isOpen && ( +
+ {/* Search field inside dropdown */} +
+ setFilterText(e.target.value)} + onKeyDown={e => e.key === "Escape" && closeDropdown()} + className="w-full pl-8 pr-8 py-1.5 text-sm border border-gray-200 rounded focus:outline-none focus:ring-2 focus:ring-blue-300" + /> + + {filterText && ( + + )} +
+ + {/* Options list */} +
    + {visibleOptions.length === 0 ? ( +
  • + No results +
  • + ) : ( + visibleOptions.map(option => { + const isSelected = selectedIds.includes(option.id) + return ( +
  • + +
  • + ) + }) + )} +
+ + {/* Footer: deselect all */} + {hasSelection && ( +
+ +
+ )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/basics/SearchField.tsx b/frontend/src/components/basics/SearchField.tsx index f66e54b..03f4628 100644 --- a/frontend/src/components/basics/SearchField.tsx +++ b/frontend/src/components/basics/SearchField.tsx @@ -19,7 +19,6 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea const [currentSearchString, setCurrentSearchString] = useState("") const changeSearchString = (newSearchString: string) => { - console.log(newSearchString); setCurrentSearchString(newSearchString); onSearchStringChanged(newSearchString) } @@ -27,41 +26,29 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea const iconStyle: string = "text-gray-400 hover:text-gray-500"; return ( -
- {/* Input of searchfield - Defines border and behavior. Requires extra padding at both sides to - accommodate the icons - */} +
changeSearchString(e.target.value)} /> - {/* Right icon: X - Clears search string on click - */} + {/* Right icon: X — clears search string on click */} - {/* Left icon: Looking glass */} + {/* Left icon: looking glass */}
- +
) -} +} \ No newline at end of file diff --git a/frontend/src/components/basics/StickyHeader.tsx b/frontend/src/components/basics/StickyHeader.tsx index 39d6536..f73f63d 100644 --- a/frontend/src/components/basics/StickyHeader.tsx +++ b/frontend/src/components/basics/StickyHeader.tsx @@ -26,7 +26,7 @@ export default function StickyHeader({children, className = ""}: StickyHeaderPro return (
diff --git a/frontend/src/components/recipes/RecipeListPage.tsx b/frontend/src/components/recipes/RecipeListPage.tsx index dc4359c..166969b 100644 --- a/frontend/src/components/recipes/RecipeListPage.tsx +++ b/frontend/src/components/recipes/RecipeListPage.tsx @@ -2,6 +2,8 @@ import {useEffect, useState} from "react" import RecipeListItem from "./RecipeListItem" import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts" import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.ts" +import {fetchAllTags} from "../../api/endpoints/TagRestResource.ts" +import type {FilterDropdownModel, FilterOption} from "../basics/FilterDropDown.tsx" import {useNavigate} from "react-router-dom" import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes" import RecipeListToolbar from "./RecipeListToolbar" @@ -20,14 +22,27 @@ export default function RecipeListPage() { const navigate = useNavigate() const [recipeList, setRecipeList] = useState(null) const [searchString, setSearchString] = useState("") - const [tagIdList, setTagIdList] = useState([]) + const [tagOptions, setTagOptions] = useState([]) + const [selectedTagIds, setSelectedTagIds] = useState([]) - // Reload list whenever search string or tag filter changes, debounced to - // avoid firing on every keystroke + // Load available tags once on mount to populate the filter dropdown + useEffect(() => { + fetchAllTags() + .then(dtos => setTagOptions( + dtos + // ensure that all tags indeed have ids as this is required by the FilterOption + .filter((dto): dto is typeof dto & { id: string } => dto.id !== undefined) + // map to filter option + .map(dto => ({id: dto.id, label: dto.description})) + )) + .catch(console.error) + }, []) + + // Reload recipe list whenever search string or tag selection changes, debounced useEffect(() => { const timeout = setTimeout(async () => { try { - const data = await fetchRecipeList(searchString, tagIdList) + const data = await fetchRecipeList(searchString, selectedTagIds) setRecipeList(data) } catch (err) { console.error(err) @@ -35,10 +50,13 @@ export default function RecipeListPage() { }, SEARCH_DEBOUNCE_MS) return () => clearTimeout(timeout) - }, [searchString, tagIdList]) + }, [searchString, selectedTagIds]) - const handleAdd = () => { - navigate(getRecipeAddUrl()) + const tagFilterModel: FilterDropdownModel = { + placeholder: "Kategorie", + options: tagOptions, + selectedIds: selectedTagIds, + onSelectionChanged: setSelectedTagIds, } if (!recipeList) { @@ -54,9 +72,10 @@ export default function RecipeListPage() {

Recipes

navigate(getRecipeAddUrl())} onSearchStringChanged={setSearchString} numberOfRecipes={recipeList.length} + tagFilterModel={tagFilterModel} />
{/* Content - List of recipe cards */} diff --git a/frontend/src/components/recipes/RecipeListToolbar.tsx b/frontend/src/components/recipes/RecipeListToolbar.tsx index 2622b0b..57d382d 100644 --- a/frontend/src/components/recipes/RecipeListToolbar.tsx +++ b/frontend/src/components/recipes/RecipeListToolbar.tsx @@ -1,35 +1,59 @@ -import { ButtonType } from "../basics/BasicButtonDefinitions" +import {ButtonType} from "../basics/BasicButtonDefinitions" import Button from "../basics/Button" import SearchField from "../basics/SearchField" +import FilterDropdown, {type FilterDropdownModel} from "../basics/FilterDropDown" /** - * Toolbar for RecipeListPage containing searchfield, add recipe button and number of recipes + * Toolbar for RecipeListPage containing tag filter, search field, add button and recipe count. + * + * Single-line (wide): [12 Recipes] [Tags ˅] [Search ×] [Add recipe] + * Two-line (narrow): [12 Recipes] [Search ×] [Add recipe] + * [Tags ˅ — same width as above] */ -type RecepeListToolbarProps = { - onSearchStringChanged: (searchString : string) => void +type RecipeListToolbarProps = { + onSearchStringChanged: (searchString: string) => void onAddClicked: () => void - numberOfRecipes : number + numberOfRecipes: number + tagFilterModel: FilterDropdownModel } -export default function RecipeListToolbar({onSearchStringChanged, onAddClicked, numberOfRecipes} : RecepeListToolbarProps){ +export default function RecipeListToolbar({ + onSearchStringChanged, + onAddClicked, + numberOfRecipes, + tagFilterModel, + }: RecipeListToolbarProps) { return ( -
- {/* Label: left-aligned on medium+ screens, full-width on small screens */} -
- +
+ + {/* Recipe count — left side, grows to push right-side group to the edge */} +
+
- {/* Search + Add button container: right-aligned on medium+ screens */} -
-
- + {/* Right-side group: wraps internally so Tags falls under Search+Button + and inherits exactly the same width */} +
+ + {/* Tags — order-2 so it wraps to row 2 below Search+Button */} +
+
-
+
)