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

Fix/header #31

Merged
merged 11 commits into from
Sep 19, 2024
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"dependencies": {
"@vanilla-extract/css": "^1.15.5",
"firebase": "^10.13.1",
"lottie-react": "^2.4.0",
"lottie-light-react": "^2.4.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.1"
Expand Down
41 changes: 34 additions & 7 deletions src/components/Header/.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,19 @@ import { vars } from "../../shared/styles/vars.css";
const mobileBreakpoint = "1000px";

export const headerStyles = style({
position: "fixed",
top: 0,
left: 0,
right: 0,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "10px 20px",
position: "relative",
padding: "0.75rem 1.5rem",
height: "2.25rem",
backdropFilter: "blur(10px)",
backgroundColor: "rgba(54,181,203,0.5)",
zIndex: 1000,
boxShadow: "0 4px 6px rgba(54,181,203, 0.4)",
});

export const logoStyles = style({
Expand All @@ -24,6 +32,14 @@ export const currentPageStyles = style({
color: "white",
fontSize: "16px",
fontWeight: "bold",
"@media": {
[`(max-width: ${mobileBreakpoint})`]: {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
},
},
});

export const toggleBtnStyles = style({
Expand All @@ -45,25 +61,33 @@ const menuTransition = "transform 0.3s ease-out, opacity 0.3s ease-out";

export const menuStyles = style({
position: "absolute",
display: "inline-block",
top: "100%",
left: 0,
right: 0,
padding: "20px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
transform: "translateX(-100%)",
boxShadow: "none",
transform: "translateX(100%)",
opacity: 0,
pointerEvents: "none",
transition: menuTransition,
transition: `${menuTransition}, background-color 0.3s ease-out, border-radius 0.3s ease-out`,
backgroundColor: "transparent",
borderTopLeftRadius: "0",
borderBottomLeftRadius: "0",
marginTop: "1rem",
selectors: {
"&.active": {
transform: "translateX(0)",
opacity: 1,
pointerEvents: "auto",
backdropFilter: "blur(5px)",
backgroundColor: "rgba(54,181,203,0.9)",
borderTopLeftRadius: "24px",
borderBottomLeftRadius: "24px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
},
},
"@media": {
[`(min-width: ${mobileBreakpoint})`]: {
marginTop: "0",
position: "static",
display: "flex",
flexDirection: "row",
Expand All @@ -73,6 +97,9 @@ export const menuStyles = style({
opacity: 1,
pointerEvents: "auto",
transition: "none",
backgroundColor: "transparent",
borderTopLeftRadius: "0",
borderBottomLeftRadius: "0",
},
},
});
Expand Down
45 changes: 45 additions & 0 deletions src/components/Header/customHooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useCallback, useEffect, useState } from "react";

/**
* 메뉴 버튼의 토글 상태를 관리하는 커스텀 훅
* @param initialState
* @returns [state, toggle]
*/
export const useToggle = (initialState = false) => {
const [state, setState] = useState(initialState);
const toggle = useCallback(() => setState((state) => !state), []);
return [state, toggle] as const;
};

/**
* 미디어 쿼리를 사용하는 커스텀 훅
* @param query 원하는 미디어 쿼리
* @returns 미디어 쿼리 매치 여부
*/
export const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(() => {
if (typeof window !== "undefined" && window.matchMedia) {
return window.matchMedia(query).matches;
}
return false; // SSR 환경 또는 matchMedia 미지원 브라우저에서 기본값 설정
});

useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) {
return;
}

const media = window.matchMedia(query);
const listener = (e: MediaQueryListEvent) => setMatches(e.matches);

if (media.addEventListener) {
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
} else if (media.addListener) {
media.addListener(listener);
return () => media.removeListener(listener);
}
}, [query]);

return matches;
};
95 changes: 51 additions & 44 deletions src/components/Header/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Link, matchPath, useLocation } from "react-router-dom";
import React, { useCallback, useEffect, useRef } from "react";
import { Link, useLocation } from "react-router-dom";
import routerInfo from "../../shared/routing/routerInfo";
import Logo from "../../assets/logo.svg?react";
import Menu from "../../assets/menu_buton.json";
Expand All @@ -15,68 +15,76 @@ import {
toggleBtnStyles,
} from "./.css.ts";
import { routerInfoType } from "../../shared/types/routing.ts";
import Lottie, { LottieRefCurrentProps } from "lottie-react";
import Lottie, { LottieRefCurrentProps } from "lottie-light-react";
import { useMediaQuery, useToggle } from "./customHooks.tsx";

/**
* 현재 경로인지 확인하는 함수
* @param path 비교할 경로
* @param pathname 현재 경로(useLocation().pathname)
* @return boolean
*/
const isCurrentPath = (path: string, pathname: string): boolean =>
!!matchPath({ path, end: true }, pathname);
import { isCurrentPath, useCurrentPage } from "../../shared/routing/routerUtils.ts";

/**
* Header 컴포넌트
* @component
* @returns {React.ReactElement} Header 컴포넌트 요소
*/
export const Header: React.FC = () => {
const [isActive, setIsActive] = useState(false);
const [currentPage, setCurrentPage] = useState("/");
const Header: React.FC = () => {
const [isActive, toggleActive] = useToggle(false);
const currentPage = useCurrentPage();
const location = useLocation();

// lottie 애니메이션을 제어하기 위한 ref | 참고: https://lottiereact.com/#calling-the-methods
const lottieRef = useRef<LottieRefCurrentProps>(null);
const headerRef = useRef<HTMLDivElement>(null);
const isDesktop = useMediaQuery("(min-width: 1000px)");

const handleToggle = useCallback(() => {
toggleActive();
if (lottieRef.current) {
lottieRef.current.playSegments(isActive ? [60, 120] : [0, 60], true);
}
}, [isActive, toggleActive]);

/**
* 토글 버튼 클릭 이벤트 핸들러
* @function handleToggle
* @return {void}
* lottie 속도 조절
*/
const handleToggle = useCallback(() => {
setIsActive((prevState) => {
const newState = !prevState;
if (lottieRef.current) {
if (newState) {
lottieRef.current.playSegments([0, 60], true);
} else {
lottieRef.current.playSegments([60, 120], true);
}
}
return newState;
});
useEffect(() => {
if (lottieRef.current) {
lottieRef.current.setSpeed(1.5);
}
}, []);

/**
* 컴포넌트가 마운트될 때 현재 페이지를 설정하고, isActive를 false로 초기화함
* 데스크탑에서 페이지 이동 시 메뉴 닫기
*/
useEffect(() => {
if (isDesktop && isActive) {
handleToggle();
}
}, [isDesktop, isActive, handleToggle]);

/**
* 컴포넌트 외부 클릭 감지
*/
useEffect(() => {
const currentRoute = routerInfo.find((route) => isCurrentPath(route.path, location.pathname));
setCurrentPage(currentRoute?.korean ?? "/");
setIsActive(false);
lottieRef.current?.setSpeed(1.5);
}, [location.pathname]);
const handleClickOutside = (event: MouseEvent) => {
if (headerRef.current && !headerRef.current.contains(event.target as Node)) {
if (isActive) {
handleToggle();
}
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isActive, handleToggle]);

return (
<header className={headerStyles}>
<header ref={headerRef} className={headerStyles}>
{/*로고 부분*/}
<Link to="/">
<Logo className={logoStyles} />
</Link>

{/*현재 페이지 출력 부분*/}
<span className={currentPageStyles}>{currentPage}</span>
<span className={currentPageStyles}>{currentPage.korean}</span>

{/*토글 버튼 (lottie)*/}
<Lottie
Expand All @@ -92,16 +100,15 @@ export const Header: React.FC = () => {
<nav className={`${menuStyles} ${isActive ? "active" : ""}`}>
<ul className={menuListStyles}>
{routerInfo
.filter((item) => item.expose)
.filter((item: routerInfoType) => item.expose)
.sort((a: routerInfoType, b: routerInfoType) => a.korean.localeCompare(b.korean))
.map((item) => (
.map((item: routerInfoType) => (
<li key={item.path} className={menuItemStyles}>
<Link
to={item.path}
className={`${menuItemLinkStyles} ${
isCurrentPath(item.path, location.pathname) ? highlightStyles : ""
isCurrentPath(item, location) ? highlightStyles : ""
}`}
onClick={handleToggle}
>
{item.korean}
</Link>
Expand All @@ -113,4 +120,4 @@ export const Header: React.FC = () => {
);
};

export default Header;
export default React.memo(Header);
38 changes: 38 additions & 0 deletions src/shared/routing/routerUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Location, matchPath, useLocation } from "react-router-dom";
import { routerInfoType } from "../types/routing.ts";
import routerInfo from "./routerInfo.ts";
import { useEffect, useState } from "react";

/**
* 현재 경로인지 비교하는 함수
* @param routerInfo - 라우터 정보 객체(routerInfo.ts)
* @param pathname - 현재 경로(useLocation() from react-router-dom)
*/
export const isCurrentPath = (routerInfo: routerInfoType, pathname: Location): boolean => {
const path = routerInfo.path;
const _pathname = pathname.pathname;
return !!matchPath({ path, end: true }, _pathname);
};

/**
* 현재 페이지 객체를 반환하는 커스텀 훅
* @returns currentPage
*/
export const useCurrentPage = (): routerInfoType => {
const nullPage: routerInfoType = routerInfo[0];
/*******************************************
* you can change to 404 page
* or make props to set component for 404 page
* Can be extended to handle custom 404 components.
* For inquiries, contact: [email protected]
*******************************************/
const location = useLocation();
const [currentPage, setCurrentPage] = useState(nullPage);

useEffect(() => {
const currentRoute = routerInfo.find((route) => isCurrentPath(route, location));
setCurrentPage(currentRoute ?? nullPage);
}, [nullPage, location]);

return currentPage;
};
1 change: 1 addition & 0 deletions src/shared/styles/globalStyle.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import backgroundImg from "../../assets/background.webp";
import { vars } from "./vars.css";

globalStyle("body", {
paddingTop: "3.75rem", // Header 높이만큼 상단 여백 추가
position: "relative",
backgroundImage: `url(${backgroundImg})`, // 배경 이미지 경로
backgroundSize: "100% auto",
Expand Down
9 changes: 9 additions & 0 deletions src/shared/types/routing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
// 라우팅 정보 객체 타입
import React, { ComponentType } from "react";

/**
* 라우팅 정보 객체 타입
* @interface
* @property {string} path - 라우팅 경로
* @property {React.LazyExoticComponent<ComponentType<unknown>>} element - 라우팅 컴포넌트
* @property {string} english - 영어 이름
* @property {string} korean - 한글 이름
* @property {boolean} expose - 헤더 노출 여부
*/
export interface routerInfoType {
path: string;
element: React.LazyExoticComponent<ComponentType<unknown>>;
Expand Down