Skip to content

Commit

Permalink
Merge pull request #114 from JohnsonMao/refactor/header-component
Browse files Browse the repository at this point in the history
Refactor/header component
  • Loading branch information
JohnsonMao authored Nov 4, 2023
2 parents b29ca58 + 49939b5 commit 4b0b07a
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 114 deletions.
138 changes: 72 additions & 66 deletions src/app/[lang]/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<HTMLDivElement>(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<ScrollHandler>(
({ 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 (
<>
<Container
as="header"
ref={headerRef}
className={cn(
'sticky top-0 z-10 flex items-center justify-between py-7 before:w-11 before:content-[""]',
!headerFixed && 'top-auto translate-y-[var(--header-translate-y)]',
willChange && 'will-change-[top]'
)}
style={headerStyles}
>
<Container
as="header"
className={cn(
'sticky top-auto z-10 translate-y-[var(--header-translate-y)]',
headerFixed && 'top-[var(--scroll-threshold)] translate-y-0'
)}
style={headerStyles}
>
<div className="sticky top-0 z-10 flex items-center justify-between py-7 before:w-11 before:content-['']">
{children}
</Container>
<Container
className={cn(
'pointer-events-none sticky top-0 z-10 pt-7',
!headerFixed && 'top-auto translate-y-[var(--avatar-translate-y)]',
willChange && 'will-change-transform'
)}
style={avatarStyles}
>
{avatar}
</Container>
</>
</div>
<div className="py-7">
<div
className={cn(
'relative z-10 inline-block origin-bottom-left scale-[var(--avatar-scale)] rounded-full',
willChange && 'will-change-transform'
)}
>
{avatar}
</div>
</div>
</Container>
);
}

Expand All @@ -112,14 +121,11 @@ type AvatarProps = {

export const Avatar = ({ src, alt }: AvatarProps) => {
return (
<Link
href="/"
className="pointer-events-auto inline-block origin-bottom-left scale-[var(--avatar-scale)]"
>
<Link href="/" className="inline-block rounded-full">
<Image
className="rounded-full shadow-black drop-shadow-xl"
width={42}
height={42}
className="inline-block rounded-full border-2 border-transparent"
width={44}
height={44}
src={src}
alt={alt}
priority
Expand Down
38 changes: 11 additions & 27 deletions src/app/[lang]/__tests__/header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ describe('Header component', () => {
});

it('should hide header on scroll down and show on scroll up', async () => {
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 50,
});

render(<Header avatar={avatar} scrollThreshold={100} />);

const header = screen.getByRole('banner');
Expand Down Expand Up @@ -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(<Header avatar={avatar} scrollThreshold={100} />);

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' });
});
});
});
2 changes: 1 addition & 1 deletion src/app/[lang]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async function RootPage({ params: { lang } }: RootParams) {

return (
<>
<Container className="py-8">
<Container className="pb-8">
<H1 className="mb-4 text-3xl font-bold dark:text-white">
{metadata.title}
</H1>
Expand Down
2 changes: 1 addition & 1 deletion src/app/[lang]/posts/[postId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async function PostPage({ params: { postId } }: PostParams) {

return (
<>
<Container className="py-8">
<Container className="pb-8">
<H1 className="mb-4 text-3xl font-bold dark:text-white">
{frontmatter.title}
</H1>
Expand Down
2 changes: 1 addition & 1 deletion src/app/[lang]/posts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async function RootPage({ params: { lang } }: RootParams) {

return (
<>
<Container className="py-8">
<Container className="pb-8">
<H1 className="mb-4 text-3xl font-bold dark:text-white">
{metadata.title}
</H1>
Expand Down
33 changes: 15 additions & 18 deletions src/components/Container/Container.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;

const Container = forwardRef<HTMLDivElement, ContainerProps>(
function InternalContainer({ as = 'div', className, ...props }, ref) {
const Component = as;
function Container({ as = 'div', className, ...props }: ContainerProps) {
const Component = as;

return (
<Component
className={cn(
'px-7 md:mx-8 lg:mx-16 lg:px-14',
as === 'main' &&
'mt-4 rounded-lg border-2 border-zinc-500 bg-zinc-100/70 dark:bg-zinc-900/70',
className
)}
ref={ref}
{...props}
/>
);
}
);
return (
<Component
className={cn(
'px-7 mx-2 md:mx-8 lg:mx-16 lg:px-14',
as === 'main' &&
'rounded-lg border-2 border-zinc-500 bg-zinc-100/70 dark:bg-zinc-900/70',
className
)}
{...props}
/>
);
}

export default Container;

0 comments on commit 4b0b07a

Please sign in to comment.