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