File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { usePathname } from 'next/navigation';
import { useCallback, useMemo, useState } from 'react';
import type {
SidebarMenu,
SidebarMenuChild,
SidebarMenuItem,
} from '@/lib/domain/sidebar-menu';
/**
* 사이드바 펼침·활성 상태(ViewModel). 트리는 서버가 준 값을 그대로 받고, 이 훅은 "지금 어디에
* 있는가"와 "무엇이 펼쳐져 있는가"만 판정한다.
*
* 펼침은 **현재 경로에서 파생된 기본값 + 사용자가 누른 값**의 2층이다. 그래서 링크를 눌러
* 이동하면 그 가지가 저절로 열리고, 사용자가 직접 접으면 그 선택이 유지된다. 열림 목록을
* 상태로만 들고 있으면 이동할 때마다 손으로 다시 열어야 한다.
*/
export interface SidebarNavState {
/** 이 href가 현재 경로인가. */
isActive: (href?: string) => boolean;
/** 1depth 항목이 펼쳐져 있는가. */
isItemOpen: (item: SidebarMenuItem) => boolean;
toggleItem: (id: string) => void;
/** 2depth 항목이 펼쳐져 있는가. */
isChildOpen: (child: SidebarMenuChild) => boolean;
toggleChild: (id: string) => void;
/** 자손 중에 현재 경로가 있는가 — 1depth를 활성으로 칠할지 판단한다. */
containsActive: (item: SidebarMenuItem) => boolean;
}
function childContainsPath(child: SidebarMenuChild, pathname: string): boolean {
return (
child.href === pathname ||
(child.children?.some((leaf) => leaf.href === pathname) ?? false)
);
}
function itemContainsPath(item: SidebarMenuItem, pathname: string): boolean {
return (
item.href === pathname ||
(item.children?.some((child) => childContainsPath(child, pathname)) ?? false)
);
}
export function useSidebarNav(menu: SidebarMenu): SidebarNavState {
const pathname = usePathname();
// 사용자가 직접 누른 것만 담는다. 없으면 경로에서 파생된 기본값을 쓴다.
const [itemOverrides, setItemOverrides] = useState<Record<string, boolean>>({});
const [childOverrides, setChildOverrides] = useState<Record<string, boolean>>(
{}
);
// 현재 경로를 품은 가지 — 기본 펼침 대상이다.
const openByPath = useMemo(() => {
const items = new Set<string>();
const children = new Set<string>();
for (const section of menu.sections) {
for (const item of section.items) {
if (!itemContainsPath(item, pathname)) {
continue;
}
items.add(item.id);
for (const child of item.children ?? []) {
if (childContainsPath(child, pathname)) {
children.add(child.id);
}
}
}
}
return { items, children };
}, [menu, pathname]);
const isActive = useCallback(
(href?: string) => href !== undefined && href === pathname,
[pathname]
);
const isItemOpen = useCallback(
(item: SidebarMenuItem) =>
itemOverrides[item.id] ?? openByPath.items.has(item.id),
[itemOverrides, openByPath]
);
const isChildOpen = useCallback(
(child: SidebarMenuChild) =>
childOverrides[child.id] ?? openByPath.children.has(child.id),
[childOverrides, openByPath]
);
const toggleItem = useCallback(
(id: string) =>
setItemOverrides((prev) => ({
...prev,
[id]: !(prev[id] ?? openByPath.items.has(id)),
})),
[openByPath]
);
const toggleChild = useCallback(
(id: string) =>
setChildOverrides((prev) => ({
...prev,
[id]: !(prev[id] ?? openByPath.children.has(id)),
})),
[openByPath]
);
const containsActive = useCallback(
(item: SidebarMenuItem) => itemContainsPath(item, pathname),
[pathname]
);
return {
isActive,
isItemOpen,
toggleItem,
isChildOpen,
toggleChild,
containsActive,
};
}