Files
invyone/frontend/components/layout/TopNavBar.tsx
T
gbpark 5153386fce
Build & Deploy to K8s / build-and-deploy (push) Successful in 3m59s
디자인 수정
2026-04-21 22:59:51 +09:00

173 lines
5.6 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
type UIMenu = {
id: string;
name: string;
icon?: React.ReactNode;
hasChildren?: boolean;
children?: UIMenu[];
url?: string;
badge?: number;
};
type TopNavBarProps = {
menus: UIMenu[];
isMenuActive: (m: UIMenu) => boolean;
onSelect: (m: UIMenu) => void;
};
/**
* TopNav — 디자인시스템 `TopNav` 포팅 (shell-components.jsx).
* invyone 메뉴 트리(최상위 = 섹션)에 맞게 단순화.
* 섹션 hover → flyout (첫 번째 레벨), flyout 아이템에 자식이 있으면 hover 로 2단계 sub-flyout.
*/
export function TopNavBar({ menus, isMenuActive, onSelect }: TopNavBarProps) {
const [openId, setOpenId] = useState<string | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const scheduleClose = useCallback(() => {
cancelClose();
// 섹션 헤더 → flyout 이동 중 마우스가 경계 근처에서 순간적으로 빠져도 안 닫히도록 여유있게.
closeTimer.current = setTimeout(() => setOpenId(null), 260);
}, [cancelClose]);
const openNow = useCallback(
(id: string) => {
cancelClose();
setOpenId(id);
},
[cancelClose],
);
const handleSectionClick = (m: UIMenu) => {
// leaf 섹션은 바로 선택. 자식이 있는 섹션은 첫 leaf 로 점프.
if (!m.hasChildren) {
onSelect(m);
return;
}
const first = m.children?.[0];
if (!first) return;
if (first.hasChildren && first.children?.[0]) onSelect(first.children[0]);
else onSelect(first);
};
return (
<nav className="v5-topnav" aria-label="주 내비게이션">
{menus.map((sec, i) => {
const isOpen = openId === sec.id;
const isActive = isMenuActive(sec);
return (
<div
key={sec.id}
className={`v5-tn-section ${isActive ? "on" : ""} ${isOpen ? "open" : ""}`}
onMouseEnter={() => openNow(sec.id)}
onMouseLeave={scheduleClose}
style={{ animationDelay: `${i * 40}ms` }}
>
<button
type="button"
className="v5-tn-item"
onClick={() => handleSectionClick(sec)}
>
{sec.icon && <span className="v5-tn-ic">{sec.icon}</span>}
<span>{sec.name}</span>
{sec.hasChildren && <ChevronDown size={12} />}
</button>
{isOpen && sec.hasChildren && (
/*
flyout 의 onMouseEnter/Leave 는 의도적으로 달지 않음.
- flyout 은 section 의 DOM 자식이므로, section 의 mouseleave 는
"마우스가 flyout 과 section 모두 벗어났을 때" 에만 발화함.
- 따라서 섹션 내부 ↔ flyout 이동은 아무 이벤트도 발사되지 않고 유지됨.
- 이전엔 flyout mouseleave → scheduleClose 가 타서 섹션 쪽으로
위로 이동 시 플라이아웃이 사라지던 버그가 있었음.
*/
<div className="v5-tn-flyout">
{sec.children?.map((it, j) => (
<TnRow
key={it.id}
item={it}
isMenuActive={isMenuActive}
onSelect={(x) => {
onSelect(x);
setOpenId(null);
}}
delay={j * 28}
/>
))}
</div>
)}
</div>
);
})}
</nav>
);
}
function TnRow({
item,
isMenuActive,
onSelect,
delay,
}: {
item: UIMenu;
isMenuActive: (m: UIMenu) => boolean;
onSelect: (m: UIMenu) => void;
delay: number;
}) {
const [subOpen, setSubOpen] = useState(false);
const hasChildren = !!item.hasChildren && !!item.children?.length;
const isOn = isMenuActive(item);
return (
<div
className={`v5-tn-row ${isOn ? "on" : ""} ${hasChildren ? "has-sub" : ""}`}
style={{ animationDelay: `${delay}ms` }}
onMouseEnter={() => hasChildren && setSubOpen(true)}
onMouseLeave={() => setSubOpen(false)}
onClick={(e) => {
if ((e.target as HTMLElement).closest(".v5-tn-sub")) return;
if (hasChildren && item.children?.[0]) onSelect(item.children[0]);
else onSelect(item);
}}
>
{item.icon && <span className="v5-tn-ic">{item.icon}</span>}
<span className="v5-tn-row-label">{item.name}</span>
{typeof item.badge === "number" && item.badge > 0 && (
<span className="v5-tn-badge">{item.badge}</span>
)}
{hasChildren && <ChevronRight size={12} />}
{hasChildren && subOpen && (
<div className="v5-tn-sub">
{item.children?.map((c, k) => (
<div
key={c.id}
className={`v5-tn-row v5-tn-sub-row ${isMenuActive(c) ? "on" : ""}`}
style={{ animationDelay: `${k * 22}ms` }}
onClick={(e) => {
e.stopPropagation();
onSelect(c);
}}
>
{c.icon && <span className="v5-tn-ic">{c.icon}</span>}
<span className="v5-tn-row-label">{c.name}</span>
{typeof c.badge === "number" && c.badge > 0 && (
<span className="v5-tn-badge">{c.badge}</span>
)}
</div>
))}
</div>
)}
</div>
);
}