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.

DataExplorer

block

필터링 가능한 데이터 탐색 영역. 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, BlockLayout, 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 BlockLayout 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 (
    <BlockLayout
      spacing="group"
      className={className}
      loading={loading}
      data-opt-id="V11X2"
      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>
    </BlockLayout>
  );
}