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.

UserExplorer

surface

사용자 탐색 Surface. DataTable + FilterBar 조합.

컴포넌트 의존 관계

깊이
▼ USES (4)UserExplorerdata-tablefilter-barloading-overlaysummary-row
100%

기본 사용

Users

Filters
Users

Data Table

NameEmailPlanStatusSigned Up
Alice Kimalice@example.comproactive2024-01-10
Bob Parkbob@example.comfreeactive2024-02-15
Carol Leecarol@example.comenterpriseinactive2023-11-20

Stats + 정렬 + 페이지네이션

Users

Total Users3↑ +10%
Active2↑ +5%
New Today1→ 0%
Churned0↓ -2%
Filters
Users

Data Table

NameEmailPlanStatusSigned Up
Alice Kimalice@example.comproactive2024-01-10
Bob Parkbob@example.comfreeactive2024-02-15
Carol Leecarol@example.comenterpriseinactive2023-11-20

테스트 커버리지

2026년 2월 4일

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

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

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

UserExplorer Props

Prop타입기본값설명
users*UserRow[]—사용자 데이터 배열
columns*ColumnDef<UserRow>[]—테이블 컬럼 정의
filtersFilterGroupDef[]—필터 정의
onRowClick(user: UserRow) => void—행 클릭 핸들러

Surface 설치

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

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

Consumer 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.tsx→user-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"
                    >
                      &times;
                    </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";