diff --git a/frontend/src/app.css b/frontend/src/app.css index e19d511..f89aef8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1231,21 +1231,52 @@ a.analysis-split__nav-item { } /* Form-Modale: Overlay + Panel (Desktop zentriert, Mobile Vollbild) */ +html.modal-scroll-locked { + overflow: hidden; + overscroll-behavior: none; + height: 100%; +} +html.modal-scroll-locked body { + overflow: hidden !important; + overscroll-behavior: none; +} +html.modal-scroll-locked .app-shell, +html.modal-scroll-locked .app-shell__column { + overflow: hidden; +} +html.modal-scroll-locked .app-main { + overflow: hidden !important; + overscroll-behavior: none; +} + .modal-overlay--form { position: fixed; inset: 0; z-index: 1000; background: rgba(0, 0, 0, 0.5); display: flex; + flex-direction: column; align-items: center; justify-content: center; padding: 1rem; overflow: hidden; + overscroll-behavior: none; + touch-action: none; box-sizing: border-box; + width: 100%; + max-width: 100vw; } .modal-overlay--form.modal-overlay--raised { z-index: 1100; } +.modal-overlay__focus-catcher { + position: absolute; + width: 0; + height: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; +} .modal-panel--form { display: flex; flex-direction: column; @@ -1259,6 +1290,10 @@ a.analysis-split__nav-item { min-height: 0; min-width: 0; box-sizing: border-box; + touch-action: manipulation; + flex: 0 1 auto; + margin: auto; + align-self: center; } .modal-panel--form.modal-panel--narrow { max-width: min(560px, 100%); @@ -1295,7 +1330,9 @@ a.analysis-split__nav-item { min-width: 0; overflow-x: hidden; overflow-y: auto; + overscroll-behavior: contain; overscroll-behavior-x: none; + touch-action: pan-y; -webkit-overflow-scrolling: touch; padding-bottom: 4px; } @@ -1309,13 +1346,24 @@ a.analysis-split__nav-item { .modal-overlay--form { padding: 0; align-items: stretch; + justify-content: stretch; + width: 100vw; + max-width: 100vw; + left: 0; + right: 0; } .modal-panel--form { max-width: 100%; + width: 100%; max-height: 100dvh; height: 100dvh; border-radius: 0; + margin: 0; + flex: 1 1 auto; + align-self: stretch; padding: 12px; + padding-left: max(12px, env(safe-area-inset-left, 0px)); + padding-right: max(12px, env(safe-area-inset-right, 0px)); padding-top: max(12px, env(safe-area-inset-top, 0px)); padding-bottom: max(0px, env(safe-area-inset-bottom, 0px)); } diff --git a/frontend/src/components/FormModalOverlay.jsx b/frontend/src/components/FormModalOverlay.jsx new file mode 100644 index 0000000..6365ae6 --- /dev/null +++ b/frontend/src/components/FormModalOverlay.jsx @@ -0,0 +1,56 @@ +import React, { useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useModalScrollLock } from '../hooks/useModalScrollLock' + +/** + * Formular-Modal am document.body (Portal) — volle Viewport-Breite, kein Hintergrund-Scroll. + */ +export default function FormModalOverlay({ + open, + children, + raised = false, + className = '', + onBackdropClick, + 'data-testid': testId, +}) { + const overlayRef = useRef(null) + useModalScrollLock(open) + + useEffect(() => { + if (!open) return undefined + const t = window.setTimeout(() => { + overlayRef.current?.focus({ preventScroll: true }) + }, 0) + return () => clearTimeout(t) + }, [open]) + + if (!open) return null + + const overlayClass = [ + 'modal-overlay', + 'modal-overlay--form', + raised ? 'modal-overlay--raised' : '', + className, + ] + .filter(Boolean) + .join(' ') + + return createPortal( +
{ + if (e.target === e.currentTarget) onBackdropClick() + } + : undefined + } + > + {children} +
, + document.body, + ) +} diff --git a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx index a2d58a9..ee2c119 100644 --- a/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx +++ b/frontend/src/components/planning/SaveExercisesAsModuleModal.jsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import FormActionBar from '../FormActionBar' +import FormModalOverlay from '../FormModalOverlay' import api from '../../utils/api' import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' @@ -147,7 +148,7 @@ export default function SaveExercisesAsModuleModal({ if (!open) return null return ( -
+

Übungen als Trainingsmodul

@@ -306,6 +307,6 @@ export default function SaveExercisesAsModuleModal({

) : null}
- + ) } diff --git a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx index b15004a..7622624 100644 --- a/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx +++ b/frontend/src/components/planning/TrainingPlanningUnitFormModal.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' import FormActionBar from '../FormActionBar' +import FormModalOverlay from '../FormModalOverlay' import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel' import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor' import { activeClubMemberships } from '../../utils/activeClub' @@ -57,7 +58,7 @@ export default function TrainingPlanningUnitFormModal({ const formId = 'planning-unit-form' return ( -
+

