an independent Bluesky client using Constellation, PDS Queries, and other services reddwarf.app
frontend spa bluesky reddwarf microcosm client app
at main 155 lines 4.4 kB view raw
1import * as TabsPrimitive from "@radix-ui/react-tabs"; 2import { useAtom } from "jotai"; 3import { useEffect, useLayoutEffect, useRef, useState } from "react"; 4 5import { isAtTopAtom, reusableTabRouteScrollAtom } from "~/utils/atoms"; 6 7/** 8 * Please wrap your Route in a div, do not return a top-level fragment, 9 * it will break navigation scroll restoration 10 */ 11export function ReusableTabRoute({ 12 route, 13 tabs, 14}: { 15 route: string; 16 tabs: Record<string, React.ReactNode>; 17}) { 18 const [reusableTabState, setReusableTabState] = useAtom( 19 reusableTabRouteScrollAtom 20 ); 21 const [isAtTop] = useAtom(isAtTopAtom); 22 23 const routeState = reusableTabState?.[route] ?? { 24 activeTab: Object.keys(tabs)[0], 25 scrollPositions: {}, 26 }; 27 const activeTab = routeState.activeTab; 28 29 const handleValueChange = (newTab: string) => { 30 setReusableTabState((prev) => { 31 const current = prev?.[route] ?? routeState; 32 return { 33 ...prev, 34 [route]: { 35 ...current, 36 scrollPositions: { 37 ...current.scrollPositions, 38 [current.activeTab]: window.scrollY, 39 }, 40 activeTab: newTab, 41 }, 42 }; 43 }); 44 }; 45 46 // // todo, warning experimental, usually this doesnt work, 47 // // like at all, and i usually do this for each tab 48 // useLayoutEffect(() => { 49 // const savedScroll = routeState.scrollPositions[activeTab] ?? 0; 50 // window.scrollTo({ top: savedScroll }); 51 // // eslint-disable-next-line react-hooks/exhaustive-deps 52 // }, [activeTab, route]); 53 54 useLayoutEffect(() => { 55 return () => { 56 setReusableTabState((prev) => { 57 const current = prev?.[route] ?? routeState; 58 return { 59 ...prev, 60 [route]: { 61 ...current, 62 scrollPositions: { 63 ...current.scrollPositions, 64 [current.activeTab]: window.scrollY, 65 }, 66 }, 67 }; 68 }); 69 }; 70 // eslint-disable-next-line react-hooks/exhaustive-deps 71 }, []); 72 73 //const { sentinelRef, isStuck } = useSticky(52); 74 //bg-gray-100 dark:bg-gray-900 75 76 return ( 77 <> 78 <TabsPrimitive.Root 79 value={activeTab} 80 onValueChange={handleValueChange} 81 className={`w-full`} 82 > 83 {/* <div ref={sentinelRef} className="h-[0.000000001px]" /> */} 84 <TabsPrimitive.List 85 className={`flex sticky top-[52px] bg-[var(--header-bg-light)] dark:bg-[var(--header-bg-dark)] sm:dark:bg-gray-950 sm:bg-white z-[9] border-0 sm:border-b ${!isAtTop && "shadow-sm"} sm:shadow-none border-gray-200 dark:border-gray-700`} 86 > 87 {Object.entries(tabs).map(([key]) => ( 88 <TabsPrimitive.Trigger key={key} value={key} className="m3tab"> 89 {key} 90 </TabsPrimitive.Trigger> 91 ))} 92 </TabsPrimitive.List> 93 94 {Object.entries(tabs).map(([key, node]) => ( 95 <TabsPrimitive.Content key={key} value={key} className="flex-1 min-h-[80dvh]"> 96 {activeTab === key && node} 97 </TabsPrimitive.Content> 98 ))} 99 </TabsPrimitive.Root> 100 </> 101 102 ); 103} 104 105export function useReusableTabScrollRestore(route: string) { 106 const [reusableTabState] = useAtom( 107 reusableTabRouteScrollAtom 108 ); 109 110 const routeState = reusableTabState?.[route]; 111 const activeTab = routeState?.activeTab; 112 113 useEffect(() => { 114 const savedScroll = activeTab ? routeState?.scrollPositions[activeTab] ?? 0 : 0; 115 //window.scrollTo(0, savedScroll); 116 window.scrollTo({ top: savedScroll }); 117 // eslint-disable-next-line react-hooks/exhaustive-deps 118 }, []); 119} 120 121 122/* 123 124 const [notifState] = useAtom(notificationsScrollAtom); 125 const activeTab = notifState.activeTab; 126 useEffect(() => { 127 const savedY = notifState.scrollPositions[activeTab] ?? 0; 128 window.scrollTo(0, savedY); 129 }, [activeTab, notifState.scrollPositions]); 130 131 */ 132 133 134 135export function useSticky(top: number = 0) { 136 const sentinelRef = useRef<HTMLDivElement | null>(null); 137 const [isStuck, setIsStuck] = useState(false); 138 139 useEffect(() => { 140 if (!sentinelRef.current) return; 141 142 const observer = new IntersectionObserver( 143 ([entry]) => setIsStuck(!entry.isIntersecting), 144 { 145 rootMargin: `-${top}px 0px 0px 0px`, 146 threshold: 0, 147 } 148 ); 149 150 observer.observe(sentinelRef.current); 151 return () => observer.disconnect(); 152 }, [top]); 153 154 return { sentinelRef, isStuck }; 155}