From e8bb6a5a36d12c50993d316459c35288d1eec9ff Mon Sep 17 00:00:00 2001 From: yohlo Date: Sun, 24 Aug 2025 23:23:34 -0500 Subject: [PATCH] sticky tabs --- src/components/swipeable-tabs.tsx | 59 ++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/src/components/swipeable-tabs.tsx b/src/components/swipeable-tabs.tsx index f307858..89b6ff3 100644 --- a/src/components/swipeable-tabs.tsx +++ b/src/components/swipeable-tabs.tsx @@ -1,6 +1,6 @@ import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core"; import { Carousel } from "@mantine/carousel"; -import { useState, useEffect, ReactNode } from "react"; +import { useState, useEffect, ReactNode, useRef } from "react"; interface TabItem { label: string; @@ -11,13 +11,18 @@ interface SwipeableTabsProps { tabs: TabItem[]; defaultTab?: number; onTabChange?: (index: number, tab: TabItem) => void; + scrollPosition?: { x: number; y: number }; } -function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) { +function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: SwipeableTabsProps) { const [activeTab, setActiveTab] = useState(defaultTab); const [embla, setEmbla] = useState(null); const [rootRef, setRootRef] = useState(null); const [controlsRefs, setControlsRefs] = useState>({}); + const [isSticky, setIsSticky] = useState(false); + const tabsRef = useRef(null); + const originalPositionRef = useRef(null); + const stickyThreshold = 0; useEffect(() => { if (!embla) return; @@ -34,6 +39,40 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps }; }, [embla]); + useEffect(() => { + if (scrollPosition) { + setIsSticky(scrollPosition.y > stickyThreshold); + return; + } + + const scrollWrapper = document.getElementById('scroll-wrapper'); + let viewport = scrollWrapper!.querySelector('.mantine-ScrollArea-viewport') as HTMLElement; + + const handleScroll = () => { + if (!tabsRef.current) return; + + const scrollTop = viewport.scrollTop; + const viewportRect = viewport.getBoundingClientRect(); + + if (originalPositionRef.current === null && !isSticky) { + const tabsRect = tabsRef.current.getBoundingClientRect(); + originalPositionRef.current = tabsRect.top - viewportRect.top + scrollTop; + } + + setIsSticky( + originalPositionRef.current !== null + && scrollTop >= (originalPositionRef.current - viewportRect.top - stickyThreshold) + ); + }; + + handleScroll(); + viewport.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + viewport.removeEventListener('scroll', handleScroll); + }; + }, [stickyThreshold, isSticky, scrollPosition]); + const handleTabChange = (index: number) => { if (index !== activeTab && index >= 0 && index < tabs.length) { setActiveTab(index); @@ -49,12 +88,24 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps return ( + {isSticky && ( + + )} { + setRootRef(node); + tabsRef.current = node; + }} + pos={isSticky ? "fixed" : "relative"} style={{ display: 'flex', marginBottom: 'var(--mantine-spacing-md)', + top: isSticky ? 0 : 'auto', + left: isSticky ? 0 : 'auto', + right: isSticky ? 0 : 'auto', + zIndex: 100, + backgroundColor: 'var(--mantine-color-body)', + transition: 'all 200ms ease', }} >