reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

SettingsPanel

surface

설정 페이지의 사이드바 + 콘텐츠 레이아웃. SidebarNav + children 슬롯 조합.

컴포넌트 의존 관계

깊이
▼ USES (1)SettingsPanelloading-overlay
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? }
activeSectionstring—제어 모드: 현재 활성 섹션 ID
defaultActiveSectionstring—비제어 모드: 초기 활성 섹션 ID
onSectionChange(sectionId: string) => void—섹션 변경 핸들러
children*ReactNode—섹션 콘텐츠. activeSection에 따라 조건부 렌더링
sidebarTitlestring"설정"사이드바 제목
sidebarWidth"sm" | "md" | "lg""md"사이드바 너비
classNamestring—최외곽 컨테이너 CSS 클래스

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add settings-panel

Consumer 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.tsx→settings-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>
  );
}