Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

💄 update web style #104

Merged
merged 1 commit into from
Oct 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading