From a7a428745f46fa35abb4c5ee81b02ecd6b8d802d Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 21 May 2026 10:31:21 +0200 Subject: [PATCH] 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. --- frontend/src/app.css | 24 +++ .../src/components/SkillTreeMultiSelect.jsx | 141 ++++++++++++------ 2 files changed, 123 insertions(+), 42 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index bd4581c..9feb71f 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/SkillTreeMultiSelect.jsx b/frontend/src/components/SkillTreeMultiSelect.jsx index 98dc4c0..8e0187a 100644 --- a/frontend/src/components/SkillTreeMultiSelect.jsx +++ b/frontend/src/components/SkillTreeMultiSelect.jsx @@ -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)) { - setOpen(false) - setBrowseTree(false) - } + 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 ? ( + addId(id)} + pickMode="multi" + /> + ) : ( + + ) + + const dropdownPanel = + open && (usePortal ? panelStyle : true) ? ( +
+ {panelContent} +
+ ) : null + return (
@@ -85,7 +176,7 @@ export default function SkillTreeMultiSelect({ ))}
-
+
- {open ? ( -
- {showTree ? ( - addId(id)} - pickMode="multi" - /> - ) : ( -
    - {leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === - 0 ? ( -
  • {emptyHint}
  • - ) : ( - leaves - .filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())) - .map((l) => ( -
  • - -
  • - )) - )} -
- )} -
- ) : null} + {!usePortal && dropdownPanel} + {usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
) }