develop #38
|
|
@ -1231,21 +1231,52 @@ a.analysis-split__nav-item {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form-Modale: Overlay + Panel (Desktop zentriert, Mobile Vollbild) */
|
/* 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 {
|
.modal-overlay--form {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
touch-action: none;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100vw;
|
||||||
}
|
}
|
||||||
.modal-overlay--form.modal-overlay--raised {
|
.modal-overlay--form.modal-overlay--raised {
|
||||||
z-index: 1100;
|
z-index: 1100;
|
||||||
}
|
}
|
||||||
|
.modal-overlay__focus-catcher {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.modal-panel--form {
|
.modal-panel--form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1259,6 +1290,10 @@ a.analysis-split__nav-item {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
touch-action: manipulation;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
margin: auto;
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
.modal-panel--form.modal-panel--narrow {
|
.modal-panel--form.modal-panel--narrow {
|
||||||
max-width: min(560px, 100%);
|
max-width: min(560px, 100%);
|
||||||
|
|
@ -1295,7 +1330,9 @@ a.analysis-split__nav-item {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
overscroll-behavior-x: none;
|
overscroll-behavior-x: none;
|
||||||
|
touch-action: pan-y;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
@ -1309,13 +1346,24 @@ a.analysis-split__nav-item {
|
||||||
.modal-overlay--form {
|
.modal-overlay--form {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
justify-content: stretch;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
}
|
}
|
||||||
.modal-panel--form {
|
.modal-panel--form {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
max-height: 100dvh;
|
max-height: 100dvh;
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
margin: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
align-self: stretch;
|
||||||
padding: 12px;
|
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-top: max(12px, env(safe-area-inset-top, 0px));
|
||||||
padding-bottom: max(0px, env(safe-area-inset-bottom, 0px));
|
padding-bottom: max(0px, env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
frontend/src/components/FormModalOverlay.jsx
Normal file
56
frontend/src/components/FormModalOverlay.jsx
Normal file
|
|
@ -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(
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
data-testid={testId}
|
||||||
|
className={overlayClass}
|
||||||
|
onClick={
|
||||||
|
onBackdropClick
|
||||||
|
? (e) => {
|
||||||
|
if (e.target === e.currentTarget) onBackdropClick()
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import FormActionBar from '../FormActionBar'
|
import FormActionBar from '../FormActionBar'
|
||||||
|
import FormModalOverlay from '../FormModalOverlay'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import { useToast } from '../../context/ToastContext'
|
import { useToast } from '../../context/ToastContext'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
@ -147,7 +148,7 @@ export default function SaveExercisesAsModuleModal({
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay modal-overlay--form modal-overlay--raised">
|
<FormModalOverlay open={open} raised onBackdropClick={onClose}>
|
||||||
<div className="card modal-panel--form modal-panel--narrow">
|
<div className="card modal-panel--form modal-panel--narrow">
|
||||||
<h2 className="modal-panel__title">Übungen als Trainingsmodul</h2>
|
<h2 className="modal-panel__title">Übungen als Trainingsmodul</h2>
|
||||||
<p className="modal-panel__intro">
|
<p className="modal-panel__intro">
|
||||||
|
|
@ -306,6 +307,6 @@ export default function SaveExercisesAsModuleModal({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormModalOverlay>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useState } from 'react'
|
import React, { useEffect, useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import FormActionBar from '../FormActionBar'
|
import FormActionBar from '../FormActionBar'
|
||||||
|
import FormModalOverlay from '../FormModalOverlay'
|
||||||
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
import TrainingPlanExerciseVisibilityPanel from '../TrainingPlanExerciseVisibilityPanel'
|
||||||
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
import TrainingUnitSectionsEditor from '../TrainingUnitSectionsEditor'
|
||||||
import { activeClubMemberships } from '../../utils/activeClub'
|
import { activeClubMemberships } from '../../utils/activeClub'
|
||||||
|
|
@ -57,7 +58,7 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
const formId = 'planning-unit-form'
|
const formId = 'planning-unit-form'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="planning-unit-form-modal" className="modal-overlay modal-overlay--form">
|
<FormModalOverlay open={open} data-testid="planning-unit-form-modal" onBackdropClick={onCancel}>
|
||||||
<div className="modal-panel--form">
|
<div className="modal-panel--form">
|
||||||
<h2 className="modal-panel__title">
|
<h2 className="modal-panel__title">
|
||||||
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
{editingUnit ? 'Trainingseinheit bearbeiten' : 'Neue Trainingseinheit'}
|
||||||
|
|
@ -575,6 +576,6 @@ export default function TrainingPlanningUnitFormModal({
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormModalOverlay>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useState, useMemo } from 'react'
|
import React, { useEffect, useState, useMemo } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import FormActionBar from '../FormActionBar'
|
import FormActionBar from '../FormActionBar'
|
||||||
|
import FormModalOverlay from '../FormModalOverlay'
|
||||||
import api from '../../utils/api'
|
import api from '../../utils/api'
|
||||||
import { useToast } from '../../context/ToastContext'
|
import { useToast } from '../../context/ToastContext'
|
||||||
import { useAuth } from '../../context/AuthContext'
|
import { useAuth } from '../../context/AuthContext'
|
||||||
|
|
@ -192,7 +193,7 @@ export default function TrainingPublishToFrameworkModal({
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay modal-overlay--form modal-overlay--raised">
|
<FormModalOverlay open={open} raised onBackdropClick={resetAndClose}>
|
||||||
<div className="card modal-panel--form modal-panel--narrow">
|
<div className="card modal-panel--form modal-panel--narrow">
|
||||||
<h2 className="modal-panel__title">Ablauf ins Rahmenprogramm übernehmen</h2>
|
<h2 className="modal-panel__title">Ablauf ins Rahmenprogramm übernehmen</h2>
|
||||||
<p className="modal-panel__intro">
|
<p className="modal-panel__intro">
|
||||||
|
|
@ -400,6 +401,6 @@ export default function TrainingPublishToFrameworkModal({
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</FormModalOverlay>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
60
frontend/src/hooks/useModalScrollLock.js
Normal file
60
frontend/src/hooks/useModalScrollLock.js
Normal file
|
|
@ -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])
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user