Skip to content

Commit 0781979

Browse files
committed
feat(ui): implement sidebar scroll position preservation
1 parent 8c5a7dd commit 0781979

File tree

4 files changed

+95
-47
lines changed

4 files changed

+95
-47
lines changed

apps/site/components/withSidebar.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@
33
import Sidebar from '@node-core/ui-components/Containers/Sidebar';
44
import { usePathname } from 'next/navigation';
55
import { useLocale, useTranslations } from 'next-intl';
6+
import { useRef } from 'react';
67

78
import Link from '#site/components/Link';
8-
import { useClientContext } from '#site/hooks/client';
9-
import { useSiteNavigation } from '#site/hooks/generic';
109
import { useRouter } from '#site/navigation.mjs';
1110

1211
import type { NavigationKeys } from '#site/types';
1312
import type { RichTranslationValues } from 'next-intl';
1413
import type { FC } from 'react';
1514

15+
import { useClientContext, useNavigationState } from '../hooks/client';
16+
import { useSiteNavigation } from '../hooks/generic';
17+
1618
type WithSidebarProps = {
1719
navKeys: Array<NavigationKeys>;
1820
context?: Record<string, RichTranslationValues>;
@@ -25,8 +27,14 @@ const WithSidebar: FC<WithSidebarProps> = ({ navKeys, context, ...props }) => {
2527
const t = useTranslations();
2628
const { push } = useRouter();
2729
const { frontmatter } = useClientContext();
30+
const sidebarRef = useRef<HTMLElement>(null);
2831
const sideNavigation = getSideNavigation(navKeys, context);
2932

33+
const localePathname = pathname.replace(`/${locale}`, '');
34+
35+
// Preserve sidebar scroll position across navigations
36+
useNavigationState('sidebar', sidebarRef);
37+
3038
const mappedSidebarItems =
3139
// If there's only a single navigation key, use it's sub-items
3240
// as our navigation.
@@ -39,8 +47,9 @@ const WithSidebar: FC<WithSidebarProps> = ({ navKeys, context, ...props }) => {
3947

4048
return (
4149
<Sidebar
50+
ref={sidebarRef}
4251
groups={mappedSidebarItems}
43-
pathname={pathname.replace(`/${locale}`, '')}
52+
pathname={localePathname}
4453
title={t('components.common.sidebar.title')}
4554
placeholder={frontmatter?.title}
4655
onSelect={push}

apps/site/hooks/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as useDetectOS } from './useDetectOS';
22
export { default as useMediaQuery } from './useMediaQuery';
33
export { default as useClientContext } from './useClientContext';
4+
export { default as useNavigationState } from './useNavigationState';
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client';
2+
3+
import { useContext, useEffect } from 'react';
4+
5+
import { NavigationStateContext } from '#site/providers/navigationStateProvider';
6+
import { debounce } from '#site/util/objects';
7+
8+
import type { RefObject } from 'react';
9+
10+
const useNavigationState = <T extends HTMLElement>(
11+
id: string,
12+
ref: RefObject<T | null>,
13+
debounceTime = 300
14+
) => {
15+
const navigationState = useContext(NavigationStateContext);
16+
17+
const handleScroll = debounce(() => {
18+
if (ref.current) {
19+
navigationState[id] = {
20+
x: ref.current.scrollLeft,
21+
y: ref.current.scrollTop,
22+
};
23+
}
24+
}, debounceTime);
25+
26+
useEffect(() => {
27+
const element = ref.current;
28+
if (element) {
29+
if (navigationState[id] && navigationState[id].y !== element.scrollTop) {
30+
element.scroll({ top: navigationState[id].y, behavior: 'instant' });
31+
}
32+
33+
element.addEventListener('scroll', handleScroll, { passive: true });
34+
35+
return () => element.removeEventListener('scroll', handleScroll);
36+
}
37+
// We need this effect to run only on mount
38+
// eslint-disable-next-line react-hooks/exhaustive-deps
39+
}, []);
40+
};
41+
42+
export default useNavigationState;
Lines changed: 40 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { forwardRef } from 'react';
2+
13
import WithNoScriptSelect from '#ui/Common/Select/NoScriptSelect';
24
import SidebarGroup from '#ui/Containers/Sidebar/SidebarGroup';
35

46
import type { LinkLike } from '#ui/types';
5-
import type { ComponentProps, FC, PropsWithChildren } from 'react';
7+
import type { ComponentProps, PropsWithChildren } from 'react';
68

79
import styles from './index.module.css';
810

@@ -17,52 +19,46 @@ type SidebarProps = {
1719
placeholder?: string;
1820
};
1921

20-
const SideBar: FC<PropsWithChildren<SidebarProps>> = ({
21-
groups,
22-
pathname,
23-
title,
24-
onSelect,
25-
as,
26-
children,
27-
placeholder,
28-
}) => {
29-
const selectItems = groups.map(({ items, groupName }) => ({
30-
label: groupName,
31-
items: items.map(({ label, link }) => ({ value: link, label })),
32-
}));
22+
const SideBar = forwardRef<HTMLElement, PropsWithChildren<SidebarProps>>(
23+
({ groups, pathname, title, onSelect, as, children, placeholder }, ref) => {
24+
const selectItems = groups.map(({ items, groupName }) => ({
25+
label: groupName,
26+
items: items.map(({ label, link }) => ({ value: link, label })),
27+
}));
3328

34-
const currentItem = selectItems
35-
.flatMap(item => item.items)
36-
.find(item => pathname === item.value);
29+
const currentItem = selectItems
30+
.flatMap(item => item.items)
31+
.find(item => pathname === item.value);
3732

38-
return (
39-
<aside className={styles.wrapper}>
40-
{children}
33+
return (
34+
<aside ref={ref} className={styles.wrapper}>
35+
{children}
4136

42-
{selectItems.length > 0 && (
43-
<WithNoScriptSelect
44-
label={title}
45-
values={selectItems}
46-
defaultValue={currentItem?.value}
47-
placeholder={placeholder}
48-
onChange={onSelect}
49-
className={styles.mobileSelect}
50-
as={as}
51-
/>
52-
)}
37+
{selectItems.length > 0 && (
38+
<WithNoScriptSelect
39+
label={title}
40+
values={selectItems}
41+
defaultValue={currentItem?.value}
42+
placeholder={placeholder}
43+
onChange={onSelect}
44+
className={styles.mobileSelect}
45+
as={as}
46+
/>
47+
)}
5348

54-
{groups.map(({ groupName, items }) => (
55-
<SidebarGroup
56-
key={groupName.toString()}
57-
groupName={groupName}
58-
items={items}
59-
pathname={pathname}
60-
as={as}
61-
className={styles.navigation}
62-
/>
63-
))}
64-
</aside>
65-
);
66-
};
49+
{groups.map(({ groupName, items }) => (
50+
<SidebarGroup
51+
key={groupName.toString()}
52+
groupName={groupName}
53+
items={items}
54+
pathname={pathname}
55+
as={as}
56+
className={styles.navigation}
57+
/>
58+
))}
59+
</aside>
60+
);
61+
}
62+
);
6763

6864
export default SideBar;

0 commit comments

Comments
 (0)