Skip to content

Commit ad3afcf

Browse files
committed
feat: 헤더 메뉴 동작 추가
1 parent 764e90d commit ad3afcf

File tree

3 files changed

+204
-8
lines changed

3 files changed

+204
-8
lines changed

src/components/layout/Header/index.tsx

Lines changed: 105 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,32 @@
11
import styled from "@emotion/styled";
2+
import { useMenu } from "../../../hooks/useMenu";
3+
4+
interface SubMenuItem {
5+
text: string;
6+
href: string;
7+
}
8+
9+
interface MenuItem {
10+
text: string;
11+
href?: string;
12+
subMenu?: SubMenuItem[];
13+
}
14+
15+
interface HeaderProps {
16+
menus: MenuItem[];
17+
}
18+
19+
export default function Header({ menus }: HeaderProps) {
20+
const {
21+
hoveredMenu,
22+
focusedMenu,
23+
menuRefs,
24+
setHoveredMenu,
25+
setFocusedMenu,
26+
handleKeyDown,
27+
handleBlur,
28+
} = useMenu();
229

3-
export default function Header() {
430
return (
531
<HeaderContainer>
632
<HeaderLogo>
@@ -14,12 +40,34 @@ export default function Header() {
1440

1541
<nav>
1642
<HeaderNav>
17-
<li>파이콘 한국</li>
18-
<li>프로그램</li>
19-
<li>세션</li>
20-
<li>구매</li>
21-
<li>재정 지원</li>
22-
<li>후원하기</li>
43+
{menus.map((menu) => (
44+
<li
45+
key={menu.text}
46+
ref={(el) => {
47+
menuRefs.current[menu.text] = el;
48+
}}
49+
onMouseEnter={() => setHoveredMenu(menu.text)}
50+
onMouseLeave={() => setHoveredMenu(null)}
51+
onFocus={() => setFocusedMenu(menu.text)}
52+
onBlur={() => handleBlur(menu)}
53+
onKeyDown={(e) => handleKeyDown(e, menu)}
54+
tabIndex={0}
55+
>
56+
{menu.text}
57+
{menu.subMenu &&
58+
(hoveredMenu === menu.text || focusedMenu === menu.text) && (
59+
<SubMenu>
60+
{menu.subMenu.map((subItem) => (
61+
<SubMenuItem key={subItem.text}>
62+
<a href={subItem.href} tabIndex={0}>
63+
{subItem.text}
64+
</a>
65+
</SubMenuItem>
66+
))}
67+
</SubMenu>
68+
)}
69+
</li>
70+
))}
2371
</HeaderNav>
2472
</nav>
2573
<HeaderLeft>
@@ -50,6 +98,7 @@ const HeaderContainer = styled.header`
5098
display: flex;
5199
justify-content: space-between;
52100
align-items: center;
101+
position: relative;
53102
`;
54103

55104
const HeaderLogo = styled.div`
@@ -64,6 +113,18 @@ const HeaderNav = styled.ul`
64113
gap: 2rem;
65114
font-size: 0.875rem;
66115
font-weight: 500;
116+
position: relative;
117+
118+
li {
119+
position: relative;
120+
cursor: pointer;
121+
outline: none;
122+
123+
&:focus {
124+
outline: 2px solid ${({ theme }) => theme.palette.primary.main};
125+
outline-offset: 1px;
126+
}
127+
}
67128
`;
68129

69130
const HeaderLeft = styled.div`
@@ -77,3 +138,40 @@ const HeaderItem = styled.div`
77138
align-items: center;
78139
gap: 0.625rem;
79140
`;
141+
142+
const SubMenu = styled.ul`
143+
position: absolute;
144+
top: 100%;
145+
left: 50%;
146+
transform: translateX(-50%);
147+
background-color: white;
148+
border-radius: 5px;
149+
padding: 5px 0;
150+
width: 125px;
151+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
152+
z-index: 1000;
153+
`;
154+
155+
const SubMenuItem = styled.li`
156+
padding: 5px 0;
157+
text-align: center;
158+
159+
a {
160+
color: ${({ theme }) => theme.palette.primary.light};
161+
text-decoration: none;
162+
font-size: 10px;
163+
display: block;
164+
outline: none;
165+
166+
&:hover,
167+
&:focus {
168+
color: ${({ theme }) => theme.palette.primary.main};
169+
font-weight: 600;
170+
}
171+
172+
&:focus {
173+
outline: 2px solid ${({ theme }) => theme.palette.primary.main};
174+
outline-offset: 0.5px;
175+
}
176+
}
177+
`;

src/components/layout/index.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,53 @@ import { Outlet } from "react-router-dom";
22
import Header from "./Header";
33
import Footer from "./Footer";
44

5+
const headerMenus = [
6+
{
7+
text: "파이콘 한국",
8+
subMenu: [
9+
{ text: "파이콘 한국 2025", href: "/2025" },
10+
{ text: "파이콘 한국 행동강령(CoC)", href: "/coc" },
11+
{ text: "파이썬 사용자 모임", href: "/user-group" },
12+
{ text: "역대 파이콘 행사", href: "/past-events" },
13+
{ text: "파이콘 한국 건강 관련 안내", href: "/health" },
14+
],
15+
},
16+
{
17+
text: "프로그램",
18+
subMenu: [
19+
{ text: "튜토리얼", href: "/tutorial" },
20+
{ text: "스프린트", href: "/sprint" },
21+
{ text: "포스터 세션", href: "/poster" },
22+
],
23+
},
24+
{
25+
text: "세션",
26+
subMenu: [
27+
{ text: "세션 목록", href: "/sessions" },
28+
{ text: "세션 시간표", href: "/schedule" },
29+
],
30+
},
31+
{
32+
text: "구매",
33+
subMenu: [
34+
{ text: "티켓 구매", href: "/tickets" },
35+
{ text: "굿즈 구매", href: "/goods" },
36+
{ text: "결제 내역", href: "/payments" },
37+
],
38+
},
39+
{
40+
text: "후원하기",
41+
subMenu: [
42+
{ text: "후원사 안내", href: "/sponsors" },
43+
{ text: "개인 후원자", href: "/individual-sponsors" },
44+
],
45+
},
46+
];
47+
548
export default function MainLayout() {
649
return (
750
<>
8-
<Header />
51+
<Header menus={headerMenus} />
952
<main>
1053
<Outlet />
1154
</main>

src/hooks/useMenu.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useState, useRef, useEffect } from "react";
2+
3+
interface MenuItem {
4+
text: string;
5+
href?: string;
6+
subMenu?: {
7+
text: string;
8+
href: string;
9+
}[];
10+
}
11+
12+
export const useMenu = () => {
13+
const [hoveredMenu, setHoveredMenu] = useState<string | null>(null);
14+
const [focusedMenu, setFocusedMenu] = useState<string | null>(null);
15+
const menuRefs = useRef<{ [key: string]: HTMLLIElement | null }>({});
16+
17+
useEffect(() => {
18+
const handleClickOutside = (event: MouseEvent) => {
19+
if (
20+
focusedMenu &&
21+
!menuRefs.current[focusedMenu]?.contains(event.target as Node)
22+
) {
23+
setFocusedMenu(null);
24+
}
25+
};
26+
27+
document.addEventListener("mousedown", handleClickOutside);
28+
return () => document.removeEventListener("mousedown", handleClickOutside);
29+
}, [focusedMenu]);
30+
31+
const handleKeyDown = (e: React.KeyboardEvent, menu: MenuItem) => {
32+
if (e.key === "Enter" || e.key === " ") {
33+
e.preventDefault();
34+
setFocusedMenu(menu.text);
35+
}
36+
};
37+
38+
const handleBlur = (menu: MenuItem) => {
39+
setTimeout(() => {
40+
if (!menuRefs.current[menu.text]?.contains(document.activeElement)) {
41+
setFocusedMenu(null);
42+
}
43+
}, 0);
44+
};
45+
46+
return {
47+
hoveredMenu,
48+
focusedMenu,
49+
menuRefs,
50+
setHoveredMenu,
51+
setFocusedMenu,
52+
handleKeyDown,
53+
handleBlur,
54+
};
55+
};

0 commit comments

Comments
 (0)