shinkan-jinkendo/frontend/src/components/MultiSelectCombo.jsx
Lars fd2009294b
Some checks failed
Deploy Development / deploy (push) Successful in 32s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m54s
feat: enhance exercise filtering and UI components
- Introduced multi-select functionality for filtering exercises by focus areas, style directions, training types, target groups, and skills, allowing users to select multiple options.
- Updated the ExercisesListPage to utilize the new multi-select component, improving the user experience for filtering exercises.
- Enhanced backend filtering logic to support new array-based query parameters, ensuring efficient handling of multiple filter criteria.
- Adjusted CSS styles for better layout and usability of the exercise filters.
2026-04-28 08:02:08 +02:00

184 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useMemo, useState, useRef, useEffect, useCallback } from 'react'
function normId(id) {
return String(id)
}
/**
* Mehrfachauswahl mit Typahead-Vorschlägen und optionaler Vollliste (Dropdown).
* Auswahl mehrerer Einträge wird als ODER interpretiert (Aufrufer/API).
*/
export default function MultiSelectCombo({
value = [],
onChange,
options = [],
placeholder = 'Tippen und Eintrag wählen…',
browseLabel = '▼ Alle',
emptyHint = 'Keine Treffer',
idKey = 'id',
labelKey = 'label',
className = '',
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [browseAll, setBrowseAll] = useState(false)
const [highlight, setHighlight] = useState(0)
const rootRef = useRef(null)
const rows = useMemo(() => {
return options.map((o) => ({
id: o[idKey],
label: typeof o[labelKey] === 'function' ? o[labelKey](o) : String(o[labelKey] ?? ''),
}))
}, [options, idKey, labelKey])
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
const selectedLabels = useMemo(() => {
return value.map((id) => {
const r = rows.find((x) => normId(x.id) === normId(id))
return r ? r.label : `#${id}`
})
}, [value, rows])
const suggestions = useMemo(() => {
const avail = rows.filter((r) => !selectedSet.has(normId(r.id)))
if (browseAll || !query.trim()) return avail
const q = query.trim().toLowerCase()
return avail.filter((r) => r.label.toLowerCase().includes(q) || normId(r.id).includes(q))
}, [rows, selectedSet, query, browseAll])
useEffect(() => {
setHighlight(0)
}, [suggestions.length, query, browseAll])
const addId = useCallback(
(id) => {
const sid = normId(id)
if (selectedSet.has(sid)) return
onChange([...value, id])
setQuery('')
setBrowseAll(false)
},
[value, onChange, selectedSet]
)
const removeAt = useCallback(
(idx) => {
const next = value.filter((_, i) => i !== idx)
onChange(next)
},
[value, onChange]
)
useEffect(() => {
const onDoc = (e) => {
if (!rootRef.current?.contains(e.target)) {
setOpen(false)
setBrowseAll(false)
}
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const onKeyDown = (e) => {
if (!open && (e.key === 'ArrowDown' || e.key === 'Enter')) {
setOpen(true)
return
}
if (!open) return
if (e.key === 'Escape') {
setOpen(false)
setBrowseAll(false)
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setHighlight((h) => Math.min(h + 1, Math.max(0, suggestions.length - 1)))
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setHighlight((h) => Math.max(0, h - 1))
}
if (e.key === 'Enter' && suggestions[highlight]) {
e.preventDefault()
addId(suggestions[highlight].id)
}
}
return (
<div className={`multiselect-combo ${className}`} ref={rootRef}>
<div className="multiselect-combo__chips">
{value.map((id, idx) => (
<button
key={`${normId(id)}-${idx}`}
type="button"
className="multiselect-combo__chip"
onClick={() => removeAt(idx)}
title="Entfernen"
>
<span>{selectedLabels[idx]}</span>
<span className="multiselect-combo__chip-x" aria-hidden>
×
</span>
</button>
))}
</div>
<div className="multiselect-combo__field">
<input
type="text"
className="form-input multiselect-combo__input"
placeholder={placeholder}
value={query}
onChange={(e) => {
setQuery(e.target.value)
setOpen(true)
setBrowseAll(false)
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
autoComplete="off"
aria-autocomplete="list"
aria-expanded={open}
/>
<button
type="button"
className="btn multiselect-combo__browse"
title="Alle Einträge anzeigen"
onClick={() => {
setOpen(true)
setBrowseAll(true)
setQuery('')
}}
>
{browseLabel}
</button>
</div>
{open && (
<ul className="multiselect-combo__list" role="listbox">
{suggestions.length === 0 ? (
<li className="multiselect-combo__empty">{emptyHint}</li>
) : (
suggestions.map((r, i) => (
<li key={normId(r.id)}>
<button
type="button"
role="option"
aria-selected={i === highlight}
className={`multiselect-combo__opt${i === highlight ? ' multiselect-combo__opt--hi' : ''}`}
onMouseEnter={() => setHighlight(i)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => addId(r.id)}
>
{r.label}
</button>
</li>
))
)}
</ul>
)}
</div>
)
}