임동욱 임동욱 08-10
feat: 사이드바 그룹 공용 컴포넌트 추가
플랫 링크만 지원하던 사이드바에 그룹 헤딩+펼침/접힘 컨테이너(SidebarGroup)를
추가한다. 펼침 상태는 소비처가 소유하는 비제어 프레젠테이션 컴포넌트이며,
IconButton/SidebarLink와 동일하게 'use client' 없이 이벤트 핸들러 prop만 받는다.

Co-Authored-By: Claude Opus 5 
@4ac90df0670a772f7a7bf3add315c383b1fd0374
 
components/ui/sidebar-group.tsx (added)
+++ components/ui/sidebar-group.tsx
@@ -0,0 +1,74 @@
+import type { ReactNode } from "react";
+
+interface SidebarGroupProps {
+  label: string;
+  icon?: ReactNode;
+  /** 펼침 상태 — 상태 소유는 소비처(feature)다. 이 컴포넌트는 비제어 프레젠테이션이다. */
+  isExpanded: boolean;
+  onToggle: () => void;
+  /** 자식 SidebarLink들(isNested로 들여쓰기). */
+  children: ReactNode;
+}
+
+/**
+ * 사이드바 그룹 헤딩 — 자식 내비게이션 항목(SidebarLink)을 펼침/접힘으로 묶는다.
+ * 상태를 갖지 않는 프레젠테이션 컴포넌트로, IconButton·SidebarLink와 동일한 관례로
+ * 'use client'를 붙이지 않는다: onToggle을 받아 <button onClick>에 연결하지만,
+ * 이 파일 자체는 훅을 쓰지 않는 순수 함수 컴포넌트다. Next.js App Router의 클라이언트
+ * 경계는 "이 모듈을 어느 파일이 import하는가"로 정해지므로, 실제 펼침 상태(useState 등)를
+ * 들고 있는 소비처(feature) 파일이 'use client'면 이 컴포넌트도 그 클라이언트 모듈
+ * 그래프에 포함되어 정상 동작한다(node_modules/next/dist/docs/01-app/01-getting-started/
+ * 05-server-and-client-components.md "module graph" 설명 참조).
+ *
+ * aria-controls는 의도적으로 생략했다 — 대상 id를 안전하게 만들 방법이 마땅치 않다
+ * (useId는 위 이유로 이 컴포넌트에서 쓸 수 없고, label을 id로 슬러그화하면 공백 포함
+ * 라벨이나 중복 라벨에서 깨질 수 있다). WAI-ARIA APG의 Disclosure 패턴에서 aria-controls는
+ * 필수가 아니라 선택 사항이며(스크린리더 지원도 일관적이지 않음), aria-expanded + 버튼
+ * 바로 다음에 내용이 오는 DOM 인접 구조만으로 충분하다고 판단했다.
+ */
+export function SidebarGroup({ label, icon, isExpanded, onToggle, children }: SidebarGroupProps) {
+  return (
+    <div>
+      <button
+        type="button"
+        onClick={onToggle}
+        aria-expanded={isExpanded}
+        className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-label-lg text-foreground transition-colors duration-200 hover:bg-surface-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
+      >
+        {icon}
+        {/* min-w-0: SidebarLink와 동일한 truncate 대응(flex 아이템 기본 min-width:auto가
+            overflow-hidden을 무력화하는 문제). 접힘 폭(md:w-16)에서도 한 줄 유지 + 말줄임만
+            적용한다 — 아이콘 전용 표시로 전환하는 정식 해법은 아이콘 자산이 없어 이번
+            범위에서 다루지 않는다(SidebarLink와 동일 사유). */}
+        <span className="min-w-0 flex-1 truncate">{label}</span>
+        <ChevronIcon
+          className={`shrink-0 transition-transform duration-200 ${isExpanded ? "rotate-90" : "rotate-0"}`}
+        />
+      </button>
+      {isExpanded && <div className="flex flex-col">{children}</div>}
+    </div>
+  );
+}
+
+/**
+ * 펼침/접힘 표시용 셰브론(오른쪽 화살표 — 펼치면 90도 회전해 아래를 향한다). 그룹 헤딩
+ * 전용 장식 요소라 components/ui/icons/에 별도 파일로 승격하지 않고 이 파일 안에 둔다.
+ * moon-icon.tsx/sun-icon.tsx와 동일한 인라인 SVG 관례(stroke=currentColor로 상위 text
+ * 색 토큰 상속, 장식용이라 aria-hidden).
+ */
+function ChevronIcon({ className }: { className?: string }) {
+  return (
+    <svg
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="currentColor"
+      strokeWidth="2"
+      strokeLinecap="round"
+      strokeLinejoin="round"
+      aria-hidden="true"
+      className={`size-4 ${className ?? ""}`}
+    >
+      <path d="M9 6l6 6-6 6" />
+    </svg>
+  );
+}
Add a comment
List