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.

EventCatalog

surface

이벤트 카탈로그 Surface. DataTable + EventIcon + EventSparkline + EventMetaEditor 조합.

컴포넌트 의존 관계

깊이
▼ USES (9)EventCatalogdata-tablefilter-barfloating-action-barevent-iconevent-sparklineevent-meta-editorloading-overlaytime-range-selectorinput
100%

기본 사용

Event Catalog

Event Catalog

Data Table

EventKeyVolumeTrendLast Seen
Page Viewpage_view125,000
2 min ago
Button Clickbutton_click45,000
5 min ago
Sign Upsignup3,200
15 min ago
Purchasepurchase890
1 hour ago

필터 + 정렬 + 페이지네이션 + 벌크 액션

Filters
Event Catalog

Data Table

EventKeyVolumeTrendLast Seen
Page Viewpage_view125,000
2 min ago
Button Clickbutton_click45,000
5 min ago
Sign Upsignup3,200
15 min ago
Purchasepurchase890
1 hour ago

테스트 커버리지

2026년 2월 4일

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

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

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

EventCatalog Props

Prop타입기본값설명
events*EventCatalogItem[]—이벤트 배열
onEventUpdate(id: string, meta: EventMeta) => void—이벤트 수정 핸들러

Surface 설치

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

bash
npx @reopt-ai/opt-cli surface add event-catalog

Consumer target

복사된 파일은 components/surfaces 아래에 저장됩니다.

tsx
import { EventCatalog } from "@/components/surfaces/event-catalog";

Registry metadata

설명
이벤트 카탈로그 Surface. DataTable + EventIcon + EventSparkline + EventMetaEditor 조합.
파일 수
1개
Registry dependencies
없음
Package dependencies
없음
태그
tablefilter
Install notes
없음

포함 파일

  • event-catalog.tsx→event-catalog.tsx
Surface 소스 보기
event-catalog.tsx
"use client";

import * as React from "react";
import {
  Input,
  Badge,
  DataTable,
  FilterBar,
  FloatingActionBar,
  EventIcon,
  EventMetaEditor,
  SurfaceLayout,
  SummaryRow,
  TabsRoot,
  TabList,
  Tab,
  cn,
  TimeRangeSelector,
  type ColumnDef,
  type DateRange,
  type EventMeta,
  type FilterGroupDef,
  type FloatingAction,
  type StatCardType,
} from "@reopt-ai/opt-ui";
import { EventSparkline } from "@reopt-ai/opt-charts";

/** Item shape for `EventCatalog`. */
export interface EventCatalogItem {
  id: string;
  name: string;
  displayName: string;
  icon: string;
  color: string;
  tags: string[];
  description: string;
  volume: number;
  sparkline: number[];
  lastSeen: string;
}

/** Option shape for `EventCatalogStatus`. */
export interface EventCatalogStatusOption {
  id: string;
  label: string;
  count?: number;
}

/** Labels for `EventCatalog`. */
export interface EventCatalogLabels {
  catalog?: string;
  searchPlaceholder?: string;
  editEvent?: string;
  tagsLabel?: string;
  filtersLabel?: string;
  selectAllLabel?: string;
  selectedCountLabel?: string;
  bulkActionsLabel?: string;
  statusTabsLabel?: string;
  emptyTitle?: string;
  emptyDescription?: string;
}

const defaultLabels: Required<EventCatalogLabels> = {
  catalog: "Event Catalog",
  searchPlaceholder: "Search events...",
  editEvent: "Edit Event",
  tagsLabel: "Tags",
  filtersLabel: "Filters",
  selectAllLabel: "Select all",
  selectedCountLabel: "{count} selected",
  bulkActionsLabel: "Bulk actions",
  statusTabsLabel: "Status",
  emptyTitle: "No events found",
  emptyDescription: "There are no events in the catalog yet.",
};

