reopt designreopt design
DocsExploreToolsPricingBuilder
시작하기
개요
시작하기
Next.js 설치
Private install
핵심 개념
아키텍처
컴포지션 패턴
접근성
키보드 패턴
스타일링
테마 시스템
고급 패턴
구축·운영
Skills
AI 연동
CLI (opt surface add)
의존 그래프
도구
Canvas 카탈로그
Theme Builder
Form Builder
템플릿
템플릿
릴리즈
릴리즈 노트
Oopt-ui
reopt designreopt design

AI 시대를 위한 디자인 시스템

  • 문서
  • 가격
  • 릴리즈 노트
  • GitHub
  • 서비스 약관
  • 개인정보처리방침

© 2026 reopt-ai. All rights reserved.

VisualDiffManager

block

스크린샷 비주얼 리그레션 관리 + 승인 워크플로우. 3-column 레이아웃 — 스냅샷 리스트 + 이미지 diff 뷰어.

컴포넌트 의존 관계

깊이
▼ USES (3)VisualDiffManagersummary-rowfilter-barloading-overlay
100%

기본 사용

Visual Regression

Total5→
Match1↑
Diff2↓
New1→
Select all

No snapshot selected

Select a snapshot from the list to view the diff.

테스트 커버리지

2026년 2월 4일

생성된 테스트 결과를 찾지 못했습니다.

VisualDiffManager 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.

테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.

VisualDiffManager Props

Prop타입기본값설명
snapshots*SnapshotItem[]—스냅샷 목록
selectedSnapshotIdstring—선택된 스냅샷 ID
onSnapshotSelect(id: string) => void—스냅샷 선택 핸들러
selectedIdsstring[]—일괄 선택된 스냅샷 ID
viewModeDiffViewMode—비교 뷰 모드 (side-by-side/overlay/slider/diff-only)
onApprove(ids: string[]) => void—승인 핸들러
onReject(ids: string[]) => void—거부 핸들러
onUpdateBaseline(ids: string[]) => void—베이스라인 업데이트 핸들러
renderDiffViewer(snapshot: SnapshotItem, mode: DiffViewMode) => ReactNode—커스텀 diff 뷰어 렌더러
loadingboolean—로딩 상태
headerReactNode—커스텀 헤더 슬롯
actionsReactNode—액션 버튼 슬롯
labelsVisualDiffManagerLabels—커스텀 레이블
classNamestring—최외곽 CSS 클래스

Surface 설치

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

bash
npx @reopt-ai/opt-cli surface add visual-diff-manager

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { VisualDiffManager } from "@/components/surfaces/visual-diff-manager";

Registry metadata

설명
스크린샷 비주얼 리그레션 관리 + 승인 워크플로우. 3-column 레이아웃 — 스냅샷 리스트 + 이미지 diff 뷰어.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
filter
Install notes
없음

포함 파일

  • visual-diff-manager.tsx→visual-diff-manager.tsx
Surface 소스 보기
visual-diff-manager.tsx
"use client";

import * as React from "react";
import { SurfaceLayout, SummaryRow, FilterBar, cn, type FilterGroupDef, type StatCardType, } from "@reopt-ai/opt-ui";

// ── Types ──

/** Type definition for `SnapshotStatus`. */
export type SnapshotStatus = "match" | "diff" | "new" | "missing";

/** View modes supported by `Diff`. */
export type DiffViewMode = "side-by-side" | "overlay" | "slider" | "diff-only";

/** Item shape for `Snapshot`. */
export interface SnapshotItem {
  id: string;
  name: string;
  status: SnapshotStatus;
  group?: string;
  baselineUrl?: string;
  actualUrl?: string;
  diffUrl?: string;
  diffPercentage?: number;
  [key: string]: unknown;
}

