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.

DataExplorer

surface

필터링 가능한 데이터 탐색 영역. compact SurfaceLayout 안에서 SearchCombobox + StatusSelect + DataTable을 조합.

컴포넌트 의존 관계

깊이
▼ USES (4)DataExplorersearch-comboboxstatus-selectdata-tableloading-overlay
100%

기본 사용

상품 목록

총 7개 결과
상품명카테고리가격상태
MacBook Pro 14전자기기₩2,890,000활성
Magic Keyboard전자기기₩159,000활성
스탠딩 데스크가구₩450,000임시
에르고 체어가구₩890,000활성
모니터 암전자기기₩89,000보관
USB-C 허브전자기기₩79,000활성
책장가구₩250,000임시

테스트 커버리지

2026년 2월 4일9/9 통과
9성공
0실패
9전체
  • 테이블을 렌더링한다
  • 제목을 렌더링한다
  • 데이터를 렌더링한다
  • 결과 개수를 표시한다
  • 검색 필드를 렌더링한다
  • 필터를 렌더링한다
  • onRowClick 콜백을 전달한다
  • 빈 상태 메시지를 표시한다
  • header 슬롯을 렌더링한다

DataExplorer Props

Prop타입기본값설명
data*T[]—탐색할 데이터 배열
columns*ColumnDef<T>[]—테이블 컬럼 정의
keyExtractor*(row: T) => string—각 행의 고유 키 추출 함수
searchKeys(keyof T)[]—검색 대상 필드 키 배열. 지정 시 검색 기능 활성화
searchPlaceholderstring"검색..."검색 입력 플레이스홀더
filtersFilterDef[][]필터 정의 배열. { id, label, options, accessor }
onRowClick(row: T) => void—행 클릭 핸들러
titlestring"데이터 탐색기"영역 제목
emptyMessagestring"데이터가 없습니다"빈 상태 메시지
headerReactNode—제목 옆에 표시할 커스텀 헤더 요소
classNamestring—최외곽 SurfaceLayout CSS 클래스

Surface 설치

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

bash
npx @reopt-ai/opt-cli surface add data-explorer

Consumer target

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

tsx
import { DataExplorer } from "@/components/surfaces/data-explorer";

Registry metadata

설명
필터링 가능한 데이터 탐색 영역. compact SurfaceLayout 안에서 SearchCombobox + StatusSelect + DataTable을 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
match-sorter
태그
table
Install notes
  • Install additional packages: match-sorter.

포함 파일

  • data-explorer.tsx→data-explorer.tsx
Surface 소스 보기
data-explorer.tsx
"use client";

import { useMemo, useState, useCallback, type ReactNode } from "react";
import { matchSorter } from "match-sorter";
import { SearchCombobox, StatusSelect, DataTable, type ColumnDef, SurfaceLayout, cn } from "@reopt-ai/opt-ui";
import type { SelectOption } from "@reopt-ai/opt-ui";

/** Defines the filter shape. */
export interface FilterDef {
  id: string;
  label: string;
  options: SelectOption[];
  accessor: string;
}

/** Localized labels for the data explorer component. */
export interface DataExplorerLabels {
  title?: string;
  searchPlaceholder?: string;
  emptyMessage?: string;
  searchLabel?: string;
  resultsSummary?: (filtered: number, total: number) => string;
  tableViewLabel?: string;
  gridViewLabel?: string;
}

const defaultLabels: Required<DataExplorerLabels> = {
  title: "데이터 탐색기",
  searchPlaceholder: "검색...",
  emptyMessage: "데이터가 없습니다",
  searchLabel: "검색",
  resultsSummary: (filtered: number, total: number) =>
    filtered !== total
      ? `총 ${filtered}개 결과 (전체 ${total}개 중)`
      : `총 ${filtered}개 결과`,
  tableViewLabel: "테이블 보기",
  gridViewLabel: "카드 보기",
};

/** Type definition for data explorer view mode. */
export type DataExplorerViewMode = "table" | "grid";

