diff --git a/src/app/[lang]/Header.tsx b/src/app/[lang]/Header.tsx index ad79bfe..1e46f3e 100644 --- a/src/app/[lang]/Header.tsx +++ b/src/app/[lang]/Header.tsx @@ -8,7 +8,7 @@ import Link from '@/components/Link'; import useScroll, { ScrollHandler } from '@/hooks/useScroll'; import useRafState from '@/hooks/useRafState'; import cn from '@/utils/cn'; -import { clamp, pipe, toFixedNumber } from '@/utils/math'; +import { toFixedNumber } from '@/utils/math'; type HeaderProps = { avatar: React.ReactNode; @@ -17,91 +17,100 @@ type HeaderProps = { function Header({ avatar, children, scrollThreshold = 100 }: HeaderProps) { const [avatarScale, setAvatarScale] = useRafState(0); - const [willChange, setWillChange] = useState(true); - const [avatarTranslateY, setAvatarTranslateY] = useState(0); - const [headerTranslateY, setHeaderTranslateY] = useState(0); const [headerFixed, setHeaderFixed] = useState(true); - const headerRef = useRef(null); + const [headerTranslateY, setHeaderTranslateY] = useState(0); + const [willChange, setWillChange] = useState(true); const previousScrollY = useRef(0); + const handleScrollDown = useCallback( + (scrollY: number) => { + if (scrollY < scrollThreshold) { + setHeaderFixed(true); + } else if (headerFixed) { + setHeaderTranslateY(scrollY - scrollThreshold); + setHeaderFixed(false); + } + }, + [scrollThreshold, headerFixed] + ); + + const handleScrollUp = useCallback( + (scrollY: number) => { + const newHeaderTranslateY = scrollY - scrollThreshold * 2; + + if (newHeaderTranslateY > headerTranslateY) { + setHeaderTranslateY(newHeaderTranslateY); + } else if (scrollY - scrollThreshold < headerTranslateY) { + setHeaderFixed(true); + } + }, + [scrollThreshold, headerTranslateY] + ); + + const handleAvatarScale = useCallback( + (scrollY: number) => { + setWillChange(scrollY < scrollThreshold + 100); + if (scrollY > scrollThreshold) { + setAvatarScale(1); + } else { + setAvatarScale( + toFixedNumber(2)(1.5 - scrollY / (scrollThreshold * 2)) + ); + } + }, + [scrollThreshold, setAvatarScale] + ); + const scrollHandler = useCallback( ({ y }) => { const currentScrollY = Math.floor(y); - const headerHeight = headerRef.current?.clientHeight || 0; const deltaScrollY = currentScrollY - previousScrollY.current; const isScrollingDown = deltaScrollY > 0; previousScrollY.current = currentScrollY; if (isScrollingDown) { - if (currentScrollY < scrollThreshold) { - setHeaderFixed(true); - } else if (headerFixed) { - setHeaderTranslateY(currentScrollY); - setAvatarTranslateY(currentScrollY - headerHeight); - setHeaderFixed(false); - } + handleScrollDown(currentScrollY); } else { - const newHeaderTranslateY = currentScrollY - headerHeight; - - if (newHeaderTranslateY > headerTranslateY) { - setHeaderTranslateY(newHeaderTranslateY); - setAvatarTranslateY(newHeaderTranslateY - headerHeight); - } else if (currentScrollY < headerTranslateY) { - setHeaderFixed(true); - } + handleScrollUp(currentScrollY); } - setWillChange(currentScrollY < scrollThreshold + 100); - setAvatarScale( - pipe( - currentScrollY, - clamp(0, scrollThreshold), - (y) => y / (scrollThreshold * 2), - (y) => 1.5 - y, - toFixedNumber(2) - ) - ); + handleAvatarScale(currentScrollY); }, - [scrollThreshold, headerFixed, headerTranslateY, setAvatarScale] + [handleScrollDown, handleScrollUp, handleAvatarScale] ); useScroll({ handler: scrollHandler, initial: true }); const headerStyles = { + '--scroll-threshold': `-${scrollThreshold}px`, '--header-translate-y': `${headerTranslateY}px`, - } as CSSProperties; - - const avatarStyles = { - '--avatar-translate-y': `${avatarTranslateY}px`, '--avatar-scale': avatarScale, } as CSSProperties; return ( - <> - + +
{children} - - - {avatar} - - +
+
+
+ {avatar} +
+
+
); } @@ -112,14 +121,11 @@ type AvatarProps = { export const Avatar = ({ src, alt }: AvatarProps) => { return ( - + {alt} { }); it('should hide header on scroll down and show on scroll up', async () => { - Object.defineProperty(HTMLElement.prototype, 'clientHeight', { - configurable: true, - value: 50, - }); - render(
); const header = screen.getByRole('banner'); @@ -57,51 +52,40 @@ describe('Header component', () => { }); await waitFor(() => { - expect(header).toHaveStyle({ '--header-translate-y': '150px' }); + expect(header).toHaveStyle({ '--header-translate-y': '50px' }); }); act(() => { - window.scrollY = 251; + window.scrollY = 299; window.dispatchEvent(new Event('scroll')); - window.scrollY = 250; + window.scrollY = 300; window.dispatchEvent(new Event('scroll')); }); await waitFor(() => { - expect(header).toHaveStyle({ '--header-translate-y': '200px' }); + expect(header).toHaveStyle({ '--header-translate-y': '50px' }); }); act(() => { - window.scrollY = 1; + window.scrollY = 501; window.dispatchEvent(new Event('scroll')); - window.scrollY = 0; + window.scrollY = 500; window.dispatchEvent(new Event('scroll')); }); await waitFor(() => { - expect(header).toHaveStyle({ '--header-translate-y': '200px' }); + expect(header).toHaveStyle({ '--header-translate-y': '300px' }); }); - }); - - it('should calculate the correct styles even when `clientHeight` is `undefined`', async () => { - Object.defineProperty(HTMLElement.prototype, 'clientHeight', { - configurable: true, - value: undefined, - }); - - render(
); - - const header = screen.getByRole('banner'); - - expect(header.tagName).toBe('HEADER'); act(() => { - window.scrollY = 200; + window.scrollY = 1; + window.dispatchEvent(new Event('scroll')); + window.scrollY = 0; window.dispatchEvent(new Event('scroll')); }); await waitFor(() => { - expect(header).toHaveStyle({ '--header-translate-y': '200px' }); + expect(header).toHaveStyle({ '--header-translate-y': '300px' }); }); }); }); diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/page.tsx index 983e7eb..763bff8 100644 --- a/src/app/[lang]/page.tsx +++ b/src/app/[lang]/page.tsx @@ -24,7 +24,7 @@ async function RootPage({ params: { lang } }: RootParams) { return ( <> - +

{metadata.title}

diff --git a/src/app/[lang]/posts/[postId]/page.tsx b/src/app/[lang]/posts/[postId]/page.tsx index 0995456..25641e2 100644 --- a/src/app/[lang]/posts/[postId]/page.tsx +++ b/src/app/[lang]/posts/[postId]/page.tsx @@ -39,7 +39,7 @@ async function PostPage({ params: { postId } }: PostParams) { return ( <> - +

{frontmatter.title}

diff --git a/src/app/[lang]/posts/page.tsx b/src/app/[lang]/posts/page.tsx index 71c3cbd..f711d44 100644 --- a/src/app/[lang]/posts/page.tsx +++ b/src/app/[lang]/posts/page.tsx @@ -24,7 +24,7 @@ async function RootPage({ params: { lang } }: RootParams) { return ( <> - +

{metadata.title}

diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx index 5a30749..63e4c17 100644 --- a/src/components/Container/Container.tsx +++ b/src/components/Container/Container.tsx @@ -1,27 +1,24 @@ -import { HTMLAttributes, forwardRef } from 'react'; +import type { HTMLAttributes } from 'react'; import cn from '@/utils/cn'; type ContainerProps = { as?: 'main' | 'header' | 'footer' | 'div'; } & HTMLAttributes; -const Container = forwardRef( - function InternalContainer({ as = 'div', className, ...props }, ref) { - const Component = as; +function Container({ as = 'div', className, ...props }: ContainerProps) { + const Component = as; - return ( - - ); - } -); + return ( + + ); +} export default Container;