2026-05-21 10:31:21 +02:00
|
|
|
|
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
|
|
|
|
import { createPortal } from 'react-dom'
|
2026-05-20 10:59:17 +02:00
|
|
|
|
import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
|
|
|
|
|
|
import SkillTreePickerPanel from './SkillTreePickerPanel'
|
|
|
|
|
|
|
2026-05-21 10:31:21 +02:00
|
|
|
|
const PANEL_MAX_HEIGHT = 360
|
|
|
|
|
|
const PANEL_MIN_HEIGHT = 140
|
|
|
|
|
|
const PANEL_Z_INDEX = 1100
|
|
|
|
|
|
|
2026-05-20 10:59:17 +02:00
|
|
|
|
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 = '',
|
2026-05-21 10:31:21 +02:00
|
|
|
|
usePortal = true,
|
2026-05-20 10:59:17 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
const [query, setQuery] = useState('')
|
|
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
|
|
const [browseTree, setBrowseTree] = useState(false)
|
2026-05-21 10:31:21 +02:00
|
|
|
|
const [panelStyle, setPanelStyle] = useState(null)
|
2026-05-20 10:59:17 +02:00
|
|
|
|
const rootRef = useRef(null)
|
2026-05-21 10:31:21 +02:00
|
|
|
|
const fieldRef = useRef(null)
|
|
|
|
|
|
const panelRef = useRef(null)
|
2026-05-20 10:59:17 +02:00
|
|
|
|
|
|
|
|
|
|
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) => {
|
2026-05-21 10:31:21 +02:00
|
|
|
|
const t = e.target
|
|
|
|
|
|
if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
|
|
|
|
|
|
setOpen(false)
|
|
|
|
|
|
setBrowseTree(false)
|
2026-05-20 10:59:17 +02:00
|
|
|
|
}
|
|
|
|
|
|
document.addEventListener('mousedown', onDoc)
|
|
|
|
|
|
return () => document.removeEventListener('mousedown', onDoc)
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
2026-05-21 10:31:21 +02:00
|
|
|
|
const updatePanelPosition = useCallback(() => {
|
|
|
|
|
|
const anchor = fieldRef.current
|
|
|
|
|
|
if (!anchor) return
|
|
|
|
|
|
const rect = anchor.getBoundingClientRect()
|
|
|
|
|
|
const gap = 4
|
|
|
|
|
|
const margin = 12
|
|
|
|
|
|
const spaceBelow = window.innerHeight - rect.bottom - gap - margin
|
|
|
|
|
|
const spaceAbove = rect.top - gap - margin
|
|
|
|
|
|
const openUp = spaceBelow < PANEL_MIN_HEIGHT && spaceAbove > spaceBelow
|
|
|
|
|
|
const available = Math.max(0, openUp ? spaceAbove : spaceBelow)
|
|
|
|
|
|
const maxHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(PANEL_MIN_HEIGHT, available))
|
|
|
|
|
|
const top = openUp ? Math.max(margin, rect.top - gap - maxHeight) : rect.bottom + gap
|
|
|
|
|
|
|
|
|
|
|
|
setPanelStyle({
|
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
|
left: rect.left,
|
|
|
|
|
|
width: rect.width,
|
|
|
|
|
|
top,
|
|
|
|
|
|
maxHeight,
|
|
|
|
|
|
zIndex: PANEL_Z_INDEX,
|
|
|
|
|
|
})
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
useLayoutEffect(() => {
|
|
|
|
|
|
if (!open || !usePortal) {
|
|
|
|
|
|
setPanelStyle(null)
|
|
|
|
|
|
return undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
updatePanelPosition()
|
|
|
|
|
|
window.addEventListener('resize', updatePanelPosition)
|
|
|
|
|
|
window.addEventListener('scroll', updatePanelPosition, true)
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('resize', updatePanelPosition)
|
|
|
|
|
|
window.removeEventListener('scroll', updatePanelPosition, true)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [open, usePortal, updatePanelPosition, query, browseTree, value.length])
|
|
|
|
|
|
|
2026-05-20 10:59:17 +02:00
|
|
|
|
const showTree = browseTree || !query.trim()
|
|
|
|
|
|
|
2026-05-21 10:31:21 +02:00
|
|
|
|
const panelContent = 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const dropdownPanel =
|
|
|
|
|
|
open && (usePortal ? panelStyle : true) ? (
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
|
className={
|
|
|
|
|
|
'skill-tree-multiselect__panel' + (usePortal ? ' skill-tree-multiselect__panel--portal' : '')
|
|
|
|
|
|
}
|
|
|
|
|
|
style={usePortal ? panelStyle : undefined}
|
|
|
|
|
|
>
|
|
|
|
|
|
{panelContent}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null
|
|
|
|
|
|
|
2026-05-20 10:59:17 +02:00
|
|
|
|
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>
|
2026-05-21 10:31:21 +02:00
|
|
|
|
<div className="multiselect-combo__field" ref={fieldRef}>
|
2026-05-20 10:59:17 +02:00
|
|
|
|
<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>
|
2026-05-21 10:31:21 +02:00
|
|
|
|
{!usePortal && dropdownPanel}
|
|
|
|
|
|
{usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
|
2026-05-20 10:59:17 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|