shinkan-jinkendo/frontend/src/components/SkillTreeMultiSelect.jsx
Lars a7a428745f
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 48s
Test Suite / playwright-tests (push) Successful in 1m28s
Test Suite / pytest-backend (pull_request) Successful in 35s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 12s
Test Suite / k6 /health Baseline (pull_request) Successful in 33s
Test Suite / playwright-tests (pull_request) Successful in 1m16s
Enhance SkillTreeMultiSelect Component and CSS Styles
- Updated the SkillTreeMultiSelect component to support dynamic positioning and improved accessibility through the use of portals.
- Refactored the dropdown panel rendering logic to enhance user experience when selecting skills.
- Added CSS styles for the exercise filter modal to improve layout and responsiveness.
- Introduced new styles for the skill tree multiselect panel, ensuring better visual integration and usability.
2026-05-21 10:31:21 +02:00

212 lines
6.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
import SkillTreePickerPanel from './SkillTreePickerPanel'
const PANEL_MAX_HEIGHT = 360
const PANEL_MIN_HEIGHT = 140
const PANEL_Z_INDEX = 1100
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 = '',
usePortal = true,
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [browseTree, setBrowseTree] = useState(false)
const [panelStyle, setPanelStyle] = useState(null)
const rootRef = useRef(null)
const fieldRef = useRef(null)
const panelRef = 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) => {
const t = e.target
if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
setOpen(false)
setBrowseTree(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
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])
const showTree = browseTree || !query.trim()
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
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" ref={fieldRef}>
<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>
{!usePortal && dropdownPanel}
{usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
</div>
)
}