DataExplorer
surface필터링 가능한 데이터 탐색 영역. compact SurfaceLayout 안에서 SearchCombobox + StatusSelect + DataTable을 조합.
컴포넌트 의존 관계
깊이
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)[] | — | 검색 대상 필드 키 배열. 지정 시 검색 기능 활성화 |
searchPlaceholder | string | "검색..." | 검색 입력 플레이스홀더 |
filters | FilterDef[] | [] | 필터 정의 배열. { id, label, options, accessor } |
onRowClick | (row: T) => void | — | 행 클릭 핸들러 |
title | string | "데이터 탐색기" | 영역 제목 |
emptyMessage | string | "데이터가 없습니다" | 빈 상태 메시지 |
header | ReactNode | — | 제목 옆에 표시할 커스텀 헤더 요소 |
className | string | — | 최외곽 SurfaceLayout CSS 클래스 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add data-explorerConsumer 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.tsxdata-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>
);
}