Enhance SkillTreeMultiSelect Component and CSS Styles
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

- 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.
This commit is contained in:
Lars 2026-05-21 10:31:21 +02:00
parent 2d187447bb
commit a7a428745f
2 changed files with 123 additions and 42 deletions

View File

@ -4086,6 +4086,12 @@ html.modal-scroll-locked .app-main {
.exercise-filter-modal.admin-modal-sheet {
max-width: min(920px, calc(100dvw - 16px));
max-height: min(92vh, 920px);
}
@media (min-width: 640px) {
.exercise-filter-modal.admin-modal-sheet {
max-height: min(90vh, 920px);
}
}
.exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll {
flex: 1;
@ -6418,6 +6424,24 @@ html.modal-scroll-locked .app-main {
max-height: min(360px, 55vh);
overflow: auto;
}
.skill-tree-multiselect__panel--portal {
position: fixed;
right: auto;
top: auto;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.skill-tree-multiselect__panel--portal .multiselect-combo__list {
position: static;
left: auto;
right: auto;
top: auto;
margin: 0;
max-height: none;
box-shadow: none;
border: none;
border-radius: 0;
}
.skill-tree {
list-style: none;
margin: 0;

View File

@ -1,7 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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)
}
@ -17,11 +22,15 @@ export default function SkillTreeMultiSelect({
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])
@ -56,17 +65,99 @@ export default function SkillTreeMultiSelect({
useEffect(() => {
const onDoc = (e) => {
if (!rootRef.current?.contains(e.target)) {
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">
@ -85,7 +176,7 @@ export default function SkillTreeMultiSelect({
</button>
))}
</div>
<div className="multiselect-combo__field">
<div className="multiselect-combo__field" ref={fieldRef}>
<input
type="text"
className="form-input multiselect-combo__input"
@ -113,42 +204,8 @@ export default function SkillTreeMultiSelect({
{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}
{!usePortal && dropdownPanel}
{usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
</div>
)
}