Skip to content

Commit

Permalink
💄 update web style
Browse files Browse the repository at this point in the history
  • Loading branch information
JohnsonMao committed Oct 14, 2023
1 parent d0e72db commit 6dd8623
Show file tree
Hide file tree
Showing 10 changed files with 196 additions and 63 deletions.
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@
"prettier"
],
"plugins": ["tailwindcss"],
"ignorePatterns": ["jest.config.js"]
"ignorePatterns": ["**.config.js"]
}
9 changes: 8 additions & 1 deletion src/components/Container/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@ const Container = forwardRef<HTMLDivElement, ContainerProps>(
const Component = as;

return (
<Component className={cn('mx-auto', className)} ref={ref} {...props} />
<Component
className={cn(
'border-x border-red-50 px-7 md:mx-8 lg:mx-16 lg:px-14',
className
)}
ref={ref}
{...props}
/>
);
}
);
Expand Down
73 changes: 53 additions & 20 deletions src/components/Header/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
'use client';

import { useEffect, useState, useRef } from 'react';
import { usePathname } from 'next/navigation';
import { CSSProperties, useEffect, useState, useRef } from 'react';

import useScroll from '@/hooks/useScroll';
import cn from '@/utils/cn';
import getLocale from '@/utils/getLocale';
import { clamp } from '@/utils/math';

import ThemeSwitcher from '../ThemeSwitcher';
import Link from '../Link';
import Logo, { LogoProps } from './Logo';
Expand All @@ -13,51 +19,78 @@ type MenuItem = {
};

export type HeaderProps = {
logo: LogoProps;
logo: Omit<LogoProps, 'scrollY' | 'scrollThreshold'>;
menu: MenuItem[];
};

function Header({ logo, menu }: HeaderProps) {
const beforeScrollY = useRef(0);
const [hideHeader, setHideHeader] = useState(false);
const pathname = usePathname();
const [translateY, setTranslateY] = useState(0);
const [scroll] = useScroll();
const headerRef = useRef<HTMLDivElement>(null);
const headerHeight = useRef(0);
const previousScrollY = useRef(0);
const currentScrollY = Math.floor(scroll.y);
const scrollThreshold = 120;

useEffect(() => {
headerHeight.current = headerRef.current?.clientHeight || 0;
}, []);

useEffect(() => {
const handleScroll = () => {
setHideHeader(beforeScrollY.current - window.scrollY < 0);
beforeScrollY.current = window.scrollY;
};
const scrollY = currentScrollY - scrollThreshold;

window.addEventListener('scroll', handleScroll);
if (scrollY <= 0) return setTranslateY(0);

return () => window.removeEventListener('scroll', handleScroll);
}, []);
const deltaScrollY = previousScrollY.current - scrollY;

previousScrollY.current = scrollY;

setTranslateY((pre) =>
clamp(pre + deltaScrollY, headerHeight.current * -1)
);
}, [currentScrollY]);

const isActiveLink = (href: LinkWithoutLocalePathProps['href']) => {
const locale = getLocale(pathname) || '';
const localePrefix = new RegExp(`^/${locale}/?`);
const adjustedPathname = pathname.replace(localePrefix, '/');

if (adjustedPathname === '/') return href === '/';

return adjustedPathname.startsWith(href) && href !== '/';
};

return (
<Container
as="header"
className={cn(
'sticky top-0 z-10 flex translate-y-0 items-center justify-between px-4 pt-2 transition-transform',
hideHeader && '-translate-y-full'
)}
ref={headerRef}
className="sticky top-0 z-10 flex translate-y-0 items-center justify-between py-7"
style={{ '--tw-translate-y': `${translateY}px` } as CSSProperties}
>
<Link href="/">
<Logo {...logo} />
</Link>
<Logo
{...logo}
scrollY={currentScrollY}
scrollThreshold={scrollThreshold}
/>
<nav>
<ul className="flex rounded-full bg-gray-900/60 px-2 backdrop-blur-md">
{menu.map(({ text, href }) => (
<li key={text}>
<Link
href={href}
className="block p-2 text-xl leading-none text-white/90 no-underline hover:text-white"
className={cn(
'block p-3 text-xl leading-none text-white/90 no-underline hover:text-white',
isActiveLink(href) && 'text-blue-500 hover:text-blue-500'
)}
>
{text}
</Link>
</li>
))}
</ul>
</nav>
<ThemeSwitcher className="rounded-full bg-gray-900/60 px-3 py-2 backdrop-blur-md" />
<ThemeSwitcher className="rounded-full bg-gray-900/60 p-3 backdrop-blur-md" />
</Container>
);
}
Expand Down
37 changes: 28 additions & 9 deletions src/components/Header/Logo.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
import type { StaticImport } from 'next/dist/shared/lib/get-img-props';
import type { CSSProperties } from 'react';
import { clamp } from '@/utils/math';
import Image from '../Image';
import Link from '../Link';

export type LogoProps = {
src: string | StaticImport;
alt: string;
scrollY: number;
scrollThreshold: number;
};

