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
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:
parent
2d187447bb
commit
a7a428745f
|
|
@ -4086,6 +4086,12 @@ html.modal-scroll-locked .app-main {
|
||||||
|
|
||||||
.exercise-filter-modal.admin-modal-sheet {
|
.exercise-filter-modal.admin-modal-sheet {
|
||||||
max-width: min(920px, calc(100dvw - 16px));
|
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 {
|
.exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -6418,6 +6424,24 @@ html.modal-scroll-locked .app-main {
|
||||||
max-height: min(360px, 55vh);
|
max-height: min(360px, 55vh);
|
||||||
overflow: auto;
|
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 {
|
.skill-tree {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -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 { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
|
||||||
import SkillTreePickerPanel from './SkillTreePickerPanel'
|
import SkillTreePickerPanel from './SkillTreePickerPanel'
|
||||||
|
|
||||||
|
const PANEL_MAX_HEIGHT = 360
|
||||||
|
const PANEL_MIN_HEIGHT = 140
|
||||||
|
const PANEL_Z_INDEX = 1100
|
||||||
|
|
||||||
function normId(id) {
|
function normId(id) {
|
||||||
return String(id)
|
return String(id)
|
||||||
}
|
}
|
||||||
|
|
@ -17,11 +22,15 @@ export default function SkillTreeMultiSelect({
|
||||||
browseLabel = '▼ Katalog',
|
browseLabel = '▼ Katalog',
|
||||||
emptyHint = 'Keine Treffer',
|
emptyHint = 'Keine Treffer',
|
||||||
className = '',
|
className = '',
|
||||||
|
usePortal = true,
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [browseTree, setBrowseTree] = useState(false)
|
const [browseTree, setBrowseTree] = useState(false)
|
||||||
|
const [panelStyle, setPanelStyle] = useState(null)
|
||||||
const rootRef = useRef(null)
|
const rootRef = useRef(null)
|
||||||
|
const fieldRef = useRef(null)
|
||||||
|
const panelRef = useRef(null)
|
||||||
|
|
||||||
const tree = useMemo(() => buildSkillCatalogTree(skills), [skills])
|
const tree = useMemo(() => buildSkillCatalogTree(skills), [skills])
|
||||||
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
|
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
|
||||||
|
|
@ -56,17 +65,99 @@ export default function SkillTreeMultiSelect({
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDoc = (e) => {
|
const onDoc = (e) => {
|
||||||
if (!rootRef.current?.contains(e.target)) {
|
const t = e.target
|
||||||
setOpen(false)
|
if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
|
||||||
setBrowseTree(false)
|
setOpen(false)
|
||||||
}
|
setBrowseTree(false)
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onDoc)
|
document.addEventListener('mousedown', onDoc)
|
||||||
return () => document.removeEventListener('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 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 (
|
return (
|
||||||
<div className={`multiselect-combo skill-tree-multiselect ${className}`.trim()} ref={rootRef}>
|
<div className={`multiselect-combo skill-tree-multiselect ${className}`.trim()} ref={rootRef}>
|
||||||
<div className="multiselect-combo__chips">
|
<div className="multiselect-combo__chips">
|
||||||
|
|
@ -85,7 +176,7 @@ export default function SkillTreeMultiSelect({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="multiselect-combo__field">
|
<div className="multiselect-combo__field" ref={fieldRef}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-input multiselect-combo__input"
|
className="form-input multiselect-combo__input"
|
||||||
|
|
@ -113,42 +204,8 @@ export default function SkillTreeMultiSelect({
|
||||||
{browseLabel}
|
{browseLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{open ? (
|
{!usePortal && dropdownPanel}
|
||||||
<div className="skill-tree-multiselect__panel">
|
{usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
|
||||||
{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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user