173 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|