SettingsPanel
surface설정 페이지의 사이드바 + 콘텐츠 레이아웃. SidebarNav + children 슬롯 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
일반 설정
프로젝트의 기본 정보를 관리합니다.
프로젝트 정보
위험 구역
프로젝트 삭제
이 작업은 되돌릴 수 없습니다.
테스트 커버리지
2026년 2월 4일10/10 통과
10성공
0실패
10전체
- navigation role을 렌더링한다
- 모든 섹션을 렌더링한다
- 아이콘을 렌더링한다
- 뱃지를 렌더링한다
- 사이드바 제목을 렌더링한다
- children을 렌더링한다
- region role을 갖는 메인 영역이 있다
- defaultActiveSection으로 초기 섹션을 설정한다
- 섹션 클릭으로 활성 섹션을 변경한다
- controlled activeSection을 사용할 수 있다
SettingsPanel Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
sections* | SettingsSectionDef[] | — | 섹션 정의 배열. { id, label, icon?, badge? } |
activeSection | string | — | 제어 모드: 현재 활성 섹션 ID |
defaultActiveSection | string | — | 비제어 모드: 초기 활성 섹션 ID |
onSectionChange | (sectionId: string) => void | — | 섹션 변경 핸들러 |
children* | ReactNode | — | 섹션 콘텐츠. activeSection에 따라 조건부 렌더링 |
sidebarTitle | string | "설정" | 사이드바 제목 |
sidebarWidth | "sm" | "md" | "lg" | "md" | 사이드바 너비 |
className | string | — | 최외곽 컨테이너 CSS 클래스 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add settings-panelConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { SettingsPanel } from "@/components/surfaces/settings-panel";Registry metadata
- 설명
- 설정 페이지의 사이드바 + 콘텐츠 레이아웃. SidebarNav + children 슬롯 조합.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- settings
- Install notes
- 없음
포함 파일
settings-panel.tsxsettings-panel.tsx
Surface 소스 보기
settings-panel.tsx
"use client";
import { useState, useCallback, useRef, type ReactNode } from "react";
import { Composite, CompositeItem, CompositeProvider, cn, SurfaceLayout } from "@reopt-ai/opt-ui";
/** Defines the settings section shape. */
export interface SettingsSectionDef {
id: string;
label: string;
icon?: ReactNode;
badge?: string;
description?: string;
}
/** Localized labels for the settings panel component. */
export interface SettingsPanelLabels {
sidebarTitle?: string;
settingsLabel?: (sectionLabel: string) => string;
addSectionLabel?: string;
removeSectionAriaLabel?: string;
emptyMessage?: string;
}
const defaultLabels: Required<SettingsPanelLabels> = {
sidebarTitle: "설정",
settingsLabel: (sectionLabel: string) => `${sectionLabel} 설정`,
addSectionLabel: "추가",
removeSectionAriaLabel: "삭제",
emptyMessage: "설정 섹션이 없습니다.",
};
/** Props for the settings panel component. */
export interface SettingsPanelProps {
sections: SettingsSectionDef[];
activeSection?: string;
defaultActiveSection?: string;
onSectionChange?: (sectionId: string) => void;
children: ReactNode;
sidebarTitle?: string;
sidebarWidth?: "sm" | "md" | "lg";
className?: string;
loading?: boolean;
header?: ReactNode;
actions?: ReactNode;
labels?: SettingsPanelLabels;
/** Enable add/remove controls on sidebar items */
editable?: boolean;
/** Called when user clicks the add section button */
onAddSection?: () => void;
/** Called when user clicks a section's remove button */
onRemoveSection?: (sectionId: string) => void;
}
const NAV_ID_PREFIX = "settings-nav-";
function domId(id: string) {
return `${NAV_ID_PREFIX}${id}`;
}
const sidebarWidthClass = {
sm: "w-48",
md: "w-56",
lg: "w-64",
};
/** Renders the settings panel component. */
export function SettingsPanel({
sections,
activeSection: controlledActiveSection,
defaultActiveSection,
onSectionChange,
children,
sidebarTitle,
sidebarWidth = "md",
className,
loading = false,
header,
actions,
labels: customLabels,
editable = false,
onAddSection,
onRemoveSection,
}: SettingsPanelProps) {
const labels = { ...defaultLabels, ...customLabels };
const [internalActiveSection, setInternalActiveSection] = useState(
defaultActiveSection ?? sections[0]?.id ?? "",
);
const activeSection = controlledActiveSection ?? internalActiveSection;
const contentRef = useRef<HTMLElement>(null);
const handleSectionChange = useCallback(
(sectionId: string) => {
if (onSectionChange) {
onSectionChange(sectionId);
} else {
setInternalActiveSection(sectionId);
}
// Move focus to content heading after section change
requestAnimationFrame(() => {
const heading = contentRef.current?.querySelector("h1, h2, h3");
if (heading instanceof HTMLElement) {
heading.setAttribute("tabindex", "-1");
heading.focus();
heading.addEventListener(
"blur",
() => heading.removeAttribute("tabindex"),
{ once: true },
);
}
});
},
[onSectionChange],
);
const [activeId, setActiveId] = useState<string | null | undefined>(
activeSection ? domId(activeSection) : undefined,
);
if (sections.length === 0) {
return (
<SurfaceLayout
loading={loading}
className={className}
data-opt-id="7PTXR"
data-opt-slug="settings-panel.SettingsPanel"
>
{header}
{actions && <div className="flex justify-end">{actions}</div>}
<div className="text-text-tertiary text-sm">{labels.emptyMessage}</div>
</SurfaceLayout>
);
}
return (
<SurfaceLayout
loading={loading}
className={cn("flex-row gap-0", className)}
data-opt-id="7PTXR"
data-opt-slug="settings-panel.SettingsPanel"
>
{(header || actions) && (
<div className="gap-element flex items-center justify-between pb-6">
<div>{header}</div>
<div>{actions}</div>
</div>
)}
{/* 사이드바 네비게이션 */}
<aside
className={cn(
"border-border shrink-0 border-r pr-6",
sidebarWidthClass[sidebarWidth],
)}
>
{(sidebarTitle ?? labels.sidebarTitle) && (
<h3 className="text-text-primary mb-4 text-sm font-semibold tracking-wide uppercase">
{sidebarTitle ?? labels.sidebarTitle}
</h3>
)}
<CompositeProvider
focusLoop
orientation="vertical"
activeId={activeId}
onActiveIdChange={setActiveId}
>
<Composite
role="navigation"
aria-label={sidebarTitle ?? labels.sidebarTitle}
className="flex flex-col gap-0.5"
>
{sections.map((section) => {
const isSelected = activeSection === section.id;
const hasRemoveButton = editable && onRemoveSection;
return (
<CompositeItem
key={section.id}
id={domId(section.id)}
onClick={() => handleSectionChange(section.id)}
{...(hasRemoveButton && {
render: <div role="button" tabIndex={0} />,
})}
className={cn(
// Base styles
"group gap-element relative flex items-center rounded-lg px-3 py-2.5 text-sm outline-none",
"cursor-pointer transition-all duration-150",
// Default state
!isSelected && [
"text-text-secondary",
"hover:bg-bg-subtle hover:text-text-primary",
],
// Selected
isSelected && ["bg-accent-subtle text-accent font-medium"],
// Keyboard navigation focus
"data-[active-item]:bg-accent-subtle data-[active-item]:text-accent",
// Focus visible
"data-[focus-visible]:ring-ring data-[focus-visible]:ring-2",
"data-[focus-visible]:ring-offset-ring-offset data-[focus-visible]:ring-offset-2",
)}
>
{/* 선택 인디케이터 */}
<span
className={cn(
"absolute top-1/2 left-0 h-5 w-0.5 -translate-y-1/2 rounded-full transition-all duration-150",
isSelected && "bg-accent",
!isSelected &&
"group-data-[active-item]:bg-accent bg-transparent",
)}
/>
{/* 아이콘 */}
{section.icon && (
<span
className={cn(
"shrink-0",
isSelected ? "text-accent" : "text-text-tertiary",
)}
>
{section.icon}
</span>
)}
{/* 레이블 + 설명 */}
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate">{section.label}</span>
{section.description && (
<span className="text-text-tertiary truncate text-xs font-normal">
{section.description}
</span>
)}
</span>
{/* 뱃지 */}
{section.badge && (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
isSelected
? "bg-accent/20 text-accent"
: "bg-bg-muted text-text-tertiary",
)}
>
{section.badge}
</span>
)}
{/* 삭제 버튼 */}
{editable && onRemoveSection && (
<button
type="button"
aria-label={`${section.label} ${labels.removeSectionAriaLabel}`}
className="text-text-tertiary hover:text-danger ml-auto shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-data-[active-item]:opacity-100"
onClick={(e) => {
e.stopPropagation();
onRemoveSection(section.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</CompositeItem>
);
})}
</Composite>
</CompositeProvider>
{/* 섹션 추가 버튼 */}
{editable && onAddSection && (
<button
type="button"
onClick={onAddSection}
className="text-text-tertiary hover:text-text-primary hover:bg-bg-subtle mt-2 flex w-full items-center justify-center rounded-lg px-3 py-2 text-sm transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="mr-1.5"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{labels.addSectionLabel}
</button>
)}
</aside>
{/* 콘텐츠 영역 */}
<main
ref={contentRef}
className="min-w-0 flex-1 pl-8"
role="region"
aria-label={labels.settingsLabel(
sections.find((s) => s.id === activeSection)?.label ??
sidebarTitle ??
labels.sidebarTitle,
)}
>
{children}
</main>
</SurfaceLayout>
);
}