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 (
{value.map((id, idx) => ( ))}
{ setQuery(e.target.value) setOpen(true) setBrowseAll(false) }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} autoComplete="off" aria-autocomplete="list" aria-expanded={open} />
{open && ( )}
) }