Files
frontend/apps/dm/components/BusinessSubNav.tsx
T

242 lines
7.1 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
export type AnchorSection = {
id: string;
label: string;
};
export type BusinessSubNavMenu = {
key: string;
label: string;
href?: string;
current?: boolean;
defaultOpen?: boolean;
sections?: AnchorSection[];
};
type BusinessSubNavProps = {
menus?: BusinessSubNavMenu[];
offsetTop?: number;
className?: string;
};
export default function BusinessSubNav({
menus = [],
offsetTop = 120,
className = "",
}: BusinessSubNavProps) {
const [activeId, setActiveId] = useState("");
const [openMenus, setOpenMenus] = useState<Record<string, boolean>>({});
const allSections = useMemo(
() => menus.flatMap((menu) => menu.sections ?? []),
[menus]
);
useEffect(() => {
const initialOpenState: Record<string, boolean> = {};
menus.forEach((menu) => {
initialOpenState[menu.key] = menu.defaultOpen ?? menu.current ?? false;
});
setOpenMenus(initialOpenState);
}, [menus]);
useEffect(() => {
if (!allSections.length) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible[0]) {
setActiveId(visible[0].target.id);
}
},
{
rootMargin: `-${offsetTop}px 0px -55% 0px`,
threshold: [0.2, 0.35, 0.5, 0.7],
}
);
const targets = allSections
.map((section) => document.getElementById(section.id))
.filter(Boolean) as HTMLElement[];
targets.forEach((el) => observer.observe(el));
const hash = window.location.hash.replace("#", "");
const initialActive =
allSections.find((section) => section.id === hash)?.id ??
allSections[0]?.id ??
"";
setActiveId(initialActive);
return () => observer.disconnect();
}, [allSections, offsetTop]);
useEffect(() => {
if (!activeId) return;
const parentMenu = menus.find((menu) =>
(menu.sections ?? []).some((section) => section.id === activeId)
);
if (!parentMenu) return;
setOpenMenus(() => {
const nextOpenState: Record<string, boolean> = {};
menus.forEach((menu) => {
nextOpenState[menu.key] = menu.key === parentMenu.key;
});
return nextOpenState;
});
}, [activeId, menus]);
const handleMove = (id: string) => {
setActiveId(id);
const target = document.getElementById(id);
if (!target) return;
window.history.replaceState(null, "", `#${id}`);
const top = target.getBoundingClientRect().top + window.scrollY - offsetTop;
window.scrollTo({
top,
behavior: "smooth",
});
};
const toggleMenu = (key: string) => {
setOpenMenus((prev) => ({
...prev,
[key]: !prev[key],
}));
};
if (!menus.length) return null;
return (
<aside className={`fixed right-2 top-[110px] z-50 w-[260px] ${className}`}>
<div className="overflow-hidden bg-white">
{menus.map((menu) => {
const isOpen = openMenus[menu.key] ?? false;
const hasChildren = (menu.sections?.length ?? 0) > 0;
const isCurrentGroup = (menu.sections ?? []).some(
(section) => section.id === activeId
);
return (
<div
key={menu.key}
>
<div className="flex items-center justify-between px-5 py-2">
{menu.href ? (
<Link
href={menu.href}
className={[
"text-[16px] font-medium tracking-[-0.02em]",
isCurrentGroup || menu.current
? "text-sky-500"
: "text-[#767676]",
].join(" ")}
>
{menu.label}
</Link>
) : (
<button
type="button"
onClick={() => {
if (hasChildren) toggleMenu(menu.key);
}}
className={[
"text-left text-[16px] font-medium tracking-[-0.02em]",
isCurrentGroup || menu.current
? "text-sky-500"
: "text-[#767676]",
].join(" ")}
>
{menu.label}
</button>
)}
{hasChildren && (
<button
type="button"
onClick={() => toggleMenu(menu.key)}
className={[
"ml-2 shrink-0",
isCurrentGroup || menu.current
? "text-sky-500"
: "text-[#767676]",
].join(" ")}
aria-label={`${menu.label} 드롭다운 토글`}
>
{isOpen ? (
<ChevronUp className="h-5 w-5" />
) : (
<ChevronDown className="h-5 w-5" />
)}
</button>
)}
</div>
{hasChildren && isOpen && (
<div className="relative px-5 pb-5">
<div className="absolute left-[31px] top-[6px] bottom-[14px] w-[2px] bg-[#CDD5DB]" />
<ul className="space-y-4 pl-2">
{menu.sections!.map((section) => {
const active = activeId === section.id;
return (
<li key={section.id}>
<button
type="button"
onClick={() => handleMove(section.id)}
className="flex items-start gap-3 text-left"
>
<span
className={[
"relative z-[1] mt-[6px] -ml-[2px] h-[11px] w-[11px] shrink-0 rounded-full border",
active
? "border-sky-500 bg-sky-500"
: "border-[#BFC7CF] bg-white",
].join(" ")}
/>
<span
className={[
"break-keep text-[14px] leading-[1.45] transition-colors",
active
? "text-[#4A4A4A]"
: "text-[#D1D7DE] hover:text-[#7B8794]",
].join(" ")}
>
{section.label}
</span>
</button>
</li>
);
})}
</ul>
</div>
)}
</div>
);
})}
</div>
</aside>
);
}