/** Props for `EventCatalog`. */
export interface EventCatalogProps {
  events: EventCatalogItem[];
  onEventUpdate?: (id: string, meta: EventMeta) => void;
  /** FilterBar filter definitions */
  filters?: FilterGroupDef[];
  onFilterChange?: (
    filterId: string,
    value: string | string[] | boolean,
  ) => void;
  /** Enable row selection for bulk actions */
  selectable?: boolean;
  selectedKeys?: string[];
  onSelectionChange?: (keys: string[]) => void;
  /** Bulk action definitions shown via FloatingActionBar */
  bulkActions?: FloatingAction[];
  onBulkAction?: (actionId: string) => void;
  /** Enable column sorting */
  sortable?: boolean;
  /** Enable expandable table rows (e.g. for PropertyExplorer) */
  expandableRows?: boolean;
  /** Render function for expanded row content */
  renderExpandedRow?: (event: EventCatalogItem) => React.ReactNode;
  /** Active tag filters shown as pill toggles */
  activeTags?: string[];
  /** Called when a tag pill is toggled */
  onTagToggle?: (tag: string) => void;
  /** Status filter tabs (e.g. active/inactive/archived) */
  statusFilter?: string;
  /** Status tab definitions */
  statusOptions?: EventCatalogStatusOption[];
  /** Called when a status tab is selected */
  onStatusFilterChange?: (status: string) => void;
  /** Stats cards displayed above the table */
  stats?: StatCardType[];
  /** Enable pagination with given page size */
  pageSize?: number;
  /** Import action slot (rendered in header actions area) */
  importAction?: React.ReactNode;
  /** Export action slot (rendered in header actions area) */
  exportAction?: React.ReactNode;
  loading?: boolean;
  timeRange?: DateRange;
  onTimeRangeChange?: (range: DateRange) => void;
  header?: React.ReactNode;
  actions?: React.ReactNode;
  labels?: EventCatalogLabels;
  className?: string;
}