/** Props for the data explorer component. */
export interface DataExplorerProps<T> {
  data: T[];
  columns: ColumnDef<T>[];
  keyExtractor: (row: T) => string;
  searchKeys?: (keyof T)[];
  searchPlaceholder?: string;
  filters?: FilterDef[];
  onRowClick?: (row: T) => void;
  /** Highlight a single row by its key */
  activeRowId?: string;
  title?: string;
  emptyMessage?: string;
  header?: ReactNode;
  /** Action buttons rendered in the header area (right side) */
  actions?: ReactNode;
  loading?: boolean;
  labels?: DataExplorerLabels;
  /** Display mode: "table" (default DataTable) or "grid" (card grid) */
  viewMode?: DataExplorerViewMode;
  /** Render function for grid cards. Required when viewMode="grid". */
  renderCard?: (item: T) => ReactNode;
  /** Show table/grid toggle buttons in the header area. Requires renderCard. */
  showViewToggle?: boolean;
  /** Called when the view mode changes via toggle buttons */
  onViewModeChange?: (mode: DataExplorerViewMode) => void;
  /** Custom className for the SurfaceLayout root. */
  className?: string;
  /** Group items by a key or custom function. Renders collapsible group headers. */
  groupBy?: keyof T | ((item: T) => string);
  /** Custom render for group headers. Defaults to group name + count. */
  renderGroupHeader?: (group: string, items: T[]) => ReactNode;
  /** Whether groups are initially collapsed. Defaults to false (expanded). */
  defaultCollapsed?: boolean;
  /** Enable expandable rows with toggle arrows. Requires renderExpandedRow. */
  expandableRows?: boolean;
  /** Render function for the expanded row content (e.g., edit form, detail view). */
  renderExpandedRow?: (item: T) => ReactNode;
}

