UserExplorer
surface사용자 탐색 Surface. DataTable + FilterBar 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
Users
Data Table
| Name | Plan | Status | Signed Up | |
|---|---|---|---|---|
| Alice Kim | alice@example.com | pro | active | 2024-01-10 |
| Bob Park | bob@example.com | free | active | 2024-02-15 |
| Carol Lee | carol@example.com | enterprise | inactive | 2023-11-20 |
Stats + 정렬 + 페이지네이션
Users
Total Users3↑ +10%
Active2↑ +5%
New Today1→ 0%
Churned0↓ -2%
Data Table
| Name | Plan | Status | Signed Up | |
|---|---|---|---|---|
| Alice Kim | alice@example.com | pro | active | 2024-01-10 |
| Bob Park | bob@example.com | free | active | 2024-02-15 |
| Carol Lee | carol@example.com | enterprise | inactive | 2023-11-20 |
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
UserExplorer 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
UserExplorer Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
users* | UserRow[] | — | 사용자 데이터 배열 |
columns* | ColumnDef<UserRow>[] | — | 테이블 컬럼 정의 |
filters | FilterGroupDef[] | — | 필터 정의 |
onRowClick | (user: UserRow) => void | — | 행 클릭 핸들러 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add user-explorerConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { UserExplorer } from "@/components/surfaces/user-explorer";Registry metadata
- 설명
- 사용자 탐색 Surface. DataTable + FilterBar 조합.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- tablefilter
- Install notes
- 없음
포함 파일
user-explorer.tsxuser-explorer.tsx
Surface 소스 보기
user-explorer.tsx
"use client";
import * as React from "react";
import {
Button,
DataTable,
FilterBar,
Input,
SurfaceLayout,
SummaryRow,
cn,
type ColumnDef,
type FilterGroupDef,
type StatCardType,
} from "@reopt-ai/opt-ui";
interface UserRow {
id: string;
[key: string]: unknown;
}
/** View modes supported by `UserExplorer`. */
export type UserExplorerViewMode = "table" | "grid";
/** Data shape for `PropertyFilter`. */
export interface PropertyFilter {
key: string;
operator: string;
value: string;
}
const PROPERTY_FILTER_OPERATORS = ["=", "!=", "contains", ">", "<"] as const;
/** Labels for `UserExplorer`. */
export interface UserExplorerLabels {
users?: string;
emptyTitle?: string;
emptyDescription?: string;
tableViewLabel?: string;
gridViewLabel?: string;
filtersLabel?: string;
propertyFilterLabel?: string;
addFilterButton?: string;
viewModeAriaLabel?: string;
}
const defaultLabels: Required<UserExplorerLabels> = {
users: "Users",
emptyTitle: "No users found",
emptyDescription: "There are no users matching the current criteria.",
tableViewLabel: "Table view",
gridViewLabel: "Card view",
filtersLabel: "Filters",
propertyFilterLabel: "Property filters",
addFilterButton: "+ Add filter",
viewModeAriaLabel: "View mode",
};
/** Props for `UserExplorer`. */
export interface UserExplorerProps {
users: UserRow[];
columns: ColumnDef<UserRow>[];
filters?: FilterGroupDef[];
onFilterChange?: (
filterId: string,
value: string | string[] | boolean,
) => void;
onRowClick?: (user: UserRow) => void;
/** KPI stat cards displayed above the filter bar */
stats?: StatCardType[];
/** Enable column sorting */
sortable?: boolean;
/** Enable pagination with given page size */
pageSize?: number;
loading?: boolean;
header?: React.ReactNode;
actions?: React.ReactNode;
/** Display mode: "table" (default) or "grid" (card grid) */
viewMode?: UserExplorerViewMode;
/** Render function for grid cards. Required when viewMode="grid". */
renderCard?: (user: UserRow) => React.ReactNode;
/** Show table/grid toggle buttons. Requires renderCard. */
showViewToggle?: boolean;
/** Called when the view mode changes */
onViewModeChange?: (mode: UserExplorerViewMode) => void;
/** Custom filter content rendered above the default FilterBar (e.g., property-based filters) */
filterContent?: React.ReactNode;
/** Active property filters */
propertyFilters?: PropertyFilter[];
/** Callback when property filters change */
onPropertyFiltersChange?: (filters: PropertyFilter[]) => void;
/** Available property keys for the filter key dropdown */
propertyKeys?: string[];
labels?: UserExplorerLabels;
className?: string;
}
const defaultFilters: FilterGroupDef[] = [
{
id: "status",
label: "Status",
type: "select",
options: [
{ id: "active", value: "active", label: "Active" },
{ id: "inactive", value: "inactive", label: "Inactive" },
{ id: "churned", value: "churned", label: "Churned" },
],
},
{
id: "plan",
label: "Plan",
type: "multi-select",
options: [
{ id: "free", value: "free", label: "Free" },
{ id: "pro", value: "pro", label: "Pro" },
{ id: "enterprise", value: "enterprise", label: "Enterprise" },
],
},
];
/** Renders the `UserExplorer` component. */
export function UserExplorer({
users,
columns,
filters = defaultFilters,
onFilterChange,
onRowClick,
stats,
sortable = false,
pageSize,
loading = false,
header,
actions,
viewMode: controlledViewMode,
renderCard,
showViewToggle = false,
onViewModeChange,
filterContent,
propertyFilters,
onPropertyFiltersChange,
propertyKeys,
labels: customLabels,
className,
}: UserExplorerProps) {
const labels = { ...defaultLabels, ...customLabels };
const sectionId = React.useId();
const [internalViewMode, setInternalViewMode] =
React.useState<UserExplorerViewMode>("table");
const viewMode = controlledViewMode ?? internalViewMode;
const handleViewModeChange = React.useCallback(
(mode: UserExplorerViewMode) => {
setInternalViewMode(mode);
onViewModeChange?.(mode);
},
[onViewModeChange],
);
const isEmpty = users.length === 0;
const hasStats = stats && stats.length > 0;
const hasPropertyFilters =
propertyFilters != null && onPropertyFiltersChange != null;
const handleAddPropertyFilter = React.useCallback(() => {
if (!onPropertyFiltersChange || !propertyFilters) return;
onPropertyFiltersChange([
...propertyFilters,
{ key: propertyKeys?.[0] ?? "", operator: "=", value: "" },
]);
}, [onPropertyFiltersChange, propertyFilters, propertyKeys]);
const handleRemovePropertyFilter = React.useCallback(
(index: number) => {
if (!onPropertyFiltersChange || !propertyFilters) return;
onPropertyFiltersChange(propertyFilters.filter((_, i) => i !== index));
},
[onPropertyFiltersChange, propertyFilters],
);
const handleUpdatePropertyFilter = React.useCallback(
(index: number, patch: Partial<PropertyFilter>) => {
if (!onPropertyFiltersChange || !propertyFilters) return;
onPropertyFiltersChange(
propertyFilters.map((f, i) => (i === index ? { ...f, ...patch } : f)),
);
},
[onPropertyFiltersChange, propertyFilters],
);
return (
<SurfaceLayout loading={loading} className={className}>
{header || actions || (showViewToggle && renderCard) ? (
<div className="flex items-center justify-between">
{header ? (
<div>{header}</div>
) : (
<h2 className="text-text-primary text-lg font-semibold">
{labels.users}
</h2>
)}
<div className="gap-element flex items-center">
{showViewToggle && renderCard && (
<div
className="border-border inline-flex rounded-md border"
role="group"
aria-label={labels.viewModeAriaLabel}
>
<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>
) : (
<h2 className="text-text-primary text-lg font-semibold">
{labels.users}
</h2>
)}
{hasStats && <SummaryRow stats={stats} />}
{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>
) : (
<div className="gap-group flex flex-col">
{filterContent}
<section aria-labelledby={`${sectionId}-filters`}>
<span id={`${sectionId}-filters`} className="sr-only">
{labels.filtersLabel}
</span>
<FilterBar filters={filters} onFilterChange={onFilterChange} />
</section>
{hasPropertyFilters && (
<section aria-labelledby={`${sectionId}-property-filters`}>
<h3
id={`${sectionId}-property-filters`}
className="text-text-secondary mb-2 text-sm font-medium"
>
{labels.propertyFilterLabel}
</h3>
<div className="gap-element flex flex-col">
{propertyFilters!.map((filter, index) => (
<div key={index} className="gap-element flex items-center">
{propertyKeys && propertyKeys.length > 0 ? (
<select
value={filter.key}
onChange={(e) =>
handleUpdatePropertyFilter(index, {
key: e.target.value,
})
}
className="border-border bg-bg-primary text-text-primary rounded-md border px-2 py-1.5 text-sm"
aria-label="Property key"
>
{!propertyKeys.includes(filter.key) && filter.key && (
<option value={filter.key}>{filter.key}</option>
)}
{propertyKeys.map((k) => (
<option key={k} value={k}>
{k}
</option>
))}
</select>
) : (
<Input
value={filter.key}
onChange={(e) =>
handleUpdatePropertyFilter(index, {
key: e.target.value,
})
}
placeholder="Key"
className="w-32"
aria-label="Property key"
/>
)}
<select
value={filter.operator}
onChange={(e) =>
handleUpdatePropertyFilter(index, {
operator: e.target.value,
})
}
className="border-border bg-bg-primary text-text-primary rounded-md border px-2 py-1.5 text-sm"
aria-label="Operator"
>
{PROPERTY_FILTER_OPERATORS.map((op) => (
<option key={op} value={op}>
{op}
</option>
))}
</select>
<Input
value={filter.value}
onChange={(e) =>
handleUpdatePropertyFilter(index, {
value: e.target.value,
})
}
placeholder="Value"
className="flex-1"
aria-label="Filter value"
/>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemovePropertyFilter(index)}
aria-label="Remove filter"
>
×
</Button>
</div>
))}
<div>
<Button
variant="ghost"
size="sm"
onClick={handleAddPropertyFilter}
>
{labels.addFilterButton}
</Button>
</div>
</div>
</section>
)}
<section aria-labelledby={`${sectionId}-table`}>
<span id={`${sectionId}-table`} className="sr-only">
{labels.users}
</span>
{viewMode === "grid" && renderCard ? (
<div className="gap-group grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{users.map((user) => (
<button
key={user.id}
type="button"
disabled={!onRowClick}
className={cn(
"rounded-lg text-left",
onRowClick ? "cursor-pointer" : "cursor-default",
)}
onClick={onRowClick ? () => onRowClick(user) : undefined}
>
{renderCard(user)}
</button>
))}
</div>
) : (
<DataTable
columns={columns}
data={users}
keyExtractor={(u) => u.id}
onRowClick={onRowClick}
sortable={sortable}
pageSize={pageSize}
/>
)}
</section>
</div>
)}
</SurfaceLayout>
);
}
UserExplorer.displayName = "UserExplorer";