diff --git a/apps/site/components/withSidebar.tsx b/apps/site/components/withSidebar.tsx index 6d5ab83b6605b..91866828ac934 100644 --- a/apps/site/components/withSidebar.tsx +++ b/apps/site/components/withSidebar.tsx @@ -1,13 +1,13 @@ 'use client'; import Sidebar from '@node-core/ui-components/Containers/Sidebar'; -import { usePathname } from 'next/navigation'; -import { useLocale, useTranslations } from 'next-intl'; +import { useTranslations } from 'next-intl'; +import { useRef } from 'react'; import Link from '#site/components/Link'; -import { useClientContext } from '#site/hooks/client'; +import { useClientContext, useScrollToElement } from '#site/hooks/client'; import { useSiteNavigation } from '#site/hooks/generic'; -import { useRouter } from '#site/navigation.mjs'; +import { useRouter, usePathname } from '#site/navigation.mjs'; import type { NavigationKeys } from '#site/types'; import type { RichTranslationValues } from 'next-intl'; @@ -21,14 +21,17 @@ type WithSidebarProps = { const WithSidebar: FC = ({ navKeys, context, ...props }) => { const { getSideNavigation } = useSiteNavigation(); const pathname = usePathname()!; - const locale = useLocale(); const t = useTranslations(); const { push } = useRouter(); const { frontmatter } = useClientContext(); + const sidebarRef = useRef(null); const sideNavigation = getSideNavigation(navKeys, context); + // Preserve sidebar scroll position across navigations + useScrollToElement('sidebar', sidebarRef); + const mappedSidebarItems = - // If there's only a single navigation key, use it's sub-items + // If there's only a single navigation key, use its sub-items // as our navigation. (navKeys.length === 1 ? sideNavigation[0][1].items : sideNavigation).map( ([, { label, items }]) => ({ @@ -39,8 +42,9 @@ const WithSidebar: FC = ({ navKeys, context, ...props }) => { return ( { + let mockElement; + let mockRef; + let navigationState; + + beforeEach(() => { + navigationState = {}; + + mockElement = { + scrollTop: 0, + scrollLeft: 0, + scroll: mock.fn(), + addEventListener: mock.fn(), + removeEventListener: mock.fn(), + }; + + mockRef = { current: mockElement }; + }); + + afterEach(() => { + mock.reset(); + }); + + it('should handle scroll restoration with various scenarios', () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + // Should restore scroll position on mount if saved state exists + navigationState.sidebar = { x: 100, y: 200 }; + const { unmount: unmount1 } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper }); + + assert.equal(mockElement.scroll.mock.callCount(), 1); + assert.deepEqual(mockElement.scroll.mock.calls[0].arguments, [ + { top: 200, behavior: 'auto' }, + ]); + + unmount1(); + mock.reset(); + mockElement.scroll = mock.fn(); + + // Should not restore if no saved state exists + navigationState = {}; + const { unmount: unmount2 } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper }); + assert.equal(mockElement.scroll.mock.callCount(), 0); + + unmount2(); + mock.reset(); + mockElement.scroll = mock.fn(); + + // Should not restore if current position matches saved state + navigationState.sidebar = { x: 0, y: 0 }; + mockElement.scrollTop = 0; + const { unmount: unmount3 } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper }); + assert.equal(mockElement.scroll.mock.callCount(), 0); + + unmount3(); + mock.reset(); + mockElement.scroll = mock.fn(); + + // Should restore scroll to element that was outside viewport (deep scroll) + navigationState.sidebar = { x: 0, y: 1500 }; + mockElement.scrollTop = 0; + renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper }); + + assert.equal(mockElement.scroll.mock.callCount(), 1); + assert.deepEqual(mockElement.scroll.mock.calls[0].arguments, [ + { top: 1500, behavior: 'auto' }, + ]); + }); + + it('should persist and restore scroll position across navigation', async () => { + const wrapper = ({ children }) => ( + + {children} + + ); + + // First render: user scrolls to position 800 + const { unmount } = renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper }); + + const scrollHandler = mockElement.addEventListener.mock.calls[0].arguments[1]; + mockElement.scrollTop = 800; + mockElement.scrollLeft = 0; + scrollHandler(); + + // Wait for debounce + await new Promise(resolve => setTimeout(resolve, 350)); + + // Position should be saved + assert.deepEqual(navigationState.sidebar, { x: 0, y: 800 }); + + // Simulate navigation (unmount) + unmount(); + + // Simulate navigation back (remount with element at top) + mockElement.scrollTop = 0; + mock.reset(); + mockElement.scroll = mock.fn(); + mockElement.addEventListener = mock.fn(); + mockElement.removeEventListener = mock.fn(); + mockRef.current = mockElement; + + renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper }); + + // Should restore to position 800 + assert.equal(mockElement.scroll.mock.callCount(), 1); + assert.deepEqual(mockElement.scroll.mock.calls[0].arguments, [ + { top: 800, behavior: 'auto' }, + ]); + + // Also test that scroll position is saved to navigation state during scroll + mock.reset(); + mockElement.addEventListener = mock.fn(); + mockElement.scroll = mock.fn(); + navigationState = {}; + + renderHook(() => useScrollToElement('sidebar', mockRef), { wrapper }); + + // Get the scroll handler that was registered + const scrollHandler2 = mockElement.addEventListener.mock.calls[0].arguments[1]; + + // Simulate scroll + mockElement.scrollTop = 150; + mockElement.scrollLeft = 50; + + // Call the handler + scrollHandler2(); + + // Wait for debounce (default 300ms) + await new Promise(resolve => setTimeout(resolve, 350)); + + // Check that navigation state was updated + assert.deepEqual(navigationState.sidebar, { x: 50, y: 150 }); + }); +}); diff --git a/apps/site/hooks/client/index.ts b/apps/site/hooks/client/index.ts index 70d3ac90d9116..bd399cf4e8b4a 100644 --- a/apps/site/hooks/client/index.ts +++ b/apps/site/hooks/client/index.ts @@ -1,3 +1,5 @@ export { default as useDetectOS } from './useDetectOS'; export { default as useMediaQuery } from './useMediaQuery'; export { default as useClientContext } from './useClientContext'; +export { default as useScrollToElement } from './useScrollToElement'; +export { default as useScroll } from './useScroll'; diff --git a/apps/site/hooks/client/useScroll.ts b/apps/site/hooks/client/useScroll.ts new file mode 100644 index 0000000000000..6bdede83a8281 --- /dev/null +++ b/apps/site/hooks/client/useScroll.ts @@ -0,0 +1,63 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +import type { RefObject } from 'react'; + +type ScrollPosition = { + x: number; + y: number; +}; + +type UseScrollOptions = { + debounceTime?: number; + onScroll?: (position: ScrollPosition) => void; +}; + +// Custom hook to handle scroll events with optional debouncing +const useScroll = ( + ref: RefObject, + { debounceTime = 300, onScroll }: UseScrollOptions = {} +) => { + const timeoutRef = useRef(undefined); + + useEffect(() => { + // Get the current element + const element = ref.current; + + // Return early if no element or onScroll callback is provided + if (!element || !onScroll) { + return; + } + + // Debounced scroll handler + const handleScroll = () => { + // Clear existing timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + // Set new timeout to call onScroll after debounceTime + timeoutRef.current = setTimeout(() => { + if (element) { + onScroll({ + x: element.scrollLeft, + y: element.scrollTop, + }); + } + }, debounceTime); + }; + + element.addEventListener('scroll', handleScroll, { passive: true }); + + return () => { + element.removeEventListener('scroll', handleScroll); + // Clear any pending debounced calls + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [ref, onScroll, debounceTime]); +}; + +export default useScroll; diff --git a/apps/site/hooks/client/useScrollToElement.ts b/apps/site/hooks/client/useScrollToElement.ts new file mode 100644 index 0000000000000..cdbfc2ab61886 --- /dev/null +++ b/apps/site/hooks/client/useScrollToElement.ts @@ -0,0 +1,47 @@ +'use client'; + +import { useContext, useEffect } from 'react'; + +import { NavigationStateContext } from '#site/providers/navigationStateProvider'; + +import type { RefObject } from 'react'; + +import useScroll from './useScroll'; + +const useScrollToElement = ( + id: string, + ref: RefObject, + debounceTime = 300 +) => { + const navigationState = useContext(NavigationStateContext); + + // Restore scroll position on mount + useEffect(() => { + if (!ref.current) { + return; + } + + // Restore scroll position if saved state exists + const savedState = navigationState[id]; + + // Scroll only if the saved position differs from current + if (savedState && savedState.y !== ref.current.scrollTop) { + ref.current.scroll({ top: savedState.y, behavior: 'auto' }); + } + // navigationState is intentionally excluded + // it's a stable object reference that doesn't need to trigger re-runs + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, ref]); + + // Save scroll position on scroll + const handleScroll = (position: { x: number; y: number }) => { + // Save the current scroll position in the navigation state + const state = navigationState as Record; + state[id] = position; + }; + + // Use the useScroll hook to handle scroll events with debouncing + useScroll(ref, { debounceTime, onScroll: handleScroll }); +}; + +export default useScrollToElement; diff --git a/apps/site/hooks/server/index.ts b/apps/site/hooks/server/index.ts index 85c173af25f29..4493382a557e3 100644 --- a/apps/site/hooks/server/index.ts +++ b/apps/site/hooks/server/index.ts @@ -1 +1,3 @@ export { default as useClientContext } from './useClientContext'; +export { default as useScrollToElement } from './useScrollToElement'; +export { default as useScroll } from './useScroll'; diff --git a/apps/site/hooks/server/useScroll.ts b/apps/site/hooks/server/useScroll.ts new file mode 100644 index 0000000000000..89485a459dfe6 --- /dev/null +++ b/apps/site/hooks/server/useScroll.ts @@ -0,0 +1,5 @@ +const useScroll = () => { + throw new Error('Attempted to call useScroll from RSC'); +}; + +export default useScroll; diff --git a/apps/site/hooks/server/useScrollToElement.ts b/apps/site/hooks/server/useScrollToElement.ts new file mode 100644 index 0000000000000..30ec4d6505439 --- /dev/null +++ b/apps/site/hooks/server/useScrollToElement.ts @@ -0,0 +1,5 @@ +const useScrollToElement = () => { + throw new Error('Attempted to call useScrollToElement from RSC'); +}; + +export default useScrollToElement; diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index d4d56690e54c7..6426a5130c8b9 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@node-core/ui-components", - "version": "1.5.3", + "version": "1.5.4", "type": "module", "exports": { "./*": [ diff --git a/packages/ui-components/src/Containers/Sidebar/index.tsx b/packages/ui-components/src/Containers/Sidebar/index.tsx index beb479d8a8bdc..b41c83e1630da 100644 --- a/packages/ui-components/src/Containers/Sidebar/index.tsx +++ b/packages/ui-components/src/Containers/Sidebar/index.tsx @@ -1,8 +1,10 @@ +import { forwardRef } from 'react'; + import WithNoScriptSelect from '#ui/Common/Select/NoScriptSelect'; import SidebarGroup from '#ui/Containers/Sidebar/SidebarGroup'; import type { LinkLike } from '#ui/types'; -import type { ComponentProps, FC, PropsWithChildren } from 'react'; +import type { ComponentProps, PropsWithChildren } from 'react'; import styles from './index.module.css'; @@ -17,52 +19,46 @@ type SidebarProps = { placeholder?: string; }; -const SideBar: FC> = ({ - groups, - pathname, - title, - onSelect, - as, - children, - placeholder, -}) => { - const selectItems = groups.map(({ items, groupName }) => ({ - label: groupName, - items: items.map(({ label, link }) => ({ value: link, label })), - })); +const SideBar = forwardRef>( + ({ groups, pathname, title, onSelect, as, children, placeholder }, ref) => { + const selectItems = groups.map(({ items, groupName }) => ({ + label: groupName, + items: items.map(({ label, link }) => ({ value: link, label })), + })); - const currentItem = selectItems - .flatMap(item => item.items) - .find(item => pathname === item.value); + const currentItem = selectItems + .flatMap(item => item.items) + .find(item => pathname === item.value); - return ( - + ); + } +); export default SideBar;