function Logo({ src, alt }: LogoProps) {
function Logo({ src, alt, scrollY, scrollThreshold }: LogoProps) {
const translateY = clamp(scrollY * -1, scrollThreshold * -1) + scrollThreshold;
const scale = 1.5 - clamp(scrollY, scrollThreshold) / (scrollThreshold * 2);
const style = {
'--tw-translate-y': `${translateY}px`,
'--tw-scale-x': scale,
'--tw-scale-y': scale,
} as CSSProperties;

return (
<Image
className="rounded-full border-4 border-black shadow-black drop-shadow-xl dark:border-slate-500"
width={50}
height={50}
src={src}
alt={alt}
priority
/>
<Link
href="/"
className="origin-top-left translate-y-0 scale-100"
style={style}
>
<Image
className="rounded-full shadow-black drop-shadow-xl"
width={40}
height={40}
src={src}
alt={alt}
priority
/>
</Link>
);
}

Expand Down
93 changes: 75 additions & 18 deletions src/components/Header/header.test.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,28 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import Header, { HeaderProps } from '.';

const mockPathname = jest.fn();

jest.mock('next/navigation', () => ({
usePathname: () => mockPathname(),
}));

describe('Header component', () => {
const logo = {
src: 'https://external.com/test.jpg',
alt: 'test logo image alt',
};
const menu: HeaderProps['menu'] = [
{ text: 'Home', href: '/' },
{ text: 'Post', href: '/posts' },
];

beforeEach(() => {
mockPathname.mockClear();
});

it('should render correct element', () => {
const logo = {
src: 'https://external.com/test.jpg',
alt: 'test logo image alt',
};
const menu: HeaderProps['menu'] = [
{ text: 'menu_A', href: '/posts/a' },
{ text: 'menu_B', href: '/posts/b' },
];
mockPathname.mockReturnValueOnce('/');

render(<Header logo={logo} menu={menu} />);

Expand All @@ -33,34 +45,79 @@ describe('Header component', () => {
});

it('should hide header on scroll down and show on scroll up', async () => {
const logo = {
src: 'https://external.com/test.jpg',
alt: 'test logo image alt',
};
const menu: HeaderProps['menu'] = [];
mockPathname.mockReturnValueOnce('/');

render(<Header logo={logo} menu={menu} />);
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
configurable: true,
value: 50,
});

render(<Header logo={logo} menu={[]} />);

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

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

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

await waitFor(() => {
expect(header).toHaveStyle({ '--tw-translate-y': '0px' });
});

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

await waitFor(() => {
expect(header).toHaveClass('-translate-y-full');
expect(header).toHaveStyle({ '--tw-translate-y': '-50px' });
});

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

await waitFor(() => {
expect(header).not.toHaveClass('-translate-y-full');
expect(header).toHaveStyle({ '--tw-translate-y': '-20px' });
});

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

await waitFor(() => {
expect(header).toHaveStyle({ '--tw-translate-y': '0px' });
});
});

it.each([
['/', menu[0].text],
['/posts', menu[1].text],
['/en', menu[0].text],
['/en/posts', menu[1].text],
['/en/posts/test', menu[1].text],
])(
'should render correct active link based on the pathname "%s"',
(pathname, activeLinkText) => {
mockPathname.mockReturnValueOnce(pathname);

render(<Header logo={logo} menu={menu} />);

const links = screen.getAllByRole('link');
const activeClassName = 'text-blue-500 hover:text-blue-500';

links.forEach((link) => {
if (link.textContent === activeLinkText) {
expect(link).toHaveClass(activeClassName);
} else {
expect(link).not.toHaveClass(activeClassName);
}
});
}
);
});
14 changes: 8 additions & 6 deletions src/components/Heading/Heading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ function Heading({ as, id, className, children, ...otherProps }: HeadingProps) {
className={cn('group relative', className)}
{...otherProps}
>
<Link
href={`#${id}`}
className="absolute -left-6 no-underline opacity-0 group-hover:opacity-100"
>
#
</Link>
{id && (
<Link
href={`#${id}`}
className="absolute -left-6 no-underline opacity-0 group-hover:opacity-100"
>
#
</Link>
)}
{children}
</Component>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/Heading/heading.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('Heading component', () => {
const heading = screen.getByRole('heading');

expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent(`#${name}`);
expect(heading).toHaveTextContent(name);
expect(heading.tagName).toBe('H2');
});

Expand All @@ -29,7 +29,7 @@ describe('Heading component', () => {

const heading = screen.getByRole('heading');

expect(heading).toHaveTextContent(`#${name}`);
expect(heading).toHaveTextContent(name);
expect(heading.tagName).toBe(expected);
});
});
14 changes: 14 additions & 0 deletions src/hooks/__tests__/useRafState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,18 @@ describe('useRafState hook', () => {

await waitFor(() => expect(result.current[0]).toBe(5));
});

it('should cancel update the state after unmount', async () => {
const { result, unmount } = renderHook(() => useRafState(1));

expect(result.current[0]).toBe(1);

act(() => result.current[1](2));

expect(result.current[0]).toBe(1);

unmount();

await waitFor(() => expect(result.current[0]).toBe(1));
});
});
8 changes: 2 additions & 6 deletions src/hooks/useRafState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,10 @@ const useRafState = <S>(initialState: S | (() => S)) => {
const setRafState = useCallback((value: S | ((prevState: S) => S)) => {
cancelAnimationFrame(frame.current);

frame.current = requestAnimationFrame(() => {
setState(value);
});
frame.current = requestAnimationFrame(() => setState(value));
}, []);

useEffect(() => {
return () => cancelAnimationFrame(frame.current);
}, [])
useEffect(() => () => cancelAnimationFrame(frame.current), []);

return [state, setRafState] as const;
};
Expand Down
Loading

0 comments on commit 6dd8623

Please sign in to comment.