임동욱 임동욱 08-10
Merge branch 'feature/sidebar-nav-tree' into integration/sidebar-nav-tree
@2e8a4382b0b1dbecea591c8ca6c425b692f4138b
app/(protected)/(basic)/_components/admin-sidebar.tsx
--- app/(protected)/(basic)/_components/admin-sidebar.tsx
+++ app/(protected)/(basic)/_components/admin-sidebar.tsx
@@ -1,19 +1,26 @@
-import { usePathname } from 'next/navigation';
 import { Sidebar } from '@/components/ui/sidebar';
+import { SidebarGroup } from '@/components/ui/sidebar-group';
 import { SidebarLink } from '@/components/ui/sidebar-link';
+import {
+  useSidebarNav,
+  type SidebarNavNode,
+} from '@/app/(protected)/(basic)/_hooks/use-sidebar-nav';
 
-interface SidebarNavItem {
-  label: string;
-  href: string;
-}
-
-// 신규 화면이 추가될 때마다 이 목록에 항목을 늘린다. 「학생 회원」은 기획상 「회원정보관리」
-// 그룹에 속하지만(Figma ADM_MEM_101 기준 — 「학생회원관리」가 아니다), 사이드바가 아직 flat
-// 구조(그룹 헤딩 없음)라 지금은 평탄한 항목으로만 추가한다 — 그룹 헤딩 컴포넌트 신설은 공용
-// 컴포넌트 영역이라 design 레인 소관(§4.2)이다.
-const SIDEBAR_NAV_ITEMS: SidebarNavItem[] = [
-  { label: '홈', href: '/' },
-  { label: '학생 회원', href: '/students' },
+// 신규 화면이 추가될 때마다 이 트리에 항목을 늘린다. 기획(Figma ADM_MEM_101)의 사이드바는
+// 그룹 헤딩(라우트 없음) 아래 자식 링크를 두는 2단 구조다 — 「학생 회원」은 「회원정보관리」
+// 그룹의 자식이다(「학생회원관리」가 아니다).
+//
+// 시안에는 이 외에도 관리자정보관리·꾸미기아이템관리·게시판관리(고객센터)·포인트관리·
+// 콘텐츠관리·시스템관리 6개 그룹이 더 있지만, 전부 대응하는 화면/라우트가 아직 없어 지금
+// 추가하면 눌러도 갈 곳 없는 죽은 메뉴가 된다. 각 화면이 실제로 구현될 때 해당 그룹과
+// 자식 링크를 추가한다.
+const SIDEBAR_NAV_TREE: SidebarNavNode[] = [
+  { type: 'link', label: '홈', href: '/' },
+  {
+    type: 'group',
+    label: '회원정보관리',
+    children: [{ type: 'link', label: '학생 회원', href: '/students' }],
+  },
 ];
 
 interface AdminSidebarProps {
@@ -23,25 +30,27 @@
 }
 
 /**
- * 사이드바 배치 — 비주얼 셸(Sidebar)과 내비 항목(SidebarLink)은 design 공용 컴포넌트를 그대로
- * 쓰고, 데스크톱 push/접힘과 모바일 오버레이+백드롭 등 반응형 배치(공용 컴포넌트가 제공하지 않음)만
- * className 패스스루로 처리한다. 전부 Tailwind 반응형 클래스(CSS-first) — JS로 브레이크포인트를
- * 판별하지 않는다.
+ * 사이드바 배치 — 비주얼 셸(Sidebar)과 내비 항목(SidebarGroup/SidebarLink)은 design 공용
+ * 컴포넌트를 그대로 쓰고, 데스크톱 push/접힘과 모바일 오버레이+백드롭 등 반응형 배치(공용
+ * 컴포넌트가 제공하지 않음)만 className 패스스루로 처리한다. 전부 Tailwind 반응형
+ * 클래스(CSS-first) — JS로 브레이크포인트를 판별하지 않는다.
  *
- * 주의(공용 컴포넌트 부족분 — 실증됨): SidebarLink는 아이콘 전용(라벨 숨김) variant를 제공하지
- * 않는다. "홈"은 1글자라 접힘 폭(md:w-16=4rem)에서도 문제없었지만, "학생 회원"(4글자)을 추가한
- * 뒤 접힘 상태를 실제로 확인한 결과 라벨이 글자 단위로 세로 줄바꿈되어("학\n생\n회\n원") 링크
- * pill이 4줄로 늘어나는 것을 확인했다(라이트/다크 동일하게 재현 — 테마 무관, 순수 레이아웃
- * 이슈). SidebarLink에 collapsed/아이콘 전용 variant(예: 접힘 시 라벨을 툴팁으로 옮기고 아이콘만
- * 표시) 추가가 필요하다 — design 레인 소관(§4.2 공용 컴포넌트 신설)이라 이번 범위에서는
- * 수정하지 않고 보고만 한다.
+ * 그룹 펼침/접힘 상태와 활성 링크·그룹 판정은 `useSidebarNav` 훅(ViewModel)이 소유한다 —
+ * 이 컴포넌트는 트리 데이터를 훅에 넘기고 반환값으로 렌더링만 담당한다.
+ *
+ * 주의(공용 컴포넌트 부족분 — 실증됨): SidebarLink는 아직 collapsed(md:w-16) 상태 전용
+ * 아이콘 표시 variant를 제공하지 않는다. 글자 단위 세로 줄바꿈 문제 자체는 SidebarLink의
+ * `min-w-0 truncate`로 완화돼 있지만(한 줄 유지 + 말줄임), 접힘 폭에서 라벨 텍스트가
+ * 말줄임으로 잘려 보이는 근본 한계는 남아 있다 — design 레인 소관(§4.2 공용 컴포넌트
+ * 신설)이라 이번 범위에서는 수정하지 않는다.
  */
 export function AdminSidebar({
   isMobileOpen,
   isDesktopCollapsed,
   onCloseMobile,
 }: AdminSidebarProps) {
-  const pathname = usePathname();
+  const { isLinkActive, isGroupExpanded, toggleGroup } =
+    useSidebarNav(SIDEBAR_NAV_TREE);
 
   return (
     <>
@@ -58,14 +67,33 @@
           isMobileOpen ? 'translate-x-0' : '-translate-x-full'
         } ${isDesktopCollapsed ? 'md:w-16' : 'md:w-64'}`}
       >
-        {SIDEBAR_NAV_ITEMS.map((item) => (
-          <SidebarLink
-            key={item.href}
-            href={item.href}
-            label={item.label}
-            isActive={pathname === item.href}
-          />
-        ))}
+        {SIDEBAR_NAV_TREE.map((node) =>
+          node.type === 'link' ? (
+            <SidebarLink
+              key={node.href}
+              href={node.href}
+              label={node.label}
+              isActive={isLinkActive(node.href)}
+            />
+          ) : (
+            <SidebarGroup
+              key={node.label}
+              label={node.label}
+              isExpanded={isGroupExpanded(node.label)}
+              onToggle={() => toggleGroup(node.label)}
+            >
+              {node.children.map((link) => (
+                <SidebarLink
+                  key={link.href}
+                  href={link.href}
+                  label={link.label}
+                  isActive={isLinkActive(link.href)}
+                  isNested
+                />
+              ))}
+            </SidebarGroup>
+          )
+        )}
       </Sidebar>
     </>
   );
 
app/(protected)/(basic)/_hooks/use-sidebar-nav.ts (added)
+++ app/(protected)/(basic)/_hooks/use-sidebar-nav.ts
@@ -0,0 +1,92 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { useCallback, useState } from 'react';
+
+/** 사이드바 최상위 링크 노드 — 그룹에 속하지 않는 단일 링크(예: 홈). */
+export interface SidebarNavLink {
+  type: 'link';
+  label: string;
+  href: string;
+}
+
+/** 사이드바 그룹 노드 — 라우트가 없는 헤딩이며, 자식 링크를 펼침/접힘으로 보여준다. */
+export interface SidebarNavGroup {
+  type: 'group';
+  label: string;
+  children: SidebarNavLink[];
+}
+
+export type SidebarNavNode = SidebarNavLink | SidebarNavGroup;
+
+export interface SidebarNavState {
+  /** href가 현재 경로와 일치하는가(활성 링크 여부). */
+  isLinkActive: (href: string) => boolean;
+  /** 그룹 라벨이 현재 펼침 상태인가. */
+  isGroupExpanded: (label: string) => boolean;
+  /** 그룹 펼침/접힘을 토글한다. */
+  toggleGroup: (label: string) => void;
+}
+
+/** tree에서 현재 pathname을 자식으로 포함하는 그룹의 라벨을 찾는다(없으면 null). */
+function findActiveGroupLabel(
+  tree: SidebarNavNode[],
+  pathname: string
+): string | null {
+  const activeGroup = tree.find(
+    (node): node is SidebarNavGroup =>
+      node.type === 'group' &&
+      node.children.some((link) => link.href === pathname)
+  );
+  return activeGroup?.label ?? null;
+}
+
+/**
+ * 사이드바 내비게이션 트리의 화면 로컬 상태(ViewModel 대응) — 그룹 라벨을 키로 펼침/접힘
+ * 여부를 들고 있고, 현재 경로 기준 활성 링크·활성 그룹 판정도 함께 제공한다. 트리 데이터
+ * 자체는 호출부가 소유하고 이 훅에 넘긴다(이 훅은 상태·판정 로직만 책임).
+ *
+ * 현재 경로가 속한 그룹은 항상 펼쳐져 있어야 한다(기획 ADM_MEM_101). 최초 렌더뿐 아니라
+ * 클라이언트 내비게이션으로 pathname이 바뀌는 매 순간에도 이 불변식을 지켜야 하므로,
+ * `use-sidebar.ts`(모바일 오버레이를 pathname 변화에 맞춰 닫는 로직)가 쓰는 것과 동일한
+ * "렌더 중 상태 조정" 패턴(pathname 파생값의 변화를 렌더 중 감지해 즉시 setState)을 쓴다 —
+ * effect 안에서 setState하면 `react-hooks/set-state-in-effect` 린트 규칙에 걸리고
+ * effect발 cascading render를 유발하므로 이 프로젝트에서는 쓰지 않는다.
+ *
+ * 사용자가 수동으로 접은 그룹을 강제로 다시 펼치지는 않는다 — activeGroupLabel 자체가
+ * 바뀔 때만(= 다른 그룹의 경로로 새로 이동했을 때만) 그 그룹을 편다. 같은 그룹 내 다른
+ * 링크로 이동하는 경우(activeGroupLabel 불변)에는 사용자가 접어둔 상태를 그대로 둔다.
+ */
+export function useSidebarNav(tree: SidebarNavNode[]): SidebarNavState {
+  const pathname = usePathname();
+  const activeGroupLabel = findActiveGroupLabel(tree, pathname);
+
+  const [expandedGroups, setExpandedGroups] = useState<
+    Record<string, boolean>
+  >(() => (activeGroupLabel ? { [activeGroupLabel]: true } : {}));
+
+  const [prevActiveGroupLabel, setPrevActiveGroupLabel] =
+    useState(activeGroupLabel);
+  if (activeGroupLabel !== prevActiveGroupLabel) {
+    setPrevActiveGroupLabel(activeGroupLabel);
+    if (activeGroupLabel !== null) {
+      setExpandedGroups((prev) => ({ ...prev, [activeGroupLabel]: true }));
+    }
+  }
+
+  const isLinkActive = useCallback(
+    (href: string) => pathname === href,
+    [pathname]
+  );
+
+  const isGroupExpanded = useCallback(
+    (label: string) => Boolean(expandedGroups[label]),
+    [expandedGroups]
+  );
+
+  const toggleGroup = useCallback((label: string) => {
+    setExpandedGroups((prev) => ({ ...prev, [label]: !prev[label] }));
+  }, []);
+
+  return { isLinkActive, isGroupExpanded, toggleGroup };
+}
Add a comment
List