/** Renders the `EventCatalog` component. */
export function EventCatalog({
  events,
  onEventUpdate,
  filters,
  onFilterChange,
  selectable = false,
  selectedKeys,
  onSelectionChange,
  bulkActions,
  onBulkAction,
  sortable = false,
  expandableRows = false,
  renderExpandedRow,
  activeTags,
  onTagToggle,
  statusFilter,
  statusOptions,
  onStatusFilterChange,
  stats,
  pageSize,
  importAction,
  exportAction,
  loading = false,
  timeRange,
  onTimeRangeChange,
  header,
  actions,
  labels: customLabels,
  className,
}: EventCatalogProps) {
  const labels = { ...defaultLabels, ...customLabels };
  const sectionId = React.useId();

  const [search, setSearch] = React.useState("");
  const [selected, setSelected] = React.useState<string | null>(null);

  const filtered = React.useMemo(() => {
    if (!search) return events;
    const q = search.toLowerCase();
    return events.filter(
      (e) =>
        e.name.toLowerCase().includes(q) ||
        e.displayName.toLowerCase().includes(q) ||
        e.tags.some((t) => t.toLowerCase().includes(q)),
    );
  }, [events, search]);

  const selectedEvent = events.find((e) => e.id === selected);

  const columns: ColumnDef<EventCatalogItem>[] = [
    {
      id: "icon",
      header: "",
      accessor: (row) => (
        <EventIcon icon={row.icon} color={row.color} size="sm" />
      ),
      sortable: false,
    },
    { id: "displayName", header: "Event", accessor: "displayName" },
    {
      id: "name",
      header: "Key",
      accessor: (row) => <code className="text-xs">{row.name}</code>,
    },
    {
      id: "volume",
      header: "Volume",
      accessor: (row) => row.volume.toLocaleString(),
    },
    {
      id: "sparkline",
      header: "Trend",
      accessor: (row) => (
        <EventSparkline data={row.sparkline} color={row.color} />
      ),
      sortable: false,
    },
    { id: "lastSeen", header: "Last Seen", accessor: "lastSeen" },
  ];

  // Collect all unique tags for pill filter
  const allTags = React.useMemo(() => {
    const tagSet = new Set<string>();
    for (const e of events) {
      for (const t of e.tags) tagSet.add(t);
    }
    return [...tagSet].sort();
  }, [events]);

  const hasTagFilter = activeTags && onTagToggle && allTags.length > 0;

  const isEmpty = events.length === 0;
  const hasFilters = filters && filters.length > 0;
  const showBulkBar =
    bulkActions &&
    bulkActions.length > 0 &&
    onBulkAction &&
    selectedKeys &&
    selectedKeys.length > 0;

  return (
    <SurfaceLayout loading={loading} className={className}>
      {header || actions ? (
        <div className="flex items-center justify-between">
          {header && <div>{header}</div>}
          <div className="gap-element flex items-center">
            <TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
            {!isEmpty && (
              <div className="w-64">
                <Input
                  placeholder={labels.searchPlaceholder}
                  value={search}
                  onChange={(e) => setSearch(e.target.value)}
                  aria-label={labels.searchPlaceholder}
                />
              </div>
            )}
            {importAction}
            {exportAction}
            {actions}
          </div>
        </div>
      ) : (
        <div className="flex items-center justify-between">
          <h2 className="text-text-primary text-lg font-semibold">
            {labels.catalog}
          </h2>
          <div className="gap-element flex items-center">
            <TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
            {!isEmpty && (
              <div className="w-64">
                <Input
                  placeholder={labels.searchPlaceholder}
                  value={search}
                  onChange={(e) => setSearch(e.target.value)}
                  aria-label={labels.searchPlaceholder}
                />
              </div>
            )}
            {importAction}
            {exportAction}
          </div>
        </div>
      )}

      {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>
      ) : (
        <>
          {stats && stats.length > 0 && <SummaryRow stats={stats} />}

          {statusOptions &&
            statusOptions.length > 0 &&
            onStatusFilterChange && (
              <TabsRoot
                selectedId={statusFilter ?? statusOptions[0]?.id}
                setSelectedId={(id) => {
                  if (id) onStatusFilterChange(id);
                }}
              >
                <TabList aria-label={labels.statusTabsLabel}>
                  {statusOptions.map((opt) => (
                    <Tab key={opt.id} id={opt.id}>
                      <span className="flex items-center gap-1.5">
                        {opt.label}
                        {opt.count != null && (
                          <Badge variant="default" size="sm">
                            {opt.count}
                          </Badge>
                        )}
                      </span>
                    </Tab>
                  ))}
                </TabList>
              </TabsRoot>
            )}

          {hasFilters && (
            <section aria-labelledby={`${sectionId}-filters`}>
              <span id={`${sectionId}-filters`} className="sr-only">
                {labels.filtersLabel}
              </span>
              <FilterBar filters={filters} onFilterChange={onFilterChange} />
            </section>
          )}

          {hasTagFilter && (
            <div
              role="group"
              aria-label={labels.tagsLabel}
              className="flex flex-wrap gap-1.5"
            >
              {allTags.map((tag) => {
                const isActive = activeTags.includes(tag);
                return (
                  <button
                    key={tag}
                    type="button"
                    onClick={() => onTagToggle(tag)}
                    aria-pressed={isActive}
                    className={cn(
                      "rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
                      isActive
                        ? "bg-accent-subtle text-text-primary"
                        : "bg-bg-subtle text-text-tertiary hover:text-text-secondary",
                    )}
                  >
                    {tag}
                  </button>
                );
              })}
            </div>
          )}

          <div className="gap-group flex">
            <section aria-labelledby={`${sectionId}-table`} className="flex-1">
              <span id={`${sectionId}-table`} className="sr-only">
                {labels.catalog}
              </span>
              <DataTable
                columns={columns}
                data={filtered}
                keyExtractor={(row) => row.id}
                onRowClick={(row) => setSelected(row.id)}
                sortable={sortable}
                selectable={selectable}
                selectedKeys={selectedKeys}
                onSelectionChange={onSelectionChange}
                pageSize={pageSize}
                expandable={expandableRows}
                renderExpanded={renderExpandedRow}
              />
            </section>

            {selectedEvent && onEventUpdate && (
              <section
                aria-labelledby={`${sectionId}-editor`}
                className="border-border w-80 shrink-0 border-l pl-4"
              >
                <h3
                  id={`${sectionId}-editor`}
                  className="text-text-primary mb-3 text-sm font-medium"
                >
                  {labels.editEvent}
                </h3>
                <EventMetaEditor
                  value={{
                    name: selectedEvent.name,
                    displayName: selectedEvent.displayName,
                    icon: selectedEvent.icon,
                    color: selectedEvent.color,
                    tags: selectedEvent.tags,
                    description: selectedEvent.description,
                  }}
                  onChange={(meta) => onEventUpdate(selectedEvent.id, meta)}
                />
              </section>
            )}
          </div>

          {showBulkBar && (
            <FloatingActionBar
              actions={bulkActions}
              selectedCount={selectedKeys.length}
              onAction={onBulkAction}
              onDismiss={() => onSelectionChange?.([])}
            />
          )}
        </>
      )}
    </SurfaceLayout>
  );
}

EventCatalog.displayName = "EventCatalog";