Mirror: React hooks for accessible, common web interactions. UI super powers without the UI.
1import { useLayoutEffect } from './utils/react'; 2import { Ref } from './types'; 3 4const mutationObservers: Map<HTMLElement, MutationObserver> = new Map(); 5const resizeListeners: Map<HTMLElement, Array<() => void>> = new Map(); 6 7const resizeObserver = new ResizeObserver(entries => { 8 const parents = new Set<Element>(); 9 for (let i = 0; i < entries.length; i++) { 10 const parent = entries[i].target.parentElement; 11 if (parent && !parents.has(parent)) { 12 parents.add(parent); 13 const listeners = resizeListeners.get(parent) || []; 14 for (let i = 0; i < listeners.length; i++) listeners[i](); 15 } 16 } 17}); 18 19function observeScrollHeight( 20 element: HTMLElement, 21 onScrollHeightChange: (scrollHeight: number) => void 22): () => void { 23 const listeners = resizeListeners.get(element) || []; 24 const isFirstListener = !listeners.length; 25 resizeListeners.set(element, listeners); 26 27 let previousScrollHeight: null | number = null; 28 let hasUnmounted = false; 29 const onResize = () => { 30 const scrollHeight = element.scrollHeight || 0; 31 if (!hasUnmounted && scrollHeight !== previousScrollHeight) { 32 onScrollHeightChange(element.scrollHeight); 33 previousScrollHeight = scrollHeight; 34 } 35 }; 36 37 listeners.push(onResize); 38 39 if (isFirstListener) { 40 const mutationObserver = new MutationObserver(entries => { 41 for (let i = 0; i < entries.length; i++) { 42 const entry = entries[i]; 43 for (let j = 0; j < entry.addedNodes.length; j++) { 44 const node = entry.addedNodes[j]; 45 if (node.nodeType === Node.ELEMENT_NODE) { 46 resizeObserver.observe(node as Element); 47 } 48 } 49 50 for (let j = 0; j < entry.removedNodes.length; j++) { 51 const node = entry.removedNodes[j]; 52 if (node.nodeType === Node.ELEMENT_NODE) { 53 resizeObserver.unobserve(node as Element); 54 } 55 } 56 } 57 }); 58 59 const childNodes = element.childNodes; 60 for (let i = 0; i < childNodes.length; i++) 61 if (childNodes[i].nodeType === Node.ELEMENT_NODE) 62 resizeObserver.observe(childNodes[i] as Element); 63 64 mutationObserver.observe(element, { childList: true }); 65 mutationObservers.set(element, mutationObserver); 66 } 67 68 requestAnimationFrame(onResize); 69 70 return () => { 71 const listeners = resizeListeners.get(element) || []; 72 listeners.splice(listeners.indexOf(onResize), 1); 73 hasUnmounted = true; 74 75 if (!listeners.length) { 76 const mutationObserver = mutationObservers.get(element); 77 if (mutationObserver) mutationObserver.disconnect(); 78 79 const childNodes = element.childNodes; 80 for (let i = 0; i < childNodes.length; i++) 81 if (childNodes[i].nodeType === Node.ELEMENT_NODE) 82 resizeObserver.unobserve(childNodes[i] as Element); 83 84 resizeListeners.delete(element); 85 mutationObservers.delete(element); 86 } 87 }; 88} 89 90const getIdForState = (() => { 91 const defaultState = {}; 92 const stateToId = new WeakMap<{}, string>(); 93 94 let uniqueID = 1; 95 96 return (state?: {} | null): string => { 97 if (!state) state = defaultState; 98 let id = stateToId.get(state); 99 if (!id) stateToId.set(state, (id = (uniqueID++).toString(36))); 100 return `${id}${document.location}`; 101 }; 102})(); 103 104const scrollPositions: Record<string, number> = {}; 105 106export function useScrollRestoration<T extends HTMLElement>( 107 ref: 'window' | Ref<T> 108) { 109 useLayoutEffect(() => { 110 let unsubscribe: void | (() => void); 111 if (ref !== 'window' && !ref.current) return; 112 113 const addonId = ref === 'window' ? 'window' : ref.current!.id || ''; 114 const eventTarget = ref === 'window' ? window : ref.current!; 115 const scrollTarget = ref === 'window' ? document.body : ref.current!; 116 117 function restoreScroll(event?: PopStateEvent) { 118 const id = `${addonId}${getIdForState( 119 event ? event.state : history.state 120 )}:${window.location}`; 121 const scrollHeight = 122 ref === 'window' 123 ? document.body.scrollHeight 124 : ref.current!.scrollHeight; 125 const scrollY = scrollPositions[id]; 126 if (!scrollY) { 127 // noop 128 } else if (scrollHeight >= scrollY) { 129 scrollTarget.scrollTo(0, scrollY); 130 } else { 131 if (unsubscribe) unsubscribe(); 132 unsubscribe = observeScrollHeight( 133 ref === 'window' ? document.body : ref.current!, 134 (scrollHeight: number) => { 135 // the scroll position shouldn't have changed by more than half the screen height 136 const hasMoved = 137 Math.abs(scrollY - scrollPositions[id]) > window.innerHeight / 2; 138 // then we restore the position as it's now possible 139 if (!hasMoved && scrollHeight >= scrollY) 140 scrollTarget.scrollTo(0, scrollY); 141 if (unsubscribe) unsubscribe(); 142 } 143 ); 144 } 145 } 146 147 function onScroll() { 148 const id = `${addonId}${getIdForState(history.state)}:${window.location}`; 149 const scrollY = 150 ref === 'window' ? window.scrollY : ref.current!.scrollTop; 151 scrollPositions[id] = scrollY || 0; 152 if (unsubscribe) { 153 unsubscribe(); 154 unsubscribe = undefined; 155 } 156 } 157 158 restoreScroll(); 159 160 const eventOpts = { passive: true } as EventListenerOptions; 161 eventTarget.addEventListener('scroll', onScroll, eventOpts); 162 window.addEventListener('popstate', restoreScroll); 163 164 return () => { 165 eventTarget.removeEventListener('scroll', onScroll, eventOpts); 166 window.removeEventListener('popstate', restoreScroll); 167 if (unsubscribe) unsubscribe(); 168 }; 169 }, [ref]); 170}