feat(ux): globale Scroll-Wiederherstellung für ALLE Listen/Seiten
Neuer Hook useScrollRestoration (einmal im AppShell gemountet) erhält die Scrollposition je Route über: - Zurück/Vorwärts-Navigation (POP restauriert, PUSH startet oben), - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) — damit das Handy nach dem Entsperren nicht mehr oben landet. Robust gegen nachladende Listen (rAF-Nachjustierung bis ~2,5 s, während die Liste wächst), bricht bei eigener Scroll-Eingabe ab, Deep-Links (?focus=, #anchor) haben Vorrang. Die bisher nur in der Tickets-Seite eingebaute Logik wurde durch den globalen Hook ersetzt. tsc/eslint/vitest grün; e2e (navigation/listen/tickets, 42) grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
129
gerbil-manager-web/src/hooks/useScrollRestoration.ts
Normal file
129
gerbil-manager-web/src/hooks/useScrollRestoration.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen).
|
||||
*
|
||||
* Erhält die Fenster-Scrollposition je Route über:
|
||||
* - Zurück/Vorwärts-Navigation (POP) → gemerkte Position wiederherstellen,
|
||||
* - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) → Position
|
||||
* sicher festhalten und beim Zurückkommen wiederherstellen (sonst springt das Handy oben).
|
||||
* Neue Navigation (PUSH/REPLACE) startet wie gewohnt oben.
|
||||
*
|
||||
* Robust gegen nachladende Listen: nach dem Wiederherstellen wird mehrfach (per rAF, bis ~2.5 s)
|
||||
* nachjustiert, während die Liste in die Höhe wächst — bricht aber ab, sobald die Nutzerin selbst
|
||||
* scrollt (damit wir nie gegen sie ankämpfen). Deep-Links (?focus=, #anchor) haben Vorrang.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useLocation, useNavigationType } from 'react-router-dom'
|
||||
|
||||
const PREFIX = 'scroll:'
|
||||
const RESTORE_DEADLINE_MS = 2500
|
||||
|
||||
export function useScrollRestoration() {
|
||||
const { pathname } = useLocation()
|
||||
const navType = useNavigationType()
|
||||
const pathRef = useRef(pathname)
|
||||
const restoringRef = useRef(false)
|
||||
const rafSaveRef = useRef<number | null>(null)
|
||||
|
||||
const keyFor = (p: string) => PREFIX + p
|
||||
|
||||
const persist = useCallback(() => {
|
||||
try {
|
||||
sessionStorage.setItem(keyFor(pathRef.current), String(window.scrollY))
|
||||
} catch {
|
||||
/* sessionStorage nicht verfügbar */
|
||||
}
|
||||
}, [])
|
||||
|
||||
const restore = useCallback((path: string, respectDeepLink: boolean) => {
|
||||
if (respectDeepLink) {
|
||||
// Ein Deep-Link (Sprung zu einem Element / Anker) hat Vorrang vor der gemerkten Position.
|
||||
if (window.location.hash) return
|
||||
try {
|
||||
if (new URLSearchParams(window.location.search).has('focus')) return
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
}
|
||||
let y = NaN
|
||||
try {
|
||||
y = Number(sessionStorage.getItem(keyFor(path)))
|
||||
} catch {
|
||||
/* ignorieren */
|
||||
}
|
||||
if (!Number.isFinite(y) || y <= 0) return
|
||||
|
||||
restoringRef.current = true
|
||||
const deadline = performance.now() + RESTORE_DEADLINE_MS
|
||||
let aborted = false
|
||||
const onUser = () => {
|
||||
aborted = true
|
||||
}
|
||||
const passive = { passive: true } as AddEventListenerOptions
|
||||
window.addEventListener('wheel', onUser, passive)
|
||||
window.addEventListener('touchmove', onUser, passive)
|
||||
window.addEventListener('keydown', onUser)
|
||||
const stop = () => {
|
||||
restoringRef.current = false
|
||||
window.removeEventListener('wheel', onUser)
|
||||
window.removeEventListener('touchmove', onUser)
|
||||
window.removeEventListener('keydown', onUser)
|
||||
}
|
||||
const tick = () => {
|
||||
if (aborted) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
window.scrollTo(0, y)
|
||||
if (Math.abs(window.scrollY - y) <= 2 || performance.now() > deadline) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}
|
||||
requestAnimationFrame(tick)
|
||||
}, [])
|
||||
|
||||
// Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
|
||||
useEffect(() => {
|
||||
pathRef.current = pathname
|
||||
if (navType === 'POP') {
|
||||
restore(pathname, true)
|
||||
} else {
|
||||
restoringRef.current = true
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo(0, 0)
|
||||
restoringRef.current = false
|
||||
})
|
||||
}
|
||||
}, [pathname, navType, restore])
|
||||
|
||||
// Laufendes Mitschreiben (rAF-gedrosselt) + Sichern beim Ausblenden + Wiederherstellen beim
|
||||
// Wiederanzeigen (Handy entsperrt / aus dem Hintergrund).
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
if (restoringRef.current || document.visibilityState !== 'visible') return
|
||||
if (rafSaveRef.current != null) return
|
||||
rafSaveRef.current = requestAnimationFrame(() => {
|
||||
rafSaveRef.current = null
|
||||
persist()
|
||||
})
|
||||
}
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') persist()
|
||||
else restore(pathRef.current, false)
|
||||
}
|
||||
const onPageShow = () => restore(pathRef.current, false)
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
window.addEventListener('pagehide', persist)
|
||||
window.addEventListener('pageshow', onPageShow)
|
||||
return () => {
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('pagehide', persist)
|
||||
window.removeEventListener('pageshow', onPageShow)
|
||||
if (rafSaveRef.current != null) cancelAnimationFrame(rafSaveRef.current)
|
||||
}
|
||||
}, [persist, restore])
|
||||
}
|
||||
Reference in New Issue
Block a user