refactor sticky scroll handler

- because of making the root's child div styles as override: auto it won't be a ref for the sticky category logic
This commit is contained in:
2025-10-07 12:57:05 +03:00
parent d8b06c097b
commit da9d7f8eb3
4 changed files with 215 additions and 79 deletions

View File

@@ -15,10 +15,28 @@ export function FloatingButton() {
const { showScrollTop } = useScrollHandler(); const { showScrollTop } = useScrollHandler();
const scrollToTop = useCallback(() => { const scrollToTop = useCallback(() => {
window.scrollTo({ // Use a more stable approach to find the scroll container
const findScrollContainer = () => {
const antApp = document.querySelector('.ant-app') as HTMLElement;
if (antApp && antApp.scrollHeight > antApp.clientHeight) {
return antApp;
}
const appContainer = document.querySelector('[class*="App"]') as HTMLElement;
if (appContainer && appContainer.scrollHeight > appContainer.clientHeight) {
return appContainer;
}
return document.documentElement;
};
const scrollContainer = findScrollContainer();
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0, top: 0,
behavior: "smooth", behavior: "smooth",
}); });
}
}, []); }, []);
return ( return (

View File

@@ -41,10 +41,36 @@ export function ScrollHandlerProvider({ children }: { children: ReactNode }) {
// Function to scroll to a specific category // Function to scroll to a specific category
const scrollToCategory = useCallback((categoryId: number) => { const scrollToCategory = useCallback((categoryId: number) => {
const categoryRef = categoryRefs.current?.[categoryId]; const categoryRef = categoryRefs.current?.[categoryId];
if (categoryRef) {
categoryRef.scrollIntoView({ // Use a more stable approach to find the scroll container
behavior: "smooth", const findScrollContainer = () => {
block: "start", const antApp = document.querySelector('.ant-app') as HTMLElement;
if (antApp && antApp.scrollHeight > antApp.clientHeight) {
return antApp;
}
const appContainer = document.querySelector('[class*="App"]') as HTMLElement;
if (appContainer && appContainer.scrollHeight > appContainer.clientHeight) {
return appContainer;
}
return document.documentElement;
};
const scrollContainer = findScrollContainer();
if (categoryRef && scrollContainer) {
// Get the position of the category relative to the scroll container
const categoryRect = categoryRef.getBoundingClientRect();
const containerRect = scrollContainer.getBoundingClientRect();
// Calculate the target scroll position
const targetScrollTop = scrollContainer.scrollTop + categoryRect.top - containerRect.top;
// Smooth scroll to the target position
scrollContainer.scrollTo({
top: targetScrollTop,
behavior: "smooth"
}); });
} }
}, []); }, []);

View File

@@ -1,5 +1,5 @@
import { useScrollHandler } from "contexts/ScrollHandlerContext"; import { useScrollHandler } from "contexts/ScrollHandlerContext";
import { useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
export default function ScrollEventHandler() { export default function ScrollEventHandler() {
const { const {
@@ -7,15 +7,52 @@ export default function ScrollEventHandler() {
setIsCategoriesSticky, setIsCategoriesSticky,
categoriesContainerRef, categoriesContainerRef,
categoryRefs, categoryRefs,
activeCategory, setActiveCategory,
setActiveCategory
} = useScrollHandler(); } = useScrollHandler();
const hasSetInitialCategory = useRef(false); const hasSetInitialCategory = useRef(false);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const lastStickyState = useRef(false);
const originalCategoriesTop = useRef<number | null>(null);
// Find the main scrollable container - use a more stable approach
useEffect(() => {
// Use a more stable approach - find the container once and cache it
const findScrollContainer = () => {
// Try to find the Ant Design app container first
const antApp = document.querySelector(".ant-app") as HTMLElement;
if (antApp && antApp.scrollHeight > antApp.clientHeight) {
return antApp;
}
// Fallback to any container with App in the class name
const appContainer = document.querySelector(
'[class*="App"]',
) as HTMLElement;
if (
appContainer &&
appContainer.scrollHeight > appContainer.clientHeight
) {
return appContainer;
}
// Last resort - use document element
return document.documentElement;
};
const scrollContainer = findScrollContainer();
if (scrollContainer) {
scrollContainerRef.current = scrollContainer;
}
}, []);
// Set initial active category when categories are available // Set initial active category when categories are available
useEffect(() => { useEffect(() => {
if (categoryRefs.current && Object.keys(categoryRefs.current).length > 0 && !hasSetInitialCategory.current) { if (
categoryRefs.current &&
Object.keys(categoryRefs.current).length > 0 &&
!hasSetInitialCategory.current
) {
// Set the first category as active initially // Set the first category as active initially
const firstCategoryId = parseInt(Object.keys(categoryRefs.current)[0]); const firstCategoryId = parseInt(Object.keys(categoryRefs.current)[0]);
setActiveCategory(firstCategoryId); setActiveCategory(firstCategoryId);
@@ -23,59 +60,84 @@ export default function ScrollEventHandler() {
} }
}, [categoryRefs, setActiveCategory]); }, [categoryRefs, setActiveCategory]);
// Store the original position of categories container once
useEffect(() => { useEffect(() => {
const handleScroll = () => { if (
const scrollTop = categoriesContainerRef.current &&
window.pageYOffset || document.documentElement.scrollTop; originalCategoriesTop.current === null
) {
const rect = categoriesContainerRef.current.getBoundingClientRect();
originalCategoriesTop.current =
rect.top + (scrollContainerRef.current?.scrollTop || 0);
}
}, [categoriesContainerRef]);
const handleScroll = useCallback(() => {
// Use the scrollable container instead of window
const scrollTop = scrollContainerRef.current?.scrollTop || 0;
setShowScrollTop(scrollTop > 300); // Show button after scrolling 300px setShowScrollTop(scrollTop > 300); // Show button after scrolling 300px
// Check if we should make categories sticky // Check if we should make categories sticky
if (categoriesContainerRef.current) { if (
// Get the original position of the categories container categoriesContainerRef.current &&
const originalTop = categoriesContainerRef.current.offsetTop; scrollContainerRef.current &&
originalCategoriesTop.current !== null
) {
// Use the cached original position to prevent flickering
const threshold = 50; // Increased threshold to prevent flickering
const shouldBeSticky =
scrollTop > originalCategoriesTop.current + threshold;
// Only make sticky when we've scrolled past the original position // Only update state if it actually changed to prevent unnecessary re-renders
// and return to normal when we scroll back above it if (shouldBeSticky !== lastStickyState.current) {
// Add a small threshold (10px) to prevent flickering
const shouldBeSticky = scrollTop > originalTop + 10;
setIsCategoriesSticky(shouldBeSticky); setIsCategoriesSticky(shouldBeSticky);
lastStickyState.current = shouldBeSticky;
}
} }
// Find the most visible category based on scroll position // Find the most visible category based on scroll position
if (categoryRefs.current) { if (categoryRefs.current && scrollContainerRef.current) {
let mostVisibleCategory: { id: number; visibility: number } | null = null; let mostVisibleCategory: { id: number; visibility: number } | null = null;
Object.entries(categoryRefs.current).forEach(([categoryId, element]) => { Object.entries(categoryRefs.current).forEach(([categoryId, element]) => {
if (element) { if (element) {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight; const containerRect =
scrollContainerRef.current!.getBoundingClientRect();
// Calculate visibility ratio // Calculate visibility ratio relative to the scroll container
const elementTop = rect.top; const elementTop = rect.top - containerRect.top;
const elementBottom = rect.bottom; const elementBottom = elementTop + rect.height;
const containerHeight = containerRect.height;
const elementHeight = rect.height; const elementHeight = rect.height;
// Calculate how much of the element is visible // Calculate how much of the element is visible within the container
let visibleHeight = 0; let visibleHeight = 0;
if (elementTop >= 0 && elementBottom <= viewportHeight) { if (elementTop >= 0 && elementBottom <= containerHeight) {
// Element is fully visible // Element is fully visible
visibleHeight = elementHeight; visibleHeight = elementHeight;
} else if (elementTop < 0 && elementBottom > 0) { } else if (elementTop < 0 && elementBottom > 0) {
// Element is partially visible from top // Element is partially visible from top
visibleHeight = elementBottom; visibleHeight = elementBottom;
} else if (elementTop < viewportHeight && elementBottom > viewportHeight) { } else if (
elementTop < containerHeight &&
elementBottom > containerHeight
) {
// Element is partially visible from bottom // Element is partially visible from bottom
visibleHeight = viewportHeight - elementTop; visibleHeight = containerHeight - elementTop;
} }
const visibility = visibleHeight / elementHeight; const visibility = visibleHeight / elementHeight;
// Only consider elements that are at least 30% visible // Only consider elements that are at least 30% visible
if (visibility > 0.3) { if (visibility > 0.3) {
if (!mostVisibleCategory || visibility > mostVisibleCategory.visibility) { if (
!mostVisibleCategory ||
visibility > mostVisibleCategory.visibility
) {
mostVisibleCategory = { mostVisibleCategory = {
id: parseInt(categoryId), id: parseInt(categoryId),
visibility visibility,
}; };
} }
} }
@@ -84,14 +146,26 @@ export default function ScrollEventHandler() {
// Update active category if we found a visible one // Update active category if we found a visible one
if (mostVisibleCategory) { if (mostVisibleCategory) {
setActiveCategory((mostVisibleCategory as { id: number; visibility: number }).id); setActiveCategory(
(mostVisibleCategory as { id: number; visibility: number }).id,
);
} }
} }
}; }, [
setShowScrollTop,
categoriesContainerRef,
categoryRefs,
setIsCategoriesSticky,
setActiveCategory,
]); // categoryRefs and categoriesContainerRef are refs, don't need to be in deps
window.addEventListener("scroll", handleScroll); useEffect(() => {
return () => window.removeEventListener("scroll", handleScroll); const scrollContainer = scrollContainerRef.current;
}, [categoriesContainerRef, setIsCategoriesSticky, setShowScrollTop, categoryRefs, setActiveCategory, activeCategory]); if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll);
return () => scrollContainer.removeEventListener("scroll", handleScroll);
}
}, [handleScroll]);
return null; return null;
} }

View File

@@ -27,11 +27,29 @@ export const ScrollToTop: React.FC = () => {
const { pathname } = useLocation(); const { pathname } = useLocation();
useEffect(() => { useEffect(() => {
window.scrollTo({ // Use a more stable approach to find the scroll container
const findScrollContainer = () => {
const antApp = document.querySelector('.ant-app') as HTMLElement;
if (antApp && antApp.scrollHeight > antApp.clientHeight) {
return antApp;
}
const appContainer = document.querySelector('[class*="App"]') as HTMLElement;
if (appContainer && appContainer.scrollHeight > appContainer.clientHeight) {
return appContainer;
}
return document.documentElement;
};
const scrollContainer = findScrollContainer();
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0, top: 0,
left: 0, left: 0,
behavior: "smooth", behavior: "smooth",
}); // Scroll to the top when the location changes }); // Scroll to the top when the location changes
}
}, [pathname]); }, [pathname]);
return null; // This component doesn't render anything return null; // This component doesn't render anything