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 [currentSearchString, setCurrentSearchString] = useState<string>("")
const changeSearchString = (newSearchString: string) => { const changeSearchString = (newSearchString: string) => {
console.log(newSearchString);
setCurrentSearchString(newSearchString); setCurrentSearchString(newSearchString);
onSearchStringChanged(newSearchString) onSearchStringChanged(newSearchString)
} }
@ -27,40 +26,28 @@ export default function SearchField({onSearchStringChanged, className = ""}: Sea
const iconStyle: string = "text-gray-400 hover:text-gray-500"; const iconStyle: string = "text-gray-400 hover:text-gray-500";
return ( return (
<div className={clsx( <div className={clsx("relative", className)}>
"relative",
className)}>
{/* Input of searchfield
Defines border and behavior. Requires extra padding at both sides to
accommodate the icons
*/}
<input <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" type="text"
placeholder="Search" placeholder="Suche"
value={currentSearchString} value={currentSearchString}
onChange={e => changeSearchString(e.target.value)} onChange={e => changeSearchString(e.target.value)}
/> />
{/* Right icon: X {/* Right icon: X — clears search string on click */}
Clears search string on click
*/}
<button <button
className={clsx( 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)} iconStyle)}
onClick={() => changeSearchString("")} onClick={() => changeSearchString("")}
> >
<X <X size={defaultIconSize}/>
size={defaultIconSize}
/>
</button> </button>
{/* Left icon: Looking glass */} {/* Left icon: looking glass */}
<div className={clsx( <div className={clsx(
"absolute left-0 inset-y-0 flex items-center ml-3", "absolute left-0 inset-y-0 flex items-center ml-3",
iconStyle)}> iconStyle)}>
<Search <Search size={defaultIconSize}/>
size={defaultIconSize}
/>
</div> </div>
</div> </div>
) )

View file

@ -26,7 +26,7 @@ export default function StickyHeader({children, className = ""}: StickyHeaderPro
return ( return (
<div <div
className={clsx( 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 className
)} )}
> >

View file

@ -2,6 +2,8 @@ import {useEffect, useState} from "react"
import RecipeListItem from "./RecipeListItem" import RecipeListItem from "./RecipeListItem"
import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts" import type {CompactRecipeDto} from "../../api/dtos/CompactRecipeDto.ts"
import {fetchRecipeList} from "../../api/endpoints/CompactRecipeRestResource.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 {useNavigate} from "react-router-dom"
import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes" import {getRecipeAddUrl, getRecipeDetailUrl, getRecipeListUrl} from "../../routes"
import RecipeListToolbar from "./RecipeListToolbar" import RecipeListToolbar from "./RecipeListToolbar"
@ -20,14 +22,27 @@ export default function RecipeListPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [recipeList, setRecipeList] = useState<CompactRecipeDto[] | null>(null) const [recipeList, setRecipeList] = useState<CompactRecipeDto[] | null>(null)
const [searchString, setSearchString] = useState<string>("") 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 // Load available tags once on mount to populate the filter dropdown
// avoid firing on every keystroke 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(() => { useEffect(() => {
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
try { try {
const data = await fetchRecipeList(searchString, tagIdList) const data = await fetchRecipeList(searchString, selectedTagIds)
setRecipeList(data) setRecipeList(data)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@ -35,10 +50,13 @@ export default function RecipeListPage() {
}, SEARCH_DEBOUNCE_MS) }, SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchString, tagIdList]) }, [searchString, selectedTagIds])
const handleAdd = () => { const tagFilterModel: FilterDropdownModel = {
navigate(getRecipeAddUrl()) placeholder: "Kategorie",
options: tagOptions,
selectedIds: selectedTagIds,
onSelectionChanged: setSelectedTagIds,
} }
if (!recipeList) { if (!recipeList) {
@ -54,9 +72,10 @@ export default function RecipeListPage() {
<StickyHeader> <StickyHeader>
<h1>Recipes</h1> <h1>Recipes</h1>
<RecipeListToolbar <RecipeListToolbar
onAddClicked={handleAdd} onAddClicked={() => navigate(getRecipeAddUrl())}
onSearchStringChanged={setSearchString} onSearchStringChanged={setSearchString}
numberOfRecipes={recipeList.length} numberOfRecipes={recipeList.length}
tagFilterModel={tagFilterModel}
/> />
</StickyHeader> </StickyHeader>
{/* Content - List of recipe cards */} {/* Content - List of recipe cards */}

View file

@ -1,36 +1,60 @@
import { ButtonType } from "../basics/BasicButtonDefinitions" import {ButtonType} from "../basics/BasicButtonDefinitions"
import Button from "../basics/Button" import Button from "../basics/Button"
import SearchField from "../basics/SearchField" 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 onSearchStringChanged: (searchString: string) => void
onAddClicked: () => 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 ( return (
<div className="flex flex-wrap items-center gap-2 w-full"> <div className="flex 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"> {/* Recipe count — left side, grows to push right-side group to the edge */}
<label className="label">{numberOfRecipes} Recipes</label> <div className="flex-1">
<label className="label">{numberOfRecipes} Rezepte</label>
</div> </div>
{/* Search + Add button container: right-aligned on medium+ screens */} {/* Right-side group: wraps internally so Tags falls under Search+Button
<div className="order-1 md:order-2 flex flex-1 md:flex-none justify-end gap-2 min-w-[160px]"> and inherits exactly the same width */}
<div className="flex-1 md:flex-none md:max-w-[500px]"> <div className="flex flex-wrap justify-end gap-2">
<SearchField onSearchStringChanged={onSearchStringChanged} />
{/* 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> </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 <Button
buttonType = {ButtonType.PrimaryButton} buttonType={ButtonType.PrimaryButton}
className="flex-shrink-0" className="flex-shrink-0"
onClick={onAddClicked} onClick={onAddClicked}
text = "Add recipe" text="Neues Rezept"
/> />
</div> </div>
</div>
</div> </div>
) )
} }