diff --git a/apps/site/components/withNavBar.tsx b/apps/site/components/withNavBar.tsx index 4b370fe48fd9b..06285712a5ba6 100644 --- a/apps/site/components/withNavBar.tsx +++ b/apps/site/components/withNavBar.tsx @@ -8,6 +8,7 @@ import NavBar from '@node-core/ui-components/Containers/NavBar'; import styles from '@node-core/ui-components/Containers/NavBar/index.module.css'; import GitHubIcon from '@node-core/ui-components/Icons/Social/GitHub'; import { availableLocales } from '@node-core/website-i18n'; +import classNames from 'classnames'; import dynamic from 'next/dynamic'; import { useLocale, useTranslations } from 'next-intl'; import { useTheme } from 'next-themes'; @@ -16,6 +17,7 @@ import SearchButton from '#site/components/Common/Searchbox'; import Link from '#site/components/Link'; import WithBanner from '#site/components/withBanner'; import WithNodejsLogo from '#site/components/withNodejsLogo'; +import { useScrollDirection } from '#site/hooks/client'; import { useSiteNavigation } from '#site/hooks/generic'; import { useRouter, usePathname } from '#site/navigation.mjs'; @@ -42,11 +44,17 @@ const WithNavBar: FC = () => { const locale = useLocale(); + const scrollDirection = useScrollDirection(); + const changeLanguage = (locale: SimpleLocaleConfig) => replace(pathname!, { locale: locale.code }); return ( -
+
{t('components.common.skipToContent')} diff --git a/apps/site/hooks/client/__tests__/useScrollDirection.test.mjs b/apps/site/hooks/client/__tests__/useScrollDirection.test.mjs new file mode 100644 index 0000000000000..94b9d016a8086 --- /dev/null +++ b/apps/site/hooks/client/__tests__/useScrollDirection.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +import { renderHook, act } from '@testing-library/react'; + +import useScrollDirection from '#site/hooks/client/useScrollDirection.js'; + +describe('useScrollDirection', () => { + let scrollY; + let originalRAF; + + beforeEach(() => { + scrollY = 0; + + Object.defineProperty(window, 'scrollY', { + get: () => scrollY, + configurable: true, + }); + + originalRAF = window.requestAnimationFrame; + Object.defineProperty(window, 'requestAnimationFrame', { + value: cb => { + cb(); + return 1; + }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + window.requestAnimationFrame = originalRAF; + }); + + it('should return null initially (at top of page)', () => { + const { result } = renderHook(() => useScrollDirection()); + assert.equal(result.current, null); + }); + + it('should return "down" when scrolling down past threshold', () => { + const { result } = renderHook(() => useScrollDirection()); + + act(() => { + scrollY = 100; + window.dispatchEvent(new Event('scroll')); + }); + + assert.equal(result.current, 'down'); + }); + + it('should return "up" when scrolling up past threshold', () => { + const { result } = renderHook(() => useScrollDirection()); + + act(() => { + scrollY = 100; + window.dispatchEvent(new Event('scroll')); + }); + + act(() => { + scrollY = 50; + window.dispatchEvent(new Event('scroll')); + }); + + assert.equal(result.current, 'up'); + }); + + it('should not change direction for scroll less than threshold', () => { + const { result } = renderHook(() => useScrollDirection()); + + act(() => { + scrollY = 5; + window.dispatchEvent(new Event('scroll')); + }); + + assert.equal(result.current, null); + }); + + it('should return null when scrolling back to top', () => { + const { result } = renderHook(() => useScrollDirection()); + + act(() => { + scrollY = 100; + window.dispatchEvent(new Event('scroll')); + }); + + assert.equal(result.current, 'down'); + + act(() => { + scrollY = 0; + window.dispatchEvent(new Event('scroll')); + }); + + assert.equal(result.current, null); + }); +}); diff --git a/apps/site/hooks/client/index.ts b/apps/site/hooks/client/index.ts index bd399cf4e8b4a..3d533ed878ee2 100644 --- a/apps/site/hooks/client/index.ts +++ b/apps/site/hooks/client/index.ts @@ -3,3 +3,4 @@ export { default as useMediaQuery } from './useMediaQuery'; export { default as useClientContext } from './useClientContext'; export { default as useScrollToElement } from './useScrollToElement'; export { default as useScroll } from './useScroll'; +export { default as useScrollDirection } from './useScrollDirection'; diff --git a/apps/site/hooks/client/useScrollDirection.ts b/apps/site/hooks/client/useScrollDirection.ts new file mode 100644 index 0000000000000..a88aca4aadc17 --- /dev/null +++ b/apps/site/hooks/client/useScrollDirection.ts @@ -0,0 +1,52 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; + +type ScrollDirection = 'up' | 'down' | null; + +const SCROLL_THRESHOLD = 10; + +const useScrollDirection = (): ScrollDirection => { + const [scrollDirection, setScrollDirection] = useState(null); + const lastScrollY = useRef(0); + const ticking = useRef(false); + + useEffect(() => { + const updateScrollDirection = () => { + const currentScrollY = window.scrollY; + + if (currentScrollY <= 0) { + setScrollDirection(null); + lastScrollY.current = currentScrollY; + ticking.current = false; + return; + } + + const diff = Math.abs(currentScrollY - lastScrollY.current); + + if (diff < SCROLL_THRESHOLD) { + ticking.current = false; + return; + } + + setScrollDirection(currentScrollY > lastScrollY.current ? 'down' : 'up'); + lastScrollY.current = currentScrollY; + ticking.current = false; + }; + + const onScroll = () => { + if (!ticking.current) { + ticking.current = true; + window.requestAnimationFrame(updateScrollDirection); + } + }; + + window.addEventListener('scroll', onScroll, { passive: true }); + + return () => window.removeEventListener('scroll', onScroll); + }, []); + + return scrollDirection; +}; + +export default useScrollDirection; diff --git a/apps/site/hooks/server/index.ts b/apps/site/hooks/server/index.ts index 4493382a557e3..5e4915d6de740 100644 --- a/apps/site/hooks/server/index.ts +++ b/apps/site/hooks/server/index.ts @@ -1,3 +1,4 @@ export { default as useClientContext } from './useClientContext'; export { default as useScrollToElement } from './useScrollToElement'; export { default as useScroll } from './useScroll'; +export { default as useScrollDirection } from './useScrollDirection'; diff --git a/apps/site/hooks/server/useScrollDirection.ts b/apps/site/hooks/server/useScrollDirection.ts new file mode 100644 index 0000000000000..105a0159323af --- /dev/null +++ b/apps/site/hooks/server/useScrollDirection.ts @@ -0,0 +1,5 @@ +const useScrollDirection = () => { + throw new Error('Attempted to call useScrollDirection from RSC'); +}; + +export default useScrollDirection; diff --git a/packages/ui-components/src/Containers/NavBar/index.module.css b/packages/ui-components/src/Containers/NavBar/index.module.css index a7f3a189d5cc3..6cb865c73ff8f 100644 --- a/packages/ui-components/src/Containers/NavBar/index.module.css +++ b/packages/ui-components/src/Containers/NavBar/index.module.css @@ -135,3 +135,16 @@ } } } + +.navBarWrapper { + @apply xl:sticky + xl:top-0 + xl:z-50 + xl:transition-transform + xl:duration-300 + xl:ease-in-out; +} + +.navBarWrapper.hidden { + @apply xl:-translate-y-full; +}