diff --git a/.eslintrc b/.eslintrc index 55a39f68..8b9730eb 100644 --- a/.eslintrc +++ b/.eslintrc @@ -15,5 +15,5 @@ "prettier" ], "plugins": ["tailwindcss"], - "ignorePatterns": ["jest.config.js"] + "ignorePatterns": ["**.config.js"] } diff --git a/src/components/Container/Container.tsx b/src/components/Container/Container.tsx index 8b87f5c2..42fa32af 100644 --- a/src/components/Container/Container.tsx +++ b/src/components/Container/Container.tsx @@ -10,7 +10,14 @@ const Container = forwardRef( const Component = as; return ( - + ); } ); diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 2252ee18..9950f281 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -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'; @@ -13,43 +19,70 @@ type MenuItem = { }; export type HeaderProps = { - logo: LogoProps; + logo: Omit; 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(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 ( - - - + - + ); } diff --git a/src/components/Header/Logo.tsx b/src/components/Header/Logo.tsx index b360c649..c07753ab 100644 --- a/src/components/Header/Logo.tsx +++ b/src/components/Header/Logo.tsx @@ -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 ( - {alt} + + {alt} + ); } diff --git a/src/components/Header/header.test.tsx b/src/components/Header/header.test.tsx index 59adb3d4..f225d7be 100644 --- a/src/components/Header/header.test.tsx +++ b/src/components/Header/header.test.tsx @@ -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(
); @@ -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(
); + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 50, + }); + + render(
); 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(
); + + 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); + } + }); + } + ); }); diff --git a/src/components/Heading/Heading.tsx b/src/components/Heading/Heading.tsx index 7188de31..483078d3 100644 --- a/src/components/Heading/Heading.tsx +++ b/src/components/Heading/Heading.tsx @@ -17,12 +17,14 @@ function Heading({ as, id, className, children, ...otherProps }: HeadingProps) { className={cn('group relative', className)} {...otherProps} > - - # - + {id && ( + + # + + )} {children} ); diff --git a/src/components/Heading/heading.test.tsx b/src/components/Heading/heading.test.tsx index 775ff496..22db7928 100644 --- a/src/components/Heading/heading.test.tsx +++ b/src/components/Heading/heading.test.tsx @@ -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'); }); @@ -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); }); }); diff --git a/src/hooks/__tests__/useRafState.test.ts b/src/hooks/__tests__/useRafState.test.ts index 8bb74d01..f521a52f 100644 --- a/src/hooks/__tests__/useRafState.test.ts +++ b/src/hooks/__tests__/useRafState.test.ts @@ -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)); + }); }); diff --git a/src/hooks/useRafState.ts b/src/hooks/useRafState.ts index 104cea78..ebaefd20 100644 --- a/src/hooks/useRafState.ts +++ b/src/hooks/useRafState.ts @@ -10,14 +10,10 @@ const useRafState = (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; }; diff --git a/tailwind.config.js b/tailwind.config.js index 80daa47e..9d21d20e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,9 +1,14 @@ +const colors = require('tailwindcss/colors'); + /** @type {import('tailwindcss').Config} */ const tailwindcssConfig = { content: ['./src/**/*.{js,ts,jsx,tsx}'], darkMode: 'class', theme: { extend: { + colors: { + primary: { ...colors.cyan, DEFAULT: colors.cyan[500] } + }, typography: { DEFAULT: { css: {