Skip to content

Commit

Permalink
Merge pull request #107 from JohnsonMao/feature/pipe
Browse files Browse the repository at this point in the history
✨ add pipe function
✨ add toFixedNumber function and refactor clamp
✨ update scroll hook functionality
♻️ refactor header render logic
  • Loading branch information
JohnsonMao authored Oct 21, 2023
2 parents 73cff25 + faa24d1 commit 62ecd5b
Show file tree
Hide file tree
Showing 7 changed files with 333 additions and 103 deletions.
106 changes: 74 additions & 32 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use client';

import type { StaticImport } from 'next/dist/shared/lib/get-img-props';
import { CSSProperties, useRef } from 'react';
import { CSSProperties, useCallback, useRef } from 'react';

import useScroll from '@/hooks/useScroll';
import { clamp } from '@/utils/math';
import useScroll, { ScrollHandler } from '@/hooks/useScroll';
import useRafState from '@/hooks/useRafState';
import cn from '@/utils/cn';
import { clamp, pipe, toFixedNumber } from '@/utils/math';

import Container from '../Container';
import Image from '../Image';
Expand All @@ -23,40 +25,77 @@ export type HeaderProps = {
} & MenuProps;

function Header({ avatar, menu, scrollThreshold = 100 }: HeaderProps) {
const [scroll] = useScroll();
const [headerFixed, setHeaderFixed] = useRafState(true);
const [headerTranslateY, setHeaderTranslateY] = useRafState(0);
const [avatarTranslateY, setAvatarTranslateY] = useRafState(0);
const [avatarScale, setAvatarScale] = useRafState(0);
const headerRef = useRef<HTMLDivElement>(null);
const headerTranslateY = useRef(0);
const previousScrollY = useRef(0);
const currentScrollY = Math.floor(scroll.y);
const avatarTranslateY = (
clamp(currentScrollY * -1, scrollThreshold * -1) + scrollThreshold
).toFixed(2);
const avatarScale = (
1.5 -
clamp(currentScrollY, scrollThreshold) / (scrollThreshold * 2)
).toFixed(2);

const calcHeaderTranslateY = () => {
const scrollPosition = currentScrollY - scrollThreshold;

if (scrollPosition <= 0) {
headerTranslateY.current = 0;
} else {
const deltaScrollY = previousScrollY.current - scrollPosition;
const headerHeight = headerRef.current?.clientHeight || 0;

previousScrollY.current = scrollPosition;
headerTranslateY.current = clamp(
headerTranslateY.current + deltaScrollY,
headerHeight * -1

const handleHeader = useCallback(
(currentScrollY: number, threshold: number) => {
const deltaScrollY = currentScrollY - previousScrollY.current;
const isScrollingDown = deltaScrollY > 0;

previousScrollY.current = currentScrollY;

if (isScrollingDown) {
if (currentScrollY < threshold) {
setHeaderFixed(true);
} else if (headerFixed) {
setHeaderTranslateY(currentScrollY);
setHeaderFixed(false);
}
} else {
const headerHeight = headerRef.current?.clientHeight || 0;
const newHeaderTranslateY = currentScrollY - headerHeight;

if (newHeaderTranslateY > headerTranslateY) {
setHeaderTranslateY(newHeaderTranslateY);
} else if (currentScrollY < headerTranslateY) {
setHeaderFixed(true);
}
}
},
[headerFixed, setHeaderFixed, headerTranslateY, setHeaderTranslateY]
);

const handleAvatar = useCallback(
(currentScrollY: number, threshold: number) => {
setAvatarTranslateY(
pipe(
currentScrollY * -1,
clamp(0, threshold * -1),
(y) => y + threshold,
toFixedNumber(2)
)
);
setAvatarScale(
pipe(
currentScrollY,
clamp(0, threshold),
(y) => y / (threshold * 2),
(y) => 1.5 - y,
toFixedNumber(2)
)
);
}
},
[setAvatarTranslateY, setAvatarScale]
);

const scrollHandler = useCallback<ScrollHandler>(
({ y }) => {
const currentScrollY = Math.floor(y);
handleHeader(currentScrollY, scrollThreshold);
handleAvatar(currentScrollY, scrollThreshold);
},
[scrollThreshold, handleHeader, handleAvatar]
);

return headerTranslateY.current;
};
useScroll({ handler: scrollHandler, initial: true });

const headerStyles = {
'--header-translate-y': `${calcHeaderTranslateY()}px`,
'--header-translate-y': `${headerTranslateY}px`,
'--avatar-translate-y': `${avatarTranslateY}px`,
'--avatar-scale': avatarScale,
} as CSSProperties;
Expand All @@ -65,7 +104,10 @@ function Header({ avatar, menu, scrollThreshold = 100 }: HeaderProps) {
<Container
as="header"
ref={headerRef}
className="sticky top-0 z-10 flex translate-y-[var(--header-translate-y)] items-center justify-between py-7 will-change-transform"
className={cn(
'sticky top-0 z-10 flex items-center justify-between py-7 will-change-[top]',
!headerFixed && 'top-auto translate-y-[var(--header-translate-y)]'
)}
style={headerStyles}
>
<Link
Expand Down
2 changes: 1 addition & 1 deletion src/components/Header/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function Menu({ menu }: MenuProps) {

return (
<nav>
<ul className="flex rounded-full bg-gray-900/60 px-2 backdrop-blur-md">
<ul className="flex rounded-full bg-gray-900/60 px-2 backdrop-blur-lg">
{menu.map(({ text, href }) => (
<li key={text}>
<Link
Expand Down
54 changes: 32 additions & 22 deletions src/components/Header/header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,65 +26,75 @@ describe('Header component', () => {
value: 50,
});

render(<Header avatar={avatar} menu={[]} scrollThreshold={120} />);
render(<Header avatar={avatar} menu={[]} scrollThreshold={100} />);

const header = screen.getByRole('banner');

expect(header.tagName).toBe('HEADER');

act(() => {
window.scrollY = 19;
window.dispatchEvent(new Event('scroll'));
window.scrollY = 20;
window.dispatchEvent(new Event('scroll'));
});

await waitFor(() => {
expect(header).toHaveStyle({ '--header-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '100.00px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.42' });
expect(header).toHaveStyle({ '--avatar-translate-y': '80px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.4' });
});

act(() => {
window.scrollY = 170;
window.scrollY = 98;
window.dispatchEvent(new Event('scroll'));
window.scrollY = 99;
window.dispatchEvent(new Event('scroll'));
});

await waitFor(() => {
expect(header).toHaveStyle({ '--header-translate-y': '-50px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '0.00px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.00' });
expect(header).toHaveStyle({ '--header-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '1px' });
expect(header).toHaveStyle({ '--avatar-scale': '1' });
});

act(() => {
window.scrollY = 140;
window.scrollY = 149;
window.dispatchEvent(new Event('scroll'));
window.scrollY = 150;
window.dispatchEvent(new Event('scroll'));
});

await waitFor(() => {
expect(header).toHaveStyle({ '--header-translate-y': '-20px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '0.00px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.00' });
expect(header).toHaveStyle({ '--header-translate-y': '150px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-scale': '1' });
});

act(() => {
window.scrollY = 80;
window.scrollY = 251;
window.dispatchEvent(new Event('scroll'));
window.scrollY = 250;
window.dispatchEvent(new Event('scroll'));
});

await waitFor(() => {
expect(header).toHaveStyle({ '--header-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '40.00px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.17' });
expect(header).toHaveStyle({ '--header-translate-y': '200px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-scale': '1' });
});

act(() => {
window.scrollY = 1;
window.dispatchEvent(new Event('scroll'));
window.scrollY = 0;
window.dispatchEvent(new Event('scroll'));
});

await waitFor(() => {
expect(header).toHaveStyle({ '--header-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '120.00px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.50' });
expect(header).toHaveStyle({ '--header-translate-y': '200px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '100px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.5' });
});
});

Expand All @@ -94,7 +104,7 @@ describe('Header component', () => {
value: undefined,
});

render(<Header avatar={avatar} menu={[]} scrollThreshold={120} />);
render(<Header avatar={avatar} menu={[]} scrollThreshold={100} />);

const header = screen.getByRole('banner');

Expand All @@ -106,9 +116,9 @@ describe('Header component', () => {
});

await waitFor(() => {
expect(header).toHaveStyle({ '--header-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '0.00px' });
expect(header).toHaveStyle({ '--avatar-scale': '1.00' });
expect(header).toHaveStyle({ '--header-translate-y': '200px' });
expect(header).toHaveStyle({ '--avatar-translate-y': '0px' });
expect(header).toHaveStyle({ '--avatar-scale': '1' });
});
});
});
Loading

0 comments on commit 62ecd5b

Please sign in to comment.