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( 34 event ? event.state : history.state 35 )}:${window.location}`; 36 const { scrollWidth, scrollHeight } = scrollTarget; 37 const scrollTo = scrollPositions[id]; 38 if (!scrollTo) { 39 // noop 40 } else if (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1]) { 41 scrollTarget.scrollTo(scrollTo[0], scrollTo[1]); 42 } else { 43 if (unsubscribe) unsubscribe(); 44 unsubscribe = observeScrollArea( 45 ref === 'window' ? document.body : ref.current!, 46 (scrollWidth: number, scrollHeight: number) => { 47 // check whether the scroll position has already moved too far 48 const halfViewportX = window.innerWidth / 2; 49 const halfViewportY = window.innerHeight / 2; 50 const newScrollTo = scrollPositions[id]; 51 const hasMoved = 52 Math.abs(scrollTo[0] - newScrollTo[0]) > halfViewportX || 53 Math.abs(scrollTo[1] - newScrollTo[1]) > halfViewportY; 54 // then we restore the position as it's now possible 55 if ( 56 hasMoved || 57 (scrollWidth >= scrollTo[0] && scrollHeight >= scrollTo[1]) 58 ) { 59 if (!hasMoved) scrollTarget.scrollTo(scrollTo[0], scrollTo[1]); 60 if (unsubscribe) unsubscribe(); 61 } 62 } 63 ); 64 } 65 } 66 67 function onScroll() { 68 const id = `${addonId}${getIdForState(history.state)}:${window.location}`; 69 const scrollY = 70 ref === 'window' ? window.scrollY : ref.current!.scrollTop; 71 const scrollX = 72 ref === 'window' ? window.scrollX : ref.current!.scrollLeft; 73 scrollPositions[id] = [scrollX, scrollY]; 74 if (unsubscribe) { 75 unsubscribe(); 76 unsubscribe = undefined; 77 } 78 } 79 80 restoreScroll(); 81 82 const eventOpts = { passive: true } as EventListenerOptions; 83 eventTarget.addEventListener('scroll', onScroll, eventOpts); 84 window.addEventListener('popstate', restoreScroll); 85 86 return () => { 87 eventTarget.removeEventListener('scroll', onScroll, eventOpts); 88 window.removeEventListener('popstate', restoreScroll); 89 if (unsubscribe) unsubscribe(); 90 }; 91 }, [ref]); 92}