/** Labels for `VisualDiffManager`. */
export interface VisualDiffManagerLabels {
  title?: string;
  searchPlaceholder?: string;
  selectAllLabel?: string;
  approveLabel?: string;
  rejectLabel?: string;
  updateBaselineLabel?: string;
  sideBySideLabel?: string;
  overlayLabel?: string;
  sliderLabel?: string;
  diffOnlyLabel?: string;
  baselineLabel?: string;
  actualLabel?: string;
  noBaselineLabel?: string;
  noActualLabel?: string;
  emptyTitle?: string;
  emptyDescription?: string;
  noSelectionTitle?: string;
  noSelectionDescription?: string;
  matchLabel?: string;
  diffLabel?: string;
  newLabel?: string;
  missingLabel?: string;
  totalLabel?: string;
  selectedCountLabel?: (count: number) => string;
  settingsLabel?: string;
  refreshLabel?: string;
}

const defaultLabels: Required<VisualDiffManagerLabels> = {
  title: "Visual Regression",
  searchPlaceholder: "Search snapshots…",
  selectAllLabel: "Select all",
  approveLabel: "Approve",
  rejectLabel: "Reject",
  updateBaselineLabel: "Update Baseline",
  sideBySideLabel: "Side by Side",
  overlayLabel: "Overlay",
  sliderLabel: "Slider",
  diffOnlyLabel: "Diff Only",
  baselineLabel: "Baseline",
  actualLabel: "Actual",
  noBaselineLabel: "No baseline",
  noActualLabel: "No actual",
  emptyTitle: "No snapshots found",
  emptyDescription: "Run visual regression tests to see results here.",
  noSelectionTitle: "No snapshot selected",
  noSelectionDescription: "Select a snapshot from the list to view the diff.",
  matchLabel: "Match",
  diffLabel: "Diff",
  newLabel: "New",
  missingLabel: "Missing",
  totalLabel: "Total",
  selectedCountLabel: (count: number) => `${count} selected`,
  settingsLabel: "Settings",
  refreshLabel: "Refresh",
};

// ── Props ──

/** Props for `VisualDiffManager`. */
export interface VisualDiffManagerProps {
  /** Snapshot items */
  snapshots: SnapshotItem[];
  /** Currently selected snapshot id */
  selectedSnapshotId?: string;
  /** Called when a snapshot is selected */
  onSnapshotSelect?: (snapshotId: string) => void;
  /** Selected snapshot IDs for batch actions */
  selectedIds?: string[];
  /** Called when selection changes */
  onSelectionChange?: (ids: string[]) => void;
  /** Active diff view mode */
  viewMode?: DiffViewMode;
  /** Called when view mode changes */
  onViewModeChange?: (mode: DiffViewMode) => void;
  /** Approve selected snapshots */
  onApprove?: (ids: string[]) => void;
  /** Reject selected snapshots */
  onReject?: (ids: string[]) => void;
  /** Update baseline for selected snapshots */
  onUpdateBaseline?: (ids: string[]) => void;
  /** Search query */
  searchQuery?: string;
  /** Called when search changes */
  onSearchChange?: (query: string) => void;
  /** Sidebar filters */
  filters?: FilterGroupDef[];
  /** Called when filter changes */
  onFilterChange?: (
    filterId: string,
    value: string | string[] | boolean,
  ) => void;
  /** Custom diff viewer renderer */
  renderDiffViewer?: (
    snapshot: SnapshotItem,
    viewMode: DiffViewMode,
  ) => React.ReactNode;
  /** Sidebar list width */
  sidebarWidth?: "sm" | "md" | "lg";
  /** Settings button handler */
  onSettings?: () => void;
  /** Refresh handler */
  onRefresh?: () => void;
  loading?: boolean;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: VisualDiffManagerLabels;
  className?: string;
}

// ── Defaults ──

const defaultFilters: FilterGroupDef[] = [
  {
    id: "status",
    label: "Status",
    type: "multi-select",
    options: [
      { id: "match", value: "match", label: "Match" },
      { id: "diff", value: "diff", label: "Diff" },
      { id: "new", value: "new", label: "New" },
      { id: "missing", value: "missing", label: "Missing" },
    ],
  },
];

const sidebarWidthClass = {
  sm: "w-64",
  md: "w-80",
  lg: "w-96",
};

const statusColors: Record<SnapshotStatus, string> = {
  match: "bg-success",
  diff: "bg-warning",
  new: "bg-accent",
  missing: "bg-danger",
};

const statusTextColors: Record<SnapshotStatus, string> = {
  match: "text-success",
  diff: "text-warning",
  new: "text-accent-fg",
  missing: "text-danger",
};

// ── Component ──

/** Renders the `VisualDiffManager` component. */
export function VisualDiffManager({
  snapshots,
  selectedSnapshotId,
  onSnapshotSelect,
  selectedIds: controlledSelectedIds,
  onSelectionChange,
  viewMode: controlledViewMode,
  onViewModeChange,
  onApprove,
  onReject,
  onUpdateBaseline,
  searchQuery: controlledSearchQuery,
  onSearchChange,
  filters = defaultFilters,
  onFilterChange,
  renderDiffViewer,
  sidebarWidth = "md",
  onSettings,
  onRefresh,
  loading = false,
  header,
  actions,
  labels: customLabels,
  className,
}: VisualDiffManagerProps) {
  const labels = { ...defaultLabels, ...customLabels };

  // Internal state
  const [internalSelectedId, setInternalSelectedId] = React.useState<
    string | undefined
  >(undefined);
  const [internalSelectedIds, setInternalSelectedIds] = React.useState<
    string[]
  >([]);
  const [internalViewMode, setInternalViewMode] =
    React.useState<DiffViewMode>("side-by-side");
  const [internalSearch, setInternalSearch] = React.useState("");

  const selectedId = selectedSnapshotId ?? internalSelectedId;
  const selectedBatchIds = controlledSelectedIds ?? internalSelectedIds;
  const viewMode = controlledViewMode ?? internalViewMode;
  const searchQuery = controlledSearchQuery ?? internalSearch;

  const selectedSnapshot = snapshots.find((s) => s.id === selectedId);
  const isEmpty = snapshots.length === 0;

  // Summary stats
  const stats: StatCardType[] = React.useMemo(() => {
    const counts = { match: 0, diff: 0, new: 0, missing: 0 };
    for (const s of snapshots) {
      counts[s.status]++;
    }
    return [
      {
        id: "total",
        title: labels.totalLabel,
        value: String(snapshots.length),
        change: "",
        trend: "neutral" as const,
      },
      {
        id: "match",
        title: labels.matchLabel,
        value: String(counts.match),
        change: "",
        trend: "up" as const,
      },
      {
        id: "diff",
        title: labels.diffLabel,
        value: String(counts.diff),
        change: "",
        trend: counts.diff > 0 ? ("down" as const) : ("neutral" as const),
      },
      {
        id: "new",
        title: labels.newLabel,
        value: String(counts.new),
        change: "",
        trend: "neutral" as const,
      },
    ];
  }, [snapshots, labels]);

  const handleSelect = React.useCallback(
    (id: string) => {
      setInternalSelectedId(id);
      onSnapshotSelect?.(id);
    },
    [onSnapshotSelect],
  );

  const handleCheckbox = React.useCallback(
    (id: string, checked: boolean) => {
      const next = checked
        ? [...selectedBatchIds, id]
        : selectedBatchIds.filter((x) => x !== id);
      setInternalSelectedIds(next);
      onSelectionChange?.(next);
    },
    [selectedBatchIds, onSelectionChange],
  );

  const handleSelectAll = React.useCallback(() => {
    const allIds = snapshots.map((s) => s.id);
    const next = selectedBatchIds.length === snapshots.length ? [] : allIds;
    setInternalSelectedIds(next);
    onSelectionChange?.(next);
  }, [snapshots, selectedBatchIds, onSelectionChange]);

  const handleViewModeChange = React.useCallback(
    (mode: DiffViewMode) => {
      setInternalViewMode(mode);
      onViewModeChange?.(mode);
    },
    [onViewModeChange],
  );

  const handleSearchChange = React.useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setInternalSearch(e.target.value);
      onSearchChange?.(e.target.value);
    },
    [onSearchChange],
  );

  // Filter by search
  const filteredSnapshots = React.useMemo(() => {
    if (!searchQuery) return snapshots;
    const q = searchQuery.toLowerCase();
    return snapshots.filter((s) => s.name.toLowerCase().includes(q));
  }, [snapshots, searchQuery]);

  const viewModes = React.useMemo(
    () => [
      { id: "side-by-side" as const, label: labels.sideBySideLabel },
      { id: "overlay" as const, label: labels.overlayLabel },
      { id: "slider" as const, label: labels.sliderLabel },
      { id: "diff-only" as const, label: labels.diffOnlyLabel },
    ],
    [
      labels.sideBySideLabel,
      labels.overlayLabel,
      labels.sliderLabel,
      labels.diffOnlyLabel,
    ],
  );

  const hasBatchSelection = selectedBatchIds.length > 0;

  return (
    <SurfaceLayout loading={loading} className={cn("gap-0", className)}>
      {/* Header */}
      <div className="border-border-subtle flex items-center justify-between border-b px-4 py-3">
        {header ? (
          <div>{header}</div>
        ) : (
          <h2 className="text-text-primary text-lg font-semibold">
            {labels.title}
          </h2>
        )}
        <div className="gap-element flex items-center">
          {onSettings && (
            <button
              type="button"
              onClick={onSettings}
              className="text-text-tertiary hover:text-text-primary rounded p-1.5 text-sm transition-colors"
              aria-label={labels.settingsLabel}
            >
              ⚙
            </button>
          )}
          {onRefresh && (
            <button
              type="button"
              onClick={onRefresh}
              className="text-text-tertiary hover:text-text-primary rounded p-1.5 text-sm transition-colors"
              aria-label={labels.refreshLabel}
            >
              ↻
            </button>
          )}
          {actions}
        </div>
      </div>

      {/* Summary stats */}
      <div className="border-border-subtle border-b px-4 py-3">
        <SummaryRow stats={stats} columns={4} />
      </div>

      {isEmpty ? (
        <div
          role="status"
          className="flex flex-col items-center justify-center py-16 text-center"
        >
          <h3 className="text-text-primary text-lg font-medium">
            {labels.emptyTitle}
          </h3>
          <p className="text-text-tertiary mt-1 text-sm">
            {labels.emptyDescription}
          </p>
        </div>
      ) : (
        <>
          {/* Batch action bar */}
          {hasBatchSelection && (
            <div className="border-border-subtle bg-bg-subtle gap-element flex items-center border-b px-4 py-2">
              <span className="text-text-secondary text-sm">
                {labels.selectedCountLabel(selectedBatchIds.length)}
              </span>
              <div className="gap-element ml-auto flex items-center">
                {onApprove && (
                  <button
                    type="button"
                    onClick={() => onApprove(selectedBatchIds)}
                    className="bg-success/10 text-success hover:bg-success/20 rounded px-3 py-1 text-sm font-medium transition-colors"
                  >
                    {labels.approveLabel}
                  </button>
                )}
                {onReject && (
                  <button
                    type="button"
                    onClick={() => onReject(selectedBatchIds)}
                    className="bg-danger/10 text-danger hover:bg-danger/20 rounded px-3 py-1 text-sm font-medium transition-colors"
                  >
                    {labels.rejectLabel}
                  </button>
                )}
                {onUpdateBaseline && (
                  <button
                    type="button"
                    onClick={() => onUpdateBaseline(selectedBatchIds)}
                    className="bg-accent-subtle text-accent-fg hover:bg-accent-subtle/80 rounded px-3 py-1 text-sm font-medium transition-colors"
                  >
                    {labels.updateBaselineLabel}
                  </button>
                )}
              </div>
            </div>
          )}

          {/* Main content: sidebar + viewer */}
          <div className="flex flex-1">
            {/* Snapshot list sidebar */}
            <aside
              className={cn(
                "border-border-subtle shrink-0 overflow-y-auto border-r",
                sidebarWidthClass[sidebarWidth],
              )}
            >
              {/* Search */}
              <div className="border-border-subtle border-b p-3">
                <input
                  type="search"
                  value={searchQuery}
                  onChange={handleSearchChange}
                  placeholder={labels.searchPlaceholder}
                  className="bg-bg-subtle text-text-primary placeholder:text-text-tertiary focus-visible:ring-accent w-full rounded-md px-3 py-1.5 text-sm outline-none focus-visible:ring-1"
                  aria-label={labels.searchPlaceholder}
                />
              </div>

              {/* Filters */}
              <div className="border-border-subtle border-b p-3">
                <FilterBar filters={filters} onFilterChange={onFilterChange} />
              </div>

              {/* Select all */}
              <div className="border-border-subtle gap-element flex items-center border-b px-3 py-2">
                <input
                  type="checkbox"
                  checked={
                    selectedBatchIds.length === snapshots.length &&
                    snapshots.length > 0
                  }
                  onChange={handleSelectAll}
                  className="accent-accent h-3.5 w-3.5"
                  aria-label={labels.selectAllLabel}
                />
                <span className="text-text-tertiary text-xs">
                  {labels.selectAllLabel}
                </span>
              </div>

              {/* Snapshot items */}
              <nav aria-label={labels.title}>
                {filteredSnapshots.map((snapshot) => (
                  <div
                    key={snapshot.id}
                    className={cn(
                      "border-border-subtle gap-element flex items-center border-b px-3 py-2 transition-colors",
                      selectedId === snapshot.id
                        ? "bg-accent-subtle"
                        : "hover:bg-bg-subtle",
                    )}
                  >
                    <input
                      type="checkbox"
                      checked={selectedBatchIds.includes(snapshot.id)}
                      onChange={(e) =>
                        handleCheckbox(snapshot.id, e.target.checked)
                      }
                      className="accent-accent h-3.5 w-3.5 shrink-0"
                      aria-label={`Select ${snapshot.name}`}
                    />
                    <button
                      type="button"
                      onClick={() => handleSelect(snapshot.id)}
                      className="gap-element flex min-w-0 flex-1 items-center text-left"
                      aria-current={
                        selectedId === snapshot.id ? "true" : undefined
                      }
                    >
                      <span
                        className={cn(
                          "h-2 w-2 shrink-0 rounded-full",
                          statusColors[snapshot.status],
                        )}
                        aria-hidden="true"
                      />
                      <span className="min-w-0 flex-1 truncate text-sm">
                        {snapshot.name}
                      </span>
                      <span
                        className={cn(
                          "shrink-0 text-xs font-medium",
                          statusTextColors[snapshot.status],
                        )}
                      >
                        {snapshot.diffPercentage != null
                          ? `${snapshot.diffPercentage.toFixed(1)}%`
                          : snapshot.status}
                      </span>
                    </button>
                  </div>
                ))}
              </nav>
            </aside>

            {/* Diff viewer */}
            <div className="min-w-0 flex-1">
              {selectedSnapshot ? (
                <div className="flex h-full flex-col">
                  {/* View mode toggle */}
                  <div className="border-border-subtle gap-element flex items-center border-b px-4 py-2">
                    <div
                      className="border-border inline-flex rounded-md border"
                      role="group"
                      aria-label="View mode"
                    >
                      {viewModes.map((mode, i) => (
                        <button
                          key={mode.id}
                          type="button"
                          onClick={() => handleViewModeChange(mode.id)}
                          aria-pressed={viewMode === mode.id}
                          className={cn(
                            "px-3 py-1.5 text-xs font-medium transition-colors",
                            i === 0 && "rounded-l-md",
                            i === viewModes.length - 1 && "rounded-r-md",
                            viewMode === mode.id
                              ? "bg-accent-subtle text-text-primary"
                              : "text-text-secondary hover:bg-bg-subtle",
                          )}
                        >
                          {mode.label}
                        </button>
                      ))}
                    </div>
                  </div>

                  {/* Diff content */}
                  <div className="flex-1 overflow-auto p-4">
                    {renderDiffViewer ? (
                      renderDiffViewer(selectedSnapshot, viewMode)
                    ) : (
                      <div className="gap-group flex">
                        {/* Default side-by-side view */}
                        <div className="flex-1">
                          <h4 className="text-text-tertiary mb-2 text-xs font-medium">
                            {labels.baselineLabel}
                          </h4>
                          <div className="bg-bg-subtle border-border-subtle flex aspect-video items-center justify-center rounded-lg border">
                            {selectedSnapshot.baselineUrl ? (
                              <img
                                src={selectedSnapshot.baselineUrl}
                                alt={`${selectedSnapshot.name} baseline`}
                                className="max-h-full max-w-full object-contain"
                              />
                            ) : (
                              <span className="text-text-tertiary text-sm">
                                {labels.noBaselineLabel}
                              </span>
                            )}
                          </div>
                        </div>
                        <div className="flex-1">
                          <h4 className="text-text-tertiary mb-2 text-xs font-medium">
                            {labels.actualLabel}
                          </h4>
                          <div className="bg-bg-subtle border-border-subtle flex aspect-video items-center justify-center rounded-lg border">
                            {selectedSnapshot.actualUrl ? (
                              <img
                                src={selectedSnapshot.actualUrl}
                                alt={`${selectedSnapshot.name} actual`}
                                className="max-h-full max-w-full object-contain"
                              />
                            ) : (
                              <span className="text-text-tertiary text-sm">
                                {labels.noActualLabel}
                              </span>
                            )}
                          </div>
                        </div>
                      </div>
                    )}
                  </div>

                  {/* Action buttons */}
                  <div className="border-border-subtle gap-element flex items-center border-t px-4 py-3">
                    {onApprove && (
                      <button
                        type="button"
                        onClick={() => onApprove([selectedSnapshot.id])}
                        className="bg-success/10 text-success hover:bg-success/20 rounded px-3 py-1.5 text-sm font-medium transition-colors"
                      >
                        {labels.approveLabel}
                      </button>
                    )}
                    {onReject && (
                      <button
                        type="button"
                        onClick={() => onReject([selectedSnapshot.id])}
                        className="bg-danger/10 text-danger hover:bg-danger/20 rounded px-3 py-1.5 text-sm font-medium transition-colors"
                      >
                        {labels.rejectLabel}
                      </button>
                    )}
                    {onUpdateBaseline && (
                      <button
                        type="button"
                        onClick={() => onUpdateBaseline([selectedSnapshot.id])}
                        className="bg-accent-subtle text-accent-fg hover:bg-accent-subtle/80 rounded px-3 py-1.5 text-sm font-medium transition-colors"
                      >
                        {labels.updateBaselineLabel}
                      </button>
                    )}
                  </div>
                </div>
              ) : (
                <div className="flex h-full flex-col items-center justify-center py-16 text-center">
                  <h3 className="text-text-primary text-lg font-medium">
                    {labels.noSelectionTitle}
                  </h3>
                  <p className="text-text-tertiary mt-1 text-sm">
                    {labels.noSelectionDescription}
                  </p>
                </div>
              )}
            </div>
          </div>
        </>
      )}
    </SurfaceLayout>
  );
}

VisualDiffManager.displayName = "VisualDiffManager";