Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
at main 3.0 kB view raw
1import { useLayoutEffect } from './utils/react'; 2import { observeScrollArea } from './utils/observeScrollArea'; 3import { Ref } from './types'; 4 5const getIdForState = (state?: {} | null): string => 6 `${state ? JSON.stringify(state) : ''}${document.location}`; 7 8const scrollPositions: Record<string, [number, number]> = {}; 9 10export function useScrollRestoration<T extends HTMLElement>( 11 ref: 'window' | Ref<T> 12) { 13 const target = ref !== 'window' ? ref.current : ref; 14 15 useLayoutEffect(() => { 16 let unsubscribe: void | (() => void); 17 if (!target) return; 18 19 const addonId = target === 'window' ? 'window' : target.id || ''; 20 const eventTarget = target === 'window' ? window : target; 21 const scrollTarget = target === 'window' ? document.body : target; 22 23 function restoreScroll(event?: PopStateEvent) { 24 const id = addonId + getIdForState(event ? event.state : history.state); 25 const { scrollWidth, scrollHeight } = scrollTarget; 26 const scrollTo = scrollPositions[id]; 27 if (!scrollTo) { 28 // noop 29 } else if (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1]) { 30 scrollTarget.scrollTo(scrollTo[0], scrollTo[1]); 31 } else { 32 if (unsubscribe) unsubscribe(); 33 unsubscribe = observeScrollArea( 34 ref === 'window' ? document.body : ref.current!, 35 (scrollWidth: number, scrollHeight: number) => { 36 // check whether the scroll position has already moved too far 37 const halfViewportX = window.innerWidth / 2; 38 const halfViewportY = window.innerHeight / 2; 39 const newScrollTo = scrollPositions[id]; 40 const hasMoved = 41 Math.abs(scrollTo[0] - newScrollTo[0]) > halfViewportX || 42 Math.abs(scrollTo[1] - newScrollTo[1]) > halfViewportY; 43 // then we restore the position as it's now possible 44 if ( 45 hasMoved || 46 (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1]) 47 ) { 48 if (!hasMoved) scrollTarget.scrollTo(scrollTo[0], scrollTo[1]); 49 if (unsubscribe) unsubscribe(); 50 } 51 } 52 ); 53 } 54 } 55 56 function onScroll() { 57 const id = addonId + getIdForState(history.state); 58 const scrollY = 59 ref === 'window' ? window.scrollY : ref.current!.scrollTop; 60 const scrollX = 61 ref === 'window' ? window.scrollX : ref.current!.scrollLeft; 62 scrollPositions[id] = [scrollX, scrollY]; 63 if (unsubscribe) { 64 unsubscribe(); 65 unsubscribe = undefined; 66 } 67 } 68 69 restoreScroll(); 70 71 const eventOpts = { passive: true } as EventListenerOptions; 72 eventTarget.addEventListener('scroll', onScroll, eventOpts); 73 window.addEventListener('popstate', restoreScroll); 74 75 return () => { 76 eventTarget.removeEventListener('scroll', onScroll, eventOpts); 77 window.removeEventListener('popstate', restoreScroll); 78 if (unsubscribe) unsubscribe(); 79 }; 80 }, [target]); 81}