Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/site/components/withSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ const WithSidebar: FC<WithSidebarProps> = ({ navKeys, context, ...props }) => {
const sidebarRef = useRef<HTMLElement>(null);
const sideNavigation = getSideNavigation(navKeys, context);

// Preserve sidebar scroll position across navigations
useScrollToElement('sidebar', sidebarRef);
// Preserve sidebar scroll position and keep the active item visible
useScrollToElement('sidebar', sidebarRef, pathname);

const mappedSidebarItems =
// If there's only a single navigation key, use its sub-items
Expand Down
56 changes: 56 additions & 0 deletions apps/site/hooks/__tests__/useScrollToElement.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ describe('useScrollToElement', () => {
navigationState = {};

mockElement = {
clientHeight: 400,
scrollTop: 0,
scrollLeft: 0,
scroll: mock.fn(),
addEventListener: mock.fn(),
removeEventListener: mock.fn(),
getBoundingClientRect: mock.fn(() => ({ top: 0 })),
querySelectorAll: mock.fn(() => []),
};

mockRef = { current: mockElement };
Expand Down Expand Up @@ -87,6 +90,59 @@ describe('useScrollToElement', () => {
]);
});

it('should center the active item when it is outside the viewport', () => {
const wrapper = ({ children }) => (
<NavigationStateContext.Provider value={navigationState}>
{children}
</NavigationStateContext.Provider>
);

const activeElement = {
origin: window.location.origin,
pathname: '/learn/diagnostics/memory',
offsetHeight: 40,
getBoundingClientRect: mock.fn(() => ({ top: 900 })),
};

mockElement.querySelectorAll = mock.fn(() => [activeElement]);

renderHook(
() =>
useScrollToElement('sidebar', mockRef, '/learn/diagnostics/memory'),
{ wrapper }
);

assert.equal(mockElement.scroll.mock.callCount(), 1);
assert.deepEqual(mockElement.scroll.mock.calls[0].arguments, [
{ top: 720, behavior: 'auto' },
]);
});

it('should not scroll when the active item is already visible', () => {
const wrapper = ({ children }) => (
<NavigationStateContext.Provider value={navigationState}>
{children}
</NavigationStateContext.Provider>
);

const activeElement = {
origin: window.location.origin,
pathname: window.location.pathname,
offsetHeight: 40,
getBoundingClientRect: mock.fn(() => ({ top: 100 })),
};

mockElement.querySelectorAll = mock.fn(() => [activeElement]);

renderHook(
() =>
useScrollToElement('sidebar', mockRef, '/learn/diagnostics/memory'),
{ wrapper }
);

assert.equal(mockElement.scroll.mock.callCount(), 0);
});

it('should persist and restore scroll position across navigation', async () => {
const wrapper = ({ children }) => (
<NavigationStateContext.Provider value={navigationState}>
Expand Down
52 changes: 47 additions & 5 deletions apps/site/hooks/useScrollToElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,69 @@ import useScroll from './useScroll';
const useScrollToElement = <T extends HTMLElement>(
id: string,
ref: RefObject<T | null>,
pathname?: string,
debounceTime = 300
) => {
const navigationState = use(NavigationStateContext);

// Restore scroll position on mount
// Restore scroll position and keep the active link visible
useEffect(() => {
if (!ref.current) {
const element = ref.current;

if (!element) {
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' });
if (savedState && savedState.y !== element.scrollTop) {
element.scroll({ top: savedState.y, behavior: 'auto' });
}

if (!pathname) {
return;
}

// usePathname can omit the locale prefix, so compare resolved anchor paths
// against both the app pathname and the browser location.
const activeElement = Array.from(
element.querySelectorAll<HTMLAnchorElement>('a[href]')
).find(
({ origin, pathname: linkPathname }) =>
origin === window.location.origin &&
(linkPathname === window.location.pathname || linkPathname === pathname)
);

if (!activeElement) {
return;
}

const activeRect = activeElement.getBoundingClientRect();
const containerRect = element.getBoundingClientRect();
const offsetTop = activeRect.top - containerRect.top + element.scrollTop;
const viewTop = element.scrollTop;
const viewBottom = viewTop + element.clientHeight;

if (
offsetTop >= viewTop &&
offsetTop + activeElement.offsetHeight <= viewBottom
) {
return;
}

element.scroll({
top: Math.max(
0,
offsetTop - element.clientHeight / 2 + activeElement.offsetHeight / 2
),
behavior: 'auto',
});
// navigationState is intentionally excluded
// it's a stable object reference that doesn't need to trigger re-runs
// eslint-disable-next-line @eslint-react/exhaustive-deps
}, [id, ref]);
}, [id, pathname, ref]);

// Save scroll position on scroll
const handleScroll = (position: { x: number; y: number }) => {
Expand Down