Add filter component for tags to recipe list toolbar

This commit is contained in:
araemer 2026-02-28 07:59:33 +01:00
parent 79d62b140e
commit 0804256b6d
5 changed files with 306 additions and 51 deletions

View file

@ -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<HTMLDivElement>(null)
const searchRef = useRef<HTMLInputElement>(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 (
<div ref={containerRef} className={clsx("relative", className)}>
{/* Trigger button */}
<button
type="button"
onClick={toggleOpen}
className={clsx(
"flex items-center justify-between gap-2 w-full",
"border border-gray-300 rounded px-3 py-2 bg-white",
"text-sm text-left leading-tight",
"hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-300 transition-colors",
hasSelection && "border-blue-400 bg-blue-50 text-blue-800",
isOpen && "border-blue-400 ring-2 ring-blue-300"
)}
>
<span className={clsx("truncate", !hasSelection && "text-gray-400")}>
{buttonLabel}
</span>
<ChevronDown
size={defaultIconSize}
className={clsx(
"flex-shrink-0 text-gray-400 transition-transform duration-150",
isOpen && "rotate-180"
)}
/>
</button>
{/* Dropdown panel */}
{isOpen && (
<div className={clsx(
"absolute z-50 mt-1 w-full min-w-[220px]",
"bg-white border border-gray-200 rounded shadow-lg",
"flex flex-col"
)}>
{/* Search field inside dropdown */}
<div className="relative p-2 border-b border-gray-100">
<input
ref={searchRef}
type="text"
placeholder="Search…"
value={filterText}
onChange={e => 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"
/>
<Search
size={14}
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none"
/>
{filterText && (
<button
type="button"
onClick={() => setFilterText("")}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<X size={14}/>
</button>
)}
</div>
{/* Options list */}
<ul className="overflow-y-auto max-h-52">
{visibleOptions.length === 0 ? (
<li className="px-3 py-2 text-sm text-gray-400 italic">
No results
</li>
) : (
visibleOptions.map(option => {
const isSelected = selectedIds.includes(option.id)
return (
<li key={option.id}>
<button
type="button"
onClick={() => toggleOption(option.id)}
className={clsx(
"flex items-center gap-2 w-full px-3 py-2 text-sm text-left",
"hover:bg-blue-50 transition-colors",
isSelected && "bg-blue-50 text-blue-800"
)}
>
<span className={clsx(
"flex-shrink-0 w-4 h-4 rounded border flex items-center justify-center transition-colors",
isSelected
? "bg-blue-500 border-blue-500"
: "bg-white border-gray-300"
)}>
{isSelected && (
<Check size={11} className="text-white" strokeWidth={3}/>
)}
</span>
<span className="truncate">{option.label}</span>
</button>
</li>
)
})
)}
</ul>
{/* Footer: deselect all */}
{hasSelection && (
<div className="border-t border-gray-100 p-2">
<button
type="button"
onClick={deselectAll}
className="flex items-center gap-1.5 text-xs text-gray-500 hover:text-red-500 transition-colors w-full px-1 py-0.5"
>
<X size={12}/>
Deselect all
</button>
</div>
)}
</div>
)}
</div>
)
}

View file

@ -19,7 +19,6 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea
const [currentSearchString, setCurrentSearchString] = useState<string>("")
const changeSearchString = (newSearchString: string) => {
console.log(newSearchString);
setCurrentSearchString(newSearchString);
onSearchStringChanged(newSearchString)
}
@ -27,40 +26,28 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea
const iconStyle: string = "text-gray-400 hover:text-gray-500";
return (
<div className={clsx(
"relative",
className)}>
{/* Input of searchfield
Defines border and behavior. Requires extra padding at both sides to
accommodate the icons
*/}
<div className={clsx("relative", className)}>
<input
className="pl-10 pr-10"
className="w-full pl-10 pr-10 py-2 text-sm border border-gray-300 rounded bg-white leading-tight focus:outline-none focus:ring-2 focus:ring-blue-300 focus:border-blue-400 transition-colors"
type="text"
placeholder="Search"
placeholder="Suche"
value={currentSearchString}
onChange={e => changeSearchString(e.target.value)}
/>
{/* Right icon: X
Clears search string on click
*/}
{/* Right icon: X — clears search string on click */}
<button
className={clsx(
"absolute right-0 inset-y-0 flex items-center -ml-1 mr-3",
"absolute right-0 inset-y-0 flex items-center mr-3",
iconStyle)}
onClick={() => changeSearchString("")}
>
<X
size={defaultIconSize}
/>
<X size={defaultIconSize}/>
</button>
{/* Left icon: Looking glass */}
{/* Left icon: looking glass */}
<div className={clsx(
"absolute left-0 inset-y-0 flex items-center ml-3",
iconStyle)}>
<Search
size={defaultIconSize}
/>
<Search size={defaultIconSize}/>
</div>
</div>
)

View file

@ -26,7 +26,7 @@ export default function StickyHeader({children, className = ""}: StickyHeaderPro
return (
<div
className={clsx(
"sticky top-0 left-0 right-0 bg-gray-100 pb-6 border-b-2 border-gray-300 z-10",
"sticky top-0 left-0 right-0 bg-gray-100 pb-4 border-b-2 border-gray-300 z-10",
className
)}
>

View file

@ -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<CompactRecipeDto[] | null>(null)
const [searchString, setSearchString] = useState<string>("")
const [tagIdList, setTagIdList] = useState<string[]>([])
const [tagOptions, setTagOptions] = useState<FilterOption[]>([])
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])
// 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() {
<StickyHeader>
<h1>Recipes</h1>
<RecipeListToolbar
onAddClicked={handleAdd}
onAddClicked={() => navigate(getRecipeAddUrl())}
onSearchStringChanged={setSearchString}
numberOfRecipes={recipeList.length}
tagFilterModel={tagFilterModel}
/>
</StickyHeader>
{/* Content - List of recipe cards */}

View file

@ -1,36 +1,60 @@
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 = {
type RecipeListToolbarProps = {
onSearchStringChanged: (searchString: string) => void
onAddClicked: () => void
numberOfRecipes: number
tagFilterModel: FilterDropdownModel
}
export default function RecipeListToolbar({onSearchStringChanged, onAddClicked, numberOfRecipes} : RecepeListToolbarProps){
export default function RecipeListToolbar({
onSearchStringChanged,
onAddClicked,
numberOfRecipes,
tagFilterModel,
}: RecipeListToolbarProps) {
return (
<div className="flex flex-wrap items-center gap-2 w-full">
{/* Label: left-aligned on medium+ screens, full-width on small screens */}
<div className="order-2 md:order-1 w-full md:w-auto md:flex-1">
<label className="label">{numberOfRecipes} Recipes</label>
<div className="flex items-center gap-2 w-full">
{/* Recipe count — left side, grows to push right-side group to the edge */}
<div className="flex-1">
<label className="label">{numberOfRecipes} Rezepte</label>
</div>
{/* Search + Add button container: right-aligned on medium+ screens */}
<div className="order-1 md:order-2 flex flex-1 md:flex-none justify-end gap-2 min-w-[160px]">
<div className="flex-1 md:flex-none md:max-w-[500px]">
<SearchField onSearchStringChanged={onSearchStringChanged} />
{/* Right-side group: wraps internally so Tags falls under Search+Button
and inherits exactly the same width */}
<div className="flex flex-wrap justify-end gap-2">
{/* Tags — order-2 so it wraps to row 2 below Search+Button */}
<div className="order-2 w-full md:order-1 md:w-auto md:min-w-[100px] md:max-w-[200px]">
<FilterDropdown model={tagFilterModel} className="w-full"/>
</div>
{/* Search + Button — order-1 so they stay on row 1 when wrapping */}
<div className="order-1 md:order-2 flex gap-2 flex-shrink-0">
<SearchField
onSearchStringChanged={onSearchStringChanged}
className="w-[260px]"
/>
<Button
buttonType={ButtonType.PrimaryButton}
className="flex-shrink-0"
onClick={onAddClicked}
text = "Add recipe"
text="Neues Rezept"
/>
</div>
</div>
</div>
)
}