shinkan-jinkendo/frontend/src/components/MultiSelectCombo.jsx

184 lines
5.1 KiB
React
Raw Normal View History

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>
)
}