플랫 링크만 지원하던 사이드바에 그룹 헤딩+펼침/접힘 컨테이너(SidebarGroup)를 추가한다. 펼침 상태는 소비처가 소유하는 비제어 프레젠테이션 컴포넌트이며, IconButton/SidebarLink와 동일하게 'use client' 없이 이벤트 핸들러 prop만 받는다. Co-Authored-By: Claude Opus 5
+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 }) {
+ <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>
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this 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?