242 lines
7.1 KiB
TypeScript
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>
|
|
);
|
|
} |