- 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.
184 lines
5.1 KiB
JavaScript
184 lines
5.1 KiB
JavaScript
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>
|
||
)
|
||
}
|