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

155 lines
4.8 KiB
React
Raw Normal View History

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
import SkillTreePickerPanel from './SkillTreePickerPanel'
function normId(id) {
return String(id)
}
/**
* Mehrfachauswahl Fähigkeiten mit hierarchischer Treeview („Alle“) und Pfad-Suche.
*/
export default function SkillTreeMultiSelect({
value = [],
onChange,
skills = [],
placeholder = 'Fähigkeit suchen …',
browseLabel = '▼ Katalog',
emptyHint = 'Keine Treffer',
className = '',
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [browseTree, setBrowseTree] = useState(false)
const rootRef = useRef(null)
const tree = useMemo(() => buildSkillCatalogTree(skills), [skills])
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
const leaves = useMemo(() => collectSkillLeavesFromTree(tree, value), [tree, value])
const selectedLabels = useMemo(() => {
return value.map((id) => {
const leaf = leaves.find((l) => normId(l.id) === normId(id)) || leaves.find(() => false)
const fromSkills = skills.find((s) => normId(s.id) === normId(id))
return leaf?.pathLabel || fromSkills?.name || `#${id}`
})
}, [value, leaves, skills])
const addId = useCallback(
(id) => {
const sid = normId(id)
if (selectedSet.has(sid)) return
onChange([...value, id])
setQuery('')
setBrowseTree(false)
},
[value, onChange, selectedSet]
)
const removeAt = useCallback(
(idx) => {
onChange(value.filter((_, i) => i !== idx))
},
[value, onChange]
)
useEffect(() => {
const onDoc = (e) => {
if (!rootRef.current?.contains(e.target)) {
setOpen(false)
setBrowseTree(false)
}
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const showTree = browseTree || !query.trim()
return (
<div className={`multiselect-combo skill-tree-multiselect ${className}`.trim()} 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)
setBrowseTree(false)
}}
onFocus={() => setOpen(true)}
autoComplete="off"
aria-expanded={open}
/>
<button
type="button"
className="btn multiselect-combo__browse"
title="Katalog als Baum"
onClick={() => {
setOpen(true)
setBrowseTree(true)
setQuery('')
}}
>
{browseLabel}
</button>
</div>
{open ? (
<div className="skill-tree-multiselect__panel">
{showTree ? (
<SkillTreePickerPanel
skills={skills}
excludeIds={value}
searchQuery={query}
onPickSkill={(id) => addId(id)}
pickMode="multi"
/>
) : (
<ul className="multiselect-combo__list" role="listbox">
{leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length ===
0 ? (
<li className="multiselect-combo__empty">{emptyHint}</li>
) : (
leaves
.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
.map((l) => (
<li key={normId(l.id)}>
<button
type="button"
className="multiselect-combo__opt skill-tree__pick--path"
onMouseDown={(e) => e.preventDefault()}
onClick={() => addId(l.id)}
>
<span className="skill-tree__pick-name">{l.label}</span>
<span className="skill-tree__pick-path">{l.pathLabel}</span>
</button>
</li>
))
)}
</ul>
)}
</div>
) : null}
</div>
)
}