{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'} @@ -575,6 +576,6 @@ export default function TrainingPlanningUnitFormModal({ />

-
+ ) } diff --git a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx index 3789013..c7714d6 100644 --- a/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx +++ b/frontend/src/components/planning/TrainingPublishToFrameworkModal.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState, useMemo } from 'react' import { useNavigate } from 'react-router-dom' import FormActionBar from '../FormActionBar' +import FormModalOverlay from '../FormModalOverlay' import api from '../../utils/api' import { useToast } from '../../context/ToastContext' import { useAuth } from '../../context/AuthContext' @@ -192,7 +193,7 @@ export default function TrainingPublishToFrameworkModal({ if (!open) return null return ( -
+

Ablauf ins Rahmenprogramm übernehmen

@@ -400,6 +401,6 @@ export default function TrainingPublishToFrameworkModal({ />

-
+ ) } diff --git a/frontend/src/hooks/useModalScrollLock.js b/frontend/src/hooks/useModalScrollLock.js new file mode 100644 index 0000000..181dbeb --- /dev/null +++ b/frontend/src/hooks/useModalScrollLock.js @@ -0,0 +1,60 @@ +import { useEffect } from 'react' + +let lockDepth = 0 +const saved = { + appMainScrollTop: 0, + bodyScrollTop: 0, + appMainOverflow: '', + appMainTouchAction: '', +} + +function queryAppMain() { + return document.querySelector('.app-main') +} + +function applyLock() { + document.documentElement.classList.add('modal-scroll-locked') + + const appMain = queryAppMain() + if (appMain) { + saved.appMainScrollTop = appMain.scrollTop + saved.appMainOverflow = appMain.style.overflow + saved.appMainTouchAction = appMain.style.touchAction + appMain.style.overflow = 'hidden' + appMain.style.touchAction = 'none' + } + + saved.bodyScrollTop = window.scrollY || document.documentElement.scrollTop || 0 + document.body.style.overflow = 'hidden' + document.body.style.overscrollBehavior = 'none' +} + +function releaseLock() { + document.documentElement.classList.remove('modal-scroll-locked') + + const appMain = queryAppMain() + if (appMain) { + appMain.style.overflow = saved.appMainOverflow + appMain.style.touchAction = saved.appMainTouchAction + appMain.scrollTop = saved.appMainScrollTop + } + + document.body.style.overflow = '' + document.body.style.overscrollBehavior = '' + if (saved.bodyScrollTop) { + window.scrollTo(0, saved.bodyScrollTop) + } +} + +/** Hintergrund-Scroll sperren (.app-main + body), verschachtelte Modals via Zähler. */ +export function useModalScrollLock(active) { + useEffect(() => { + if (!active) return undefined + lockDepth += 1 + if (lockDepth === 1) applyLock() + return () => { + lockDepth = Math.max(0, lockDepth - 1) + if (lockDepth === 0) releaseLock() + } + }, [active]) +}