Skip to content

Commit b98e45d

Browse files
committed
feat: make navbar re-appear on upward scroll
1 parent 3036b2d commit b98e45d

File tree

7 files changed

+176
-1
lines changed

7 files changed

+176
-1
lines changed

apps/site/components/withNavBar.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import NavBar from '@node-core/ui-components/Containers/NavBar';
88
import styles from '@node-core/ui-components/Containers/NavBar/index.module.css';
99
import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub';
1010
import { availableLocales } from '@node-core/website-i18n';
11+
import classNames from 'classnames';
1112
import dynamic from 'next/dynamic';
1213
import { useLocale, useTranslations } from 'next-intl';
1314
import { useTheme } from 'next-themes';
@@ -16,6 +17,7 @@ import SearchButton from '#site/components/Common/Searchbox';
1617
import Link from '#site/components/Link';
1718
import WithBanner from '#site/components/withBanner';
1819
import WithNodejsLogo from '#site/components/withNodejsLogo';
20+
import { useScrollDirection } from '#site/hooks/client';
1921
import { useSiteNavigation } from '#site/hooks/generic';
2022
import { useRouter, usePathname } from '#site/navigation.mjs';
2123

@@ -42,11 +44,17 @@ const WithNavBar: FC = () => {
4244

4345
const locale = useLocale();
4446

47+
const scrollDirection = useScrollDirection();
48+
4549
const changeLanguage = (locale: SimpleLocaleConfig) =>
4650
replace(pathname!, { locale: locale.code });
4751

4852
return (
49-
<div>
53+
<div
54+
className={classNames(styles.navBarWrapper, {
55+
[styles.hidden]: scrollDirection === 'down',
56+
})}
57+
>
5058
<SkipToContentButton>
5159
{t('components.common.skipToContent')}
5260
</SkipToContentButton>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import assert from 'node:assert/strict';
2+
import { afterEach, beforeEach, describe, it } from 'node:test';
3+
4+
import { renderHook, act } from '@testing-library/react';
5+
6+
import useScrollDirection from '#site/hooks/client/useScrollDirection.js';
7+
8+
describe('useScrollDirection', () => {
9+
let scrollY;
10+
let originalRAF;
11+
12+
beforeEach(() => {
13+
scrollY = 0;
14+
15+
Object.defineProperty(window, 'scrollY', {
16+
get: () => scrollY,
17+
configurable: true,
18+
});
19+
20+
originalRAF = window.requestAnimationFrame;
21+
Object.defineProperty(window, 'requestAnimationFrame', {
22+
value: cb => {
23+
cb();
24+
return 1;
25+
},
26+
writable: true,
27+
configurable: true,
28+
});
29+
});
30+
31+
afterEach(() => {
32+
window.requestAnimationFrame = originalRAF;
33+
});
34+
35+
it('should return null initially (at top of page)', () => {
36+
const { result } = renderHook(() => useScrollDirection());
37+
assert.equal(result.current, null);
38+
});
39+
40+
it('should return "down" when scrolling down past threshold', () => {
41+
const { result } = renderHook(() => useScrollDirection());
42+
43+
act(() => {
44+
scrollY = 100;
45+
window.dispatchEvent(new Event('scroll'));
46+
});
47+
48+
assert.equal(result.current, 'down');
49+
});
50+
51+
it('should return "up" when scrolling up past threshold', () => {
52+
const { result } = renderHook(() => useScrollDirection());
53+
54+
act(() => {
55+
scrollY = 100;
56+
window.dispatchEvent(new Event('scroll'));
57+
});
58+
59+
act(() => {
60+
scrollY = 50;
61+
window.dispatchEvent(new Event('scroll'));
62+
});
63+
64+
assert.equal(result.current, 'up');
65+
});
66+
67+
it('should not change direction for scroll less than threshold', () => {
68+
const { result } = renderHook(() => useScrollDirection());
69+
70+
act(() => {
71+
scrollY = 5;
72+
window.dispatchEvent(new Event('scroll'));
73+
});
74+
75+
assert.equal(result.current, null);
76+
});
77+
78+
it('should return null when scrolling back to top', () => {
79+
const { result } = renderHook(() => useScrollDirection());
80+
81+
act(() => {
82+
scrollY = 100;
83+
window.dispatchEvent(new Event('scroll'));
84+
});
85+
86+
assert.equal(result.current, 'down');
87+
88+
act(() => {
89+
scrollY = 0;
90+
window.dispatchEvent(new Event('scroll'));
91+
});
92+
93+
assert.equal(result.current, null);
94+
});
95+
});

apps/site/hooks/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export { default as useMediaQuery } from './useMediaQuery';
33
export { default as useClientContext } from './useClientContext';
44
export { default as useScrollToElement } from './useScrollToElement';
55
export { default as useScroll } from './useScroll';
6+
export { default as useScrollDirection } from './useScrollDirection';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
'use client';
2+
3+
import { useState, useEffect, useRef } from 'react';
4+
5+
type ScrollDirection = 'up' | 'down' | null;
6+
7+
const SCROLL_THRESHOLD = 10;
8+
9+
const useScrollDirection = (): ScrollDirection => {
10+
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>(null);
11+
const lastScrollY = useRef(0);
12+
const ticking = useRef(false);
13+
14+
useEffect(() => {
15+
const updateScrollDirection = () => {
16+
const currentScrollY = window.scrollY;
17+
18+
if (currentScrollY <= 0) {
19+
setScrollDirection(null);
20+
lastScrollY.current = currentScrollY;
21+
ticking.current = false;
22+
return;
23+
}
24+
25+
const diff = Math.abs(currentScrollY - lastScrollY.current);
26+
27+
if (diff < SCROLL_THRESHOLD) {
28+
ticking.current = false;
29+
return;
30+
}
31+
32+
setScrollDirection(currentScrollY > lastScrollY.current ? 'down' : 'up');
33+
lastScrollY.current = currentScrollY;
34+
ticking.current = false;
35+
};
36+
37+
const onScroll = () => {
38+
if (!ticking.current) {
39+
ticking.current = true;
40+
window.requestAnimationFrame(updateScrollDirection);
41+
}
42+
};
43+
44+
window.addEventListener('scroll', onScroll, { passive: true });
45+
46+
return () => window.removeEventListener('scroll', onScroll);
47+
}, []);
48+
49+
return scrollDirection;
50+
};
51+
52+
export default useScrollDirection;

apps/site/hooks/server/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as useClientContext } from './useClientContext';
22
export { default as useScrollToElement } from './useScrollToElement';
33
export { default as useScroll } from './useScroll';
4+
export { default as useScrollDirection } from './useScrollDirection';
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const useScrollDirection = () => {
2+
throw new Error('Attempted to call useScrollDirection from RSC');
3+
};
4+
5+
export default useScrollDirection;

packages/ui-components/src/Containers/NavBar/index.module.css

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,16 @@
135135
}
136136
}
137137
}
138+
139+
.navBarWrapper {
140+
@apply xl:sticky
141+
xl:top-0
142+
xl:z-50
143+
xl:transition-transform
144+
xl:duration-300
145+
xl:ease-in-out;
146+
}
147+
148+
.navBarWrapper.hidden {
149+
@apply xl:-translate-y-full;
150+
}

0 commit comments

Comments
 (0)