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.

UserExplorer

block

사용자 탐색 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";