/** Renders the data explorer component. */
export function DataExplorer<T extends object>({
  data,
  columns,
  keyExtractor,
  searchKeys,
  searchPlaceholder,
  filters = [],
  onRowClick,
  activeRowId,
  title,
  emptyMessage,
  header,
  actions,
  loading = false,
  labels: customLabels,
  viewMode: controlledViewMode,
  renderCard,
  showViewToggle = false,
  onViewModeChange,
  className,
  groupBy,
  renderGroupHeader,
  defaultCollapsed = false,
  expandableRows = false,
  renderExpandedRow,
}: DataExplorerProps<T>) {
  const labels = { ...defaultLabels, ...customLabels };
  const [internalViewMode, setInternalViewMode] =
    useState<DataExplorerViewMode>("table");
  const viewMode = controlledViewMode ?? internalViewMode;

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

  const [searchValue, setSearchValue] = useState("");
  const [filterValues, setFilterValues] = useState<Record<string, string>>(() =>
    filters.reduce(
      (acc, f) => ({ ...acc, [f.id]: f.options[0]?.value ?? "" }),
      {},
    ),
  );

  const handleFilterChange = useCallback((filterId: string, value: string) => {
    setFilterValues((prev) => ({ ...prev, [filterId]: value }));
  }, []);

  const filteredData = useMemo(() => {
    let result = data;

    // 필터 적용
    for (const filter of filters) {
      const value = filterValues[filter.id];
      if (value && value !== "all") {
        result = result.filter((row) => {
          const rowValue = (row as Record<string, unknown>)[filter.accessor];
          return rowValue === value;
        });
      }
    }

    // 검색 적용
    if (searchValue && searchKeys && searchKeys.length > 0) {
      result = matchSorter(result, searchValue, {
        keys: searchKeys as string[],
      });
    }

    return result;
  }, [data, searchValue, filterValues, filters, searchKeys]);

  // 검색용 문자열 추출 (SearchCombobox에 전달)
  const searchItems = useMemo(() => {
    if (!searchKeys || searchKeys.length === 0) return [];
    const set = new Set<string>();
    for (const row of data) {
      for (const key of searchKeys) {
        const value = (row as Record<string, unknown>)[key as string];
        if (typeof value === "string") {
          set.add(value);
        }
      }
    }
    return Array.from(set);
  }, [data, searchKeys]);

  // groupBy 처리
  const groupedData = useMemo(() => {
    if (!groupBy) return null;
    const groups = new Map<string, T[]>();
    for (const item of filteredData) {
      const key =
        typeof groupBy === "function"
          ? groupBy(item)
          : String((item as Record<string, unknown>)[groupBy as string] ?? "");
      const arr = groups.get(key);
      if (arr) {
        arr.push(item);
      } else {
        groups.set(key, [item]);
      }
    }
    return groups;
  }, [filteredData, groupBy]);

  // Track explicitly toggled groups. Groups not in this set follow defaultCollapsed.
  const [toggledGroups, setToggledGroups] = useState<Set<string>>(new Set());

  const isGroupCollapsed = useCallback(
    (group: string) =>
      toggledGroups.has(group) ? !defaultCollapsed : defaultCollapsed,
    [toggledGroups, defaultCollapsed],
  );

  const toggleGroup = useCallback((group: string) => {
    setToggledGroups((prev) => {
      const next = new Set(prev);
      if (next.has(group)) {
        next.delete(group);
      } else {
        next.add(group);
      }
      return next;
    });
  }, []);

  const hasSearch = searchKeys && searchKeys.length > 0;
  const hasFilters = filters.length > 0;
  const resolvedEmptyMessage = emptyMessage ?? labels.emptyMessage;

  const renderCardGrid = (items: T[]) => (
    <div className="gap-group grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
      {items.map((item) => (
        <div
          key={keyExtractor(item)}
          className={cn(
            onRowClick && "cursor-pointer",
            activeRowId === keyExtractor(item) && "ring-accent ring-2",
            "rounded-lg",
          )}
          {...(onRowClick
            ? {
                role: "button" as const,
                tabIndex: 0,
                onClick: () => onRowClick(item),
                onKeyDown: (e: React.KeyboardEvent<HTMLDivElement>) => {
                  if (e.key === "Enter" || e.key === " ") {
                    e.preventDefault();
                    onRowClick(item);
                  }
                },
              }
            : {})}
        >
          {renderCard?.(item)}
        </div>
      ))}
    </div>
  );

  let dataContent: React.ReactNode;

  if (groupedData) {
    if (filteredData.length === 0) {
      dataContent = (
        <p className="text-text-tertiary py-8 text-center text-sm">
          {resolvedEmptyMessage}
        </p>
      );
    } else {
      dataContent = (
        <div className="gap-group flex flex-col">
          {Array.from(groupedData.entries()).map(([group, items]) => {
            const collapsed = isGroupCollapsed(group);
            return (
              <div key={group}>
                <button
                  type="button"
                  className={cn(
                    "border-border bg-bg-subtle flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-left text-sm font-medium transition-colors",
                    "hover:bg-bg-muted text-text-primary",
                  )}
                  aria-expanded={!collapsed}
                  onClick={() => toggleGroup(group)}
                >
                  {renderGroupHeader ? (
                    renderGroupHeader(group, items)
                  ) : (
                    <span>
                      {group}{" "}
                      <span className="text-text-tertiary">
                        ({items.length})
                      </span>
                    </span>
                  )}
                  <svg
                    className={cn(
                      "text-text-tertiary size-4 transition-transform",
                      !collapsed && "rotate-180",
                    )}
                    fill="none"
                    viewBox="0 0 24 24"
                    stroke="currentColor"
                    aria-hidden="true"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M19 9l-7 7-7-7"
                    />
                  </svg>
                </button>
                {!collapsed &&
                  (viewMode === "grid" && renderCard ? (
                    <div className="mt-2">{renderCardGrid(items)}</div>
                  ) : (
                    <div className="mt-2">
                      <DataTable
                        data={items}
                        columns={columns}
                        keyExtractor={keyExtractor}
                        onRowClick={onRowClick}
                        activeRowId={activeRowId}
                        emptyMessage={resolvedEmptyMessage}
                        expandable={expandableRows}
                        renderExpanded={renderExpandedRow}
                        title=""
                      />
                    </div>
                  ))}
              </div>
            );
          })}
        </div>
      );
    }
  } else if (viewMode === "grid" && renderCard) {
    if (filteredData.length === 0) {
      dataContent = (
        <p className="text-text-tertiary py-8 text-center text-sm">
          {resolvedEmptyMessage}
        </p>
      );
    } else {
      dataContent = renderCardGrid(filteredData);
    }
  } else {
    dataContent = (
      <DataTable
        data={filteredData}
        columns={columns}
        keyExtractor={keyExtractor}
        onRowClick={onRowClick}
        activeRowId={activeRowId}
        emptyMessage={resolvedEmptyMessage}
        expandable={expandableRows}
        renderExpanded={renderExpandedRow}
        title=""
      />
    );
  }

  return (
    <SurfaceLayout
      spacing="group"
      className={className}
      loading={loading}
      data-opt-id="Q5TZS"
      data-opt-slug="data-explorer.DataExplorer"
    >
      {/* 헤더 영역 */}
      {((title ?? labels.title) ||
        header ||
        actions ||
        (showViewToggle && renderCard)) && (
        <div className="gap-group flex flex-col sm:flex-row sm:items-center sm:justify-between">
          <div className="gap-group flex items-center">
            {(title ?? labels.title) && (
              <h3 className="text-text-primary text-base font-semibold">
                {title ?? labels.title}
              </h3>
            )}
            {header}
          </div>
          <div className="gap-element flex items-center">
            {showViewToggle && renderCard && (
              <div
                className="border-border inline-flex rounded-md border"
                role="group"
                aria-label="뷰 모드"
              >
                <button
                  type="button"
                  className={cn(
                    "rounded-l-md px-3 py-1.5 text-sm font-medium transition-colors",
                    viewMode === "table"
                      ? "bg-accent-subtle text-text-primary"
                      : "text-text-secondary hover:bg-bg-subtle",
                  )}
                  aria-pressed={viewMode === "table"}
                  onClick={() => handleViewModeChange("table")}
                >
                  {labels.tableViewLabel}
                </button>
                <button
                  type="button"
                  className={cn(
                    "rounded-r-md px-3 py-1.5 text-sm font-medium transition-colors",
                    viewMode === "grid"
                      ? "bg-accent-subtle text-text-primary"
                      : "text-text-secondary hover:bg-bg-subtle",
                  )}
                  aria-pressed={viewMode === "grid"}
                  onClick={() => handleViewModeChange("grid")}
                >
                  {labels.gridViewLabel}
                </button>
              </div>
            )}
            {actions}
          </div>
        </div>
      )}

      {/* 검색 및 필터 영역 */}
      {(hasSearch || hasFilters) && (
        <div
          className={cn(
            "gap-group flex flex-col sm:flex-row sm:flex-wrap sm:items-end",
            "border-border bg-bg-subtle rounded-lg border p-3",
          )}
        >
          {hasSearch && (
            <div className="min-w-0 sm:min-w-[220px] sm:flex-[2_1_280px]">
              <SearchCombobox
                items={searchItems}
                placeholder={searchPlaceholder ?? labels.searchPlaceholder}
                label={labels.searchLabel}
                onSelect={(item) => setSearchValue(item)}
              />
            </div>
          )}
          {filters.map((filter) => (
            <div
              key={filter.id}
              className="min-w-0 sm:max-w-[180px] sm:min-w-[150px] sm:flex-[1_1_150px]"
            >
              <StatusSelect
                options={filter.options}
                value={filterValues[filter.id]}
                onChange={(value) => handleFilterChange(filter.id, value)}
                label={filter.label}
              />
            </div>
          ))}
        </div>
      )}

      <div className="gap-element flex flex-col">
        {/* 결과 요약 */}
        <div className="text-text-tertiary text-sm">
          {labels.resultsSummary(filteredData.length, data.length)}
        </div>

        {/* 데이터 영역 */}
        {dataContent}
      </div>
    </SurfaceLayout>
  );
}