feat: 사이드바 내비게이션을 그룹·자식 트리 구조로 전환
「학생 회원」을 「회원정보관리」 그룹의 자식으로 두는 기획(Figma ADM_MEM_101) 2단 구조를 반영. flat SIDEBAR_NAV_ITEMS를 링크/그룹 판별 유니언(SidebarNavNode)으로 바꾸고, 그룹 펼침/접힘 상태와 활성 링크·그룹 판정은 신규 훅 useSidebarNav로 분리했다. 현재 경로가 속한 그룹은 항상 펼쳐지도록 use-sidebar.ts와 동일한 "렌더 중 상태 조정" 패턴을 사용해 useEffect 없이 pathname 변화를 반영한다(사용자가 수동으로 접은 그룹은 같은 그룹 내 이동 시 강제로 재펼침하지 않음). design 레인의 SidebarGroup(신설)과 SidebarLink의 isNested prop이 아직 없어 관련 tsc 에러 2건이 남아있다 — 병합 후 동기화 단계에서 해소 예정. Co-Authored-By: Claude Opus 5
@f3bc33ef53480a33c236b19710abcff7874e2366
--- app/(protected)/(basic)/_components/admin-sidebar.tsx
+++ app/(protected)/(basic)/_components/admin-sidebar.tsx
... | ... | @@ -1,19 +1,26 @@ |
| 1 |
-import { usePathname } from 'next/navigation';
|
|
| 2 | 1 |
import { Sidebar } from '@/components/ui/sidebar';
|
| 2 |
+import { SidebarGroup } from '@/components/ui/sidebar-group';
|
|
| 3 | 3 |
import { SidebarLink } from '@/components/ui/sidebar-link';
|
| 4 |
+import {
|
|
| 5 |
+ useSidebarNav, |
|
| 6 |
+ type SidebarNavNode, |
|
| 7 |
+} from '@/app/(protected)/(basic)/_hooks/use-sidebar-nav'; |
|
| 4 | 8 |
|
| 5 |
-interface SidebarNavItem {
|
|
| 6 |
- label: string; |
|
| 7 |
- href: string; |
|
| 8 |
-} |
|
| 9 |
- |
|
| 10 |
-// 신규 화면이 추가될 때마다 이 목록에 항목을 늘린다. 「학생 회원」은 기획상 「회원정보관리」 |
|
| 11 |
-// 그룹에 속하지만(Figma ADM_MEM_101 기준 — 「학생회원관리」가 아니다), 사이드바가 아직 flat |
|
| 12 |
-// 구조(그룹 헤딩 없음)라 지금은 평탄한 항목으로만 추가한다 — 그룹 헤딩 컴포넌트 신설은 공용 |
|
| 13 |
-// 컴포넌트 영역이라 design 레인 소관(§4.2)이다. |
|
| 14 |
-const SIDEBAR_NAV_ITEMS: SidebarNavItem[] = [ |
|
| 15 |
- { label: '홈', href: '/' },
|
|
| 16 |
- { label: '학생 회원', href: '/students' },
|
|
| 9 |
+// 신규 화면이 추가될 때마다 이 트리에 항목을 늘린다. 기획(Figma ADM_MEM_101)의 사이드바는 |
|
| 10 |
+// 그룹 헤딩(라우트 없음) 아래 자식 링크를 두는 2단 구조다 — 「학생 회원」은 「회원정보관리」 |
|
| 11 |
+// 그룹의 자식이다(「학생회원관리」가 아니다). |
|
| 12 |
+// |
|
| 13 |
+// 시안에는 이 외에도 관리자정보관리·꾸미기아이템관리·게시판관리(고객센터)·포인트관리· |
|
| 14 |
+// 콘텐츠관리·시스템관리 6개 그룹이 더 있지만, 전부 대응하는 화면/라우트가 아직 없어 지금 |
|
| 15 |
+// 추가하면 눌러도 갈 곳 없는 죽은 메뉴가 된다. 각 화면이 실제로 구현될 때 해당 그룹과 |
|
| 16 |
+// 자식 링크를 추가한다. |
|
| 17 |
+const SIDEBAR_NAV_TREE: SidebarNavNode[] = [ |
|
| 18 |
+ { type: 'link', label: '홈', href: '/' },
|
|
| 19 |
+ {
|
|
| 20 |
+ type: 'group', |
|
| 21 |
+ label: '회원정보관리', |
|
| 22 |
+ children: [{ type: 'link', label: '학생 회원', href: '/students' }],
|
|
| 23 |
+ }, |
|
| 17 | 24 |
]; |
| 18 | 25 |
|
| 19 | 26 |
interface AdminSidebarProps {
|
... | ... | @@ -23,25 +30,27 @@ |
| 23 | 30 |
} |
| 24 | 31 |
|
| 25 | 32 |
/** |
| 26 |
- * 사이드바 배치 — 비주얼 셸(Sidebar)과 내비 항목(SidebarLink)은 design 공용 컴포넌트를 그대로 |
|
| 27 |
- * 쓰고, 데스크톱 push/접힘과 모바일 오버레이+백드롭 등 반응형 배치(공용 컴포넌트가 제공하지 않음)만 |
|
| 28 |
- * className 패스스루로 처리한다. 전부 Tailwind 반응형 클래스(CSS-first) — JS로 브레이크포인트를 |
|
| 29 |
- * 판별하지 않는다. |
|
| 33 |
+ * 사이드바 배치 — 비주얼 셸(Sidebar)과 내비 항목(SidebarGroup/SidebarLink)은 design 공용 |
|
| 34 |
+ * 컴포넌트를 그대로 쓰고, 데스크톱 push/접힘과 모바일 오버레이+백드롭 등 반응형 배치(공용 |
|
| 35 |
+ * 컴포넌트가 제공하지 않음)만 className 패스스루로 처리한다. 전부 Tailwind 반응형 |
|
| 36 |
+ * 클래스(CSS-first) — JS로 브레이크포인트를 판별하지 않는다. |
|
| 30 | 37 |
* |
| 31 |
- * 주의(공용 컴포넌트 부족분 — 실증됨): SidebarLink는 아이콘 전용(라벨 숨김) variant를 제공하지 |
|
| 32 |
- * 않는다. "홈"은 1글자라 접힘 폭(md:w-16=4rem)에서도 문제없었지만, "학생 회원"(4글자)을 추가한 |
|
| 33 |
- * 뒤 접힘 상태를 실제로 확인한 결과 라벨이 글자 단위로 세로 줄바꿈되어("학\n생\n회\n원") 링크
|
|
| 34 |
- * pill이 4줄로 늘어나는 것을 확인했다(라이트/다크 동일하게 재현 — 테마 무관, 순수 레이아웃 |
|
| 35 |
- * 이슈). SidebarLink에 collapsed/아이콘 전용 variant(예: 접힘 시 라벨을 툴팁으로 옮기고 아이콘만 |
|
| 36 |
- * 표시) 추가가 필요하다 — design 레인 소관(§4.2 공용 컴포넌트 신설)이라 이번 범위에서는 |
|
| 37 |
- * 수정하지 않고 보고만 한다. |
|
| 38 |
+ * 그룹 펼침/접힘 상태와 활성 링크·그룹 판정은 `useSidebarNav` 훅(ViewModel)이 소유한다 — |
|
| 39 |
+ * 이 컴포넌트는 트리 데이터를 훅에 넘기고 반환값으로 렌더링만 담당한다. |
|
| 40 |
+ * |
|
| 41 |
+ * 주의(공용 컴포넌트 부족분 — 실증됨): SidebarLink는 아직 collapsed(md:w-16) 상태 전용 |
|
| 42 |
+ * 아이콘 표시 variant를 제공하지 않는다. 글자 단위 세로 줄바꿈 문제 자체는 SidebarLink의 |
|
| 43 |
+ * `min-w-0 truncate`로 완화돼 있지만(한 줄 유지 + 말줄임), 접힘 폭에서 라벨 텍스트가 |
|
| 44 |
+ * 말줄임으로 잘려 보이는 근본 한계는 남아 있다 — design 레인 소관(§4.2 공용 컴포넌트 |
|
| 45 |
+ * 신설)이라 이번 범위에서는 수정하지 않는다. |
|
| 38 | 46 |
*/ |
| 39 | 47 |
export function AdminSidebar({
|
| 40 | 48 |
isMobileOpen, |
| 41 | 49 |
isDesktopCollapsed, |
| 42 | 50 |
onCloseMobile, |
| 43 | 51 |
}: AdminSidebarProps) {
|
| 44 |
- const pathname = usePathname(); |
|
| 52 |
+ const { isLinkActive, isGroupExpanded, toggleGroup } =
|
|
| 53 |
+ useSidebarNav(SIDEBAR_NAV_TREE); |
|
| 45 | 54 |
|
| 46 | 55 |
return ( |
| 47 | 56 |
<> |
... | ... | @@ -58,14 +67,33 @@ |
| 58 | 67 |
isMobileOpen ? 'translate-x-0' : '-translate-x-full' |
| 59 | 68 |
} ${isDesktopCollapsed ? 'md:w-16' : 'md:w-64'}`}
|
| 60 | 69 |
> |
| 61 |
- {SIDEBAR_NAV_ITEMS.map((item) => (
|
|
| 62 |
- <SidebarLink |
|
| 63 |
- key={item.href}
|
|
| 64 |
- href={item.href}
|
|
| 65 |
- label={item.label}
|
|
| 66 |
- isActive={pathname === item.href}
|
|
| 67 |
- /> |
|
| 68 |
- ))} |
|
| 70 |
+ {SIDEBAR_NAV_TREE.map((node) =>
|
|
| 71 |
+ node.type === 'link' ? ( |
|
| 72 |
+ <SidebarLink |
|
| 73 |
+ key={node.href}
|
|
| 74 |
+ href={node.href}
|
|
| 75 |
+ label={node.label}
|
|
| 76 |
+ isActive={isLinkActive(node.href)}
|
|
| 77 |
+ /> |
|
| 78 |
+ ) : ( |
|
| 79 |
+ <SidebarGroup |
|
| 80 |
+ key={node.label}
|
|
| 81 |
+ label={node.label}
|
|
| 82 |
+ isExpanded={isGroupExpanded(node.label)}
|
|
| 83 |
+ onToggle={() => toggleGroup(node.label)}
|
|
| 84 |
+ > |
|
| 85 |
+ {node.children.map((link) => (
|
|
| 86 |
+ <SidebarLink |
|
| 87 |
+ key={link.href}
|
|
| 88 |
+ href={link.href}
|
|
| 89 |
+ label={link.label}
|
|
| 90 |
+ isActive={isLinkActive(link.href)}
|
|
| 91 |
+ isNested |
|
| 92 |
+ /> |
|
| 93 |
+ ))} |
|
| 94 |
+ </SidebarGroup> |
|
| 95 |
+ ) |
|
| 96 |
+ )} |
|
| 69 | 97 |
</Sidebar> |
| 70 | 98 |
</> |
| 71 | 99 |
); |
app/(protected)/(basic)/_hooks/use-sidebar-nav.ts (added)
+++ app/(protected)/(basic)/_hooks/use-sidebar-nav.ts
... | ... | @@ -0,0 +1,92 @@ |
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { usePathname } from 'next/navigation'; | |
| 4 | +import { useCallback, useState } from 'react'; | |
| 5 | + | |
| 6 | +/** 사이드바 최상위 링크 노드 — 그룹에 속하지 않는 단일 링크(예: 홈). */ | |
| 7 | +export interface SidebarNavLink { | |
| 8 | + type: 'link'; | |
| 9 | + label: string; | |
| 10 | + href: string; | |
| 11 | +} | |
| 12 | + | |
| 13 | +/** 사이드바 그룹 노드 — 라우트가 없는 헤딩이며, 자식 링크를 펼침/접힘으로 보여준다. */ | |
| 14 | +export interface SidebarNavGroup { | |
| 15 | + type: 'group'; | |
| 16 | + label: string; | |
| 17 | + children: SidebarNavLink[]; | |
| 18 | +} | |
| 19 | + | |
| 20 | +export type SidebarNavNode = SidebarNavLink | SidebarNavGroup; | |
| 21 | + | |
| 22 | +export interface SidebarNavState { | |
| 23 | + /** href가 현재 경로와 일치하는가(활성 링크 여부). */ | |
| 24 | + isLinkActive: (href: string) => boolean; | |
| 25 | + /** 그룹 라벨이 현재 펼침 상태인가. */ | |
| 26 | + isGroupExpanded: (label: string) => boolean; | |
| 27 | + /** 그룹 펼침/접힘을 토글한다. */ | |
| 28 | + toggleGroup: (label: string) => void; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** tree에서 현재 pathname을 자식으로 포함하는 그룹의 라벨을 찾는다(없으면 null). */ | |
| 32 | +function findActiveGroupLabel( | |
| 33 | + tree: SidebarNavNode[], | |
| 34 | + pathname: string | |
| 35 | +): string | null { | |
| 36 | + const activeGroup = tree.find( | |
| 37 | + (node): node is SidebarNavGroup => | |
| 38 | + node.type === 'group' && | |
| 39 | + node.children.some((link) => link.href === pathname) | |
| 40 | + ); | |
| 41 | + return activeGroup?.label ?? null; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * 사이드바 내비게이션 트리의 화면 로컬 상태(ViewModel 대응) — 그룹 라벨을 키로 펼침/접힘 | |
| 46 | + * 여부를 들고 있고, 현재 경로 기준 활성 링크·활성 그룹 판정도 함께 제공한다. 트리 데이터 | |
| 47 | + * 자체는 호출부가 소유하고 이 훅에 넘긴다(이 훅은 상태·판정 로직만 책임). | |
| 48 | + * | |
| 49 | + * 현재 경로가 속한 그룹은 항상 펼쳐져 있어야 한다(기획 ADM_MEM_101). 최초 렌더뿐 아니라 | |
| 50 | + * 클라이언트 내비게이션으로 pathname이 바뀌는 매 순간에도 이 불변식을 지켜야 하므로, | |
| 51 | + * `use-sidebar.ts`(모바일 오버레이를 pathname 변화에 맞춰 닫는 로직)가 쓰는 것과 동일한 | |
| 52 | + * "렌더 중 상태 조정" 패턴(pathname 파생값의 변화를 렌더 중 감지해 즉시 setState)을 쓴다 — | |
| 53 | + * effect 안에서 setState하면 `react-hooks/set-state-in-effect` 린트 규칙에 걸리고 | |
| 54 | + * effect발 cascading render를 유발하므로 이 프로젝트에서는 쓰지 않는다. | |
| 55 | + * | |
| 56 | + * 사용자가 수동으로 접은 그룹을 강제로 다시 펼치지는 않는다 — activeGroupLabel 자체가 | |
| 57 | + * 바뀔 때만(= 다른 그룹의 경로로 새로 이동했을 때만) 그 그룹을 편다. 같은 그룹 내 다른 | |
| 58 | + * 링크로 이동하는 경우(activeGroupLabel 불변)에는 사용자가 접어둔 상태를 그대로 둔다. | |
| 59 | + */ | |
| 60 | +export function useSidebarNav(tree: SidebarNavNode[]): SidebarNavState { | |
| 61 | + const pathname = usePathname(); | |
| 62 | + const activeGroupLabel = findActiveGroupLabel(tree, pathname); | |
| 63 | + | |
| 64 | + const [expandedGroups, setExpandedGroups] = useState< | |
| 65 | + Record<string, boolean> | |
| 66 | + >(() => (activeGroupLabel ? { [activeGroupLabel]: true } : {})); | |
| 67 | + | |
| 68 | + const [prevActiveGroupLabel, setPrevActiveGroupLabel] = | |
| 69 | + useState(activeGroupLabel); | |
| 70 | + if (activeGroupLabel !== prevActiveGroupLabel) { | |
| 71 | + setPrevActiveGroupLabel(activeGroupLabel); | |
| 72 | + if (activeGroupLabel !== null) { | |
| 73 | + setExpandedGroups((prev) => ({ ...prev, [activeGroupLabel]: true })); | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + const isLinkActive = useCallback( | |
| 78 | + (href: string) => pathname === href, | |
| 79 | + [pathname] | |
| 80 | + ); | |
| 81 | + | |
| 82 | + const isGroupExpanded = useCallback( | |
| 83 | + (label: string) => Boolean(expandedGroups[label]), | |
| 84 | + [expandedGroups] | |
| 85 | + ); | |
| 86 | + | |
| 87 | + const toggleGroup = useCallback((label: string) => { | |
| 88 | + setExpandedGroups((prev) => ({ ...prev, [label]: !prev[label] })); | |
| 89 | + }, []); | |
| 90 | + | |
| 91 | + return { isLinkActive, isGroupExpanded, toggleGroup }; | |
| 92 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?