columns
열 key, header, width, align, render, sortable을 선언해 표시 구조를 고정합니다.
keyField
행 선택과 접근 가능한 이름에 사용할 고유 값 필드입니다.
bulk actions
일괄 액션은 하단 floating BulkActionBar 하나로만 제공합니다. DataTable은 selectable/selectedIds로 선택만 제어하고 액션 표면은 BulkActionBar와 조합합니다.
activeRowId
selectedIds와 독립인 현재 행 강조 값입니다. 데스크톱 row와 모바일 card에 같은 selected fill을 적용합니다.
row context
row와 mobile card는 data-row-id를 노출하고 onRowContextMenu(rowId, event)를 호출합니다.
row navigation
rowHref만 주면 window.location.assign(전체 새로고침)입니다. linkComponent(Next Link 등)를 주면 첫 열 링크가 행 전체를 덮어 라우터로 이동하고, onRowClick을 주면 행 클릭·Enter·Space를 직접 받습니다.
mobile
같은 data와 columns로 좁은 화면에서는 카드 목록과 모바일 정렬 버튼을 렌더링합니다.
virtual
연속 로드로 수천 행이 쌓이는 목록용 opt-in입니다. 행 높이가 모두 같다는 전제로 보이는 레이아웃(표 또는 카드)만, 화면 근처 행만 그리고 행을 memo합니다. 헤더 체크는 여전히 불러온 행 전체를 고릅니다. 열 머리는 스크롤 컨테이너 안에 sticky로 남고, 앞에 inline BulkActionBar 띠가 떠 있으면 그 아래(top 40px)에 붙습니다.
loading
데이터가 없으면 skeleton row, 데이터가 있으면 상단 progress bar로 갱신 중임을 표시합니다.
emptyState
테이블이 이미 카드 표면이므로 빈 상태는 bordered={false}로 두어 이중 테두리를 만들지 않습니다.

데스크톱 테이블

열 폭, 숫자 정렬, 행 이동
과목
응시자
상태
2026 중간고사
해부학
124
진행 예정
임상술기 평가
OSCE
42
채점 완료
심혈관 보충 시험
내과학
88
검토 중
2026 중간고사
과목
해부학
응시자
124
상태
진행 예정
임상술기 평가
과목
OSCE
응시자
42
상태
채점 완료
심혈관 보충 시험
과목
내과학
응시자
88
상태
검토 중
코드 보기tsx
import { DataTable, StatusBadge, type DataTableColumn } from "@olundot/ui";

type ExamRow = {
  id: string;
  title: string;
  subject: string;
  students: number;
  status: "진행 예정" | "채점 완료" | "검토 중";
};

const columns: DataTableColumn<ExamRow>[] = [
  { key: "title", header: "시험명", width: "1.5fr", sortable: true, render: (row) => <strong>{row.title}</strong> },
  { key: "subject", header: "과목", width: "1fr" },
  { key: "students", header: "응시자", width: "96px", align: "right", tabular: true, render: (row) => row.students.toLocaleString("ko-KR") },
  { key: "status", header: "상태", width: "120px", render: (row) => <StatusBadge pulse={false} status={row.status === "채점 완료" ? "success" : "info"}>{row.status}</StatusBadge> },
];

const rows: ExamRow[] = [
  { id: "exam-1", title: "2026 중간고사", subject: "해부학", students: 124, status: "진행 예정" },
  { id: "exam-2", title: "임상술기 평가", subject: "OSCE", students: 42, status: "채점 완료" },
  { id: "exam-3", title: "심혈관 보충 시험", subject: "내과학", students: 88, status: "검토 중" },
];

export function ExamTable() {
  return (
    <DataTable surface="canvas"
      aria-label="시험 목록"
      columns={columns}
      data={rows}
      keyField="id"
      rowHref={(row) => `/components/data-table#${row.id}`}
    />
  );
}
linkComponent, onRowClick
과목
응시자
상태
해부학
124
진행 예정
OSCE
42
채점 완료
내과학
88
검토 중
과목
해부학
응시자
124
상태
진행 예정
과목
OSCE
응시자
42
상태
채점 완료
과목
내과학
응시자
88
상태
검토 중
코드 보기tsx
import Link from "next/link";
import { DataTable, type DataTableColumn } from "@olundot/ui";

type ExamRow = { id: string; title: string; subject: string; students: number };

const columns: DataTableColumn<ExamRow>[] = [
  { key: "title", header: "시험명", width: "1.5fr", render: (row) => <strong>{row.title}</strong> },
  { key: "subject", header: "과목", width: "1fr" },
  { key: "students", header: "응시자", width: "96px", align: "right", tabular: true },
];

const rows: ExamRow[] = [
  { id: "exam-1", title: "2026 중간고사", subject: "해부학", students: 124 },
  { id: "exam-2", title: "임상술기 평가", subject: "OSCE", students: 42 },
];

// 첫 열이 Next Link가 되고 행 전체를 덮는다 — 새로고침 없이 이동하고,
// 돌아오면 목록 캐시와 스크롤 위치가 그대로 남는다. cmd/가운데 클릭은 새 탭.
export function ExamTableWithRouter() {
  return (
    <DataTable surface="canvas"
      aria-label="시험 목록"
      columns={columns}
      data={rows}
      keyField="id"
      rowHref={(row) => `/exams/${row.id}`}
      linkComponent={Link}
    />
  );
}

// 링크가 아닌 동작(패널 열기 등)은 onRowClick으로 받는다.
// <DataTable ... onRowClick={(row) => openPeek(row.id)} />

가상 스크롤 3,000행

virtual, 헤더 체크 = 불러온 행 전체
3,000명
이름
학년
분반
학생 1
1학년
1반
학생 2
2학년
2반
학생 3
3학년
3반
학생 4
4학년
4반
학생 5
5학년
1반
학생 6
6학년
2반
학생 7
1학년
3반
학생 8
2학년
4반
학생 9
3학년
1반
학생 10
4학년
2반
학생 11
5학년
3반
학생 12
6학년
4반
학생 13
1학년
1반
학생 14
2학년
2반
학생 15
3학년
3반
학생 16
4학년
4반
학생 17
5학년
1반
학생 18
6학년
2반
학생 19
1학년
3반
학생 20
2학년
4반
학생 21
3학년
1반
학생 22
4학년
2반
학생 23
5학년
3반
학생 24
6학년
4반
학생 1
학년
1학년
분반
1반
학생 2
학년
2학년
분반
2반
학생 3
학년
3학년
분반
3반
학생 4
학년
4학년
분반
4반
학생 5
학년
5학년
분반
1반
학생 6
학년
6학년
분반
2반
학생 7
학년
1학년
분반
3반
학생 8
학년
2학년
분반
4반
학생 9
학년
3학년
분반
1반
학생 10
학년
4학년
분반
2반
학생 11
학년
5학년
분반
3반
학생 12
학년
6학년
분반
4반
학생 13
학년
1학년
분반
1반
학생 14
학년
2학년
분반
2반
학생 15
학년
3학년
분반
3반
학생 16
학년
4학년
분반
4반
학생 17
학년
5학년
분반
1반
학생 18
학년
6학년
분반
2반
학생 19
학년
1학년
분반
3반
학생 20
학년
2학년
분반
4반
학생 21
학년
3학년
분반
1반
학생 22
학년
4학년
분반
2반
학생 23
학년
5학년
분반
3반
학생 24
학년
6학년
분반
4반
코드 보기tsx
import { useState } from "react";
import { BulkActionBar, DataTable, type DataTableColumn } from "@olundot/ui";

type StudentRow = { id: string; name: string; grade: string; section: string };

// 열은 모듈 상수(또는 useMemo)로 고정한다 — 그래야 가상 행 memo가 유지된다.
const columns: DataTableColumn<StudentRow>[] = [
  { key: "name", header: "이름", width: "1.4fr" },
  { key: "grade", header: "학년", width: "1fr" },
  { key: "section", header: "분반", width: "1fr" },
];

const rows: StudentRow[] = Array.from({ length: 3000 }, (_, index) => ({
  id: `st-${index + 1}`,
  name: `학생 ${(index + 1).toLocaleString("ko-KR")}`,
  grade: `${(index % 6) + 1}학년`,
  section: `${(index % 4) + 1}반`,
}));

export function VirtualStudentTable() {
  const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());

  return (
    // 스크롤 컨테이너는 window든 overflow-y-auto 요소든 상관없다.
    <div className="max-h-[480px] overflow-y-auto">
      <BulkActionBar
        placement="inline"
        count={selectedIds.size}
        unit="명"
        idleContent={`${rows.length.toLocaleString("ko-KR")}명`}
        onClear={() => setSelectedIds(new Set())}
        actions={[{ key: "grade", label: "학년·분반 변경", onClick: () => undefined }]}
      />
      <DataTable surface="canvas"
        aria-label="학생 목록"
        columns={columns}
        data={rows}
        keyField="id"
        selectable
        selectedIds={selectedIds}
        onSelectionChange={setSelectedIds}
        virtual
      />
    </div>
  );
}

선택 행

selectable, selectedIds, BulkActionBar
1개 선택됨
그룹
진도율
김지수
A반
82%
이민준
B반
64%
박서연
A반
91%
김지수
그룹
A반
진도율
82%
이민준
그룹
B반
진도율
64%
박서연
그룹
A반
진도율
91%
코드 보기tsx
import { useState } from "react";
import { BulkActionBar, DataTable, type DataTableColumn } from "@olundot/ui";

type StudentRow = { id: string; name: string; group: string; progress: number };

const columns: DataTableColumn<StudentRow>[] = [
  { key: "name", header: "학생", width: "1.4fr", sortable: true },
  { key: "group", header: "그룹", width: "1fr" },
  { key: "progress", header: "진도율", width: "96px", align: "right", tabular: true, render: (row) => `${row.progress}%` },
];

const rows: StudentRow[] = [
  { id: "st-1", name: "김지수", group: "A반", progress: 82 },
  { id: "st-2", name: "이민준", group: "B반", progress: 64 },
  { id: "st-3", name: "박서연", group: "A반", progress: 91 },
];

export function SelectableStudents() {
  const [selectedIds, setSelectedIds] = useState(new Set(["st-1"]));

  return (
    <div className="grid gap-[var(--space-2)]">
      <DataTable surface="canvas"
        aria-label="학생 진도 목록"
        columns={columns}
        data={rows}
        keyField="id"
        selectable
        selectedIds={selectedIds}
        onSelectionChange={setSelectedIds}
      />
      <BulkActionBar
        count={selectedIds.size}
        onClear={() => setSelectedIds(new Set())}
        actions={[
          { key: "message", label: "메시지", onClick: () => undefined },
          { key: "remove", label: "제외", variant: "destructive", onClick: () => undefined },
        ]}
      />
    </div>
  );
}

행 액션과 하단 일괄바

activeRowId, onRowContextMenu, BulkActionBar
2개 선택됨
그룹
진도율
김지수
A반
82%
이민준
B반
64%
박서연
A반
91%
김지수
그룹
A반
진도율
82%
이민준
그룹
B반
진도율
64%
박서연
그룹
A반
진도율
91%
코드 보기tsx
import { useState } from "react";
import {
  BulkActionBar,
  ContextMenu,
  DataTable,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  type ContextMenuPosition,
  type DataTableColumn,
} from "@olundot/ui";

type StudentRow = { id: string; name: string; group: string; progress: number };

const columns: DataTableColumn<StudentRow>[] = [
  { key: "name", header: "학생", width: "1.4fr", sortable: true },
  { key: "group", header: "그룹", width: "1fr" },
  { key: "progress", header: "진도율", width: "96px", align: "right", tabular: true, render: (row) => `${row.progress}%` },
];

const rows: StudentRow[] = [
  { id: "st-1", name: "김지수", group: "A반", progress: 82 },
  { id: "st-2", name: "이민준", group: "B반", progress: 64 },
  { id: "st-3", name: "박서연", group: "A반", progress: 91 },
];

export function RowActionsTable() {
  const [selectedIds, setSelectedIds] = useState(new Set(["st-1"]));
  const [activeRowId, setActiveRowId] = useState<string>();
  const [position, setPosition] = useState<ContextMenuPosition | null>(null);
  const [menuOpen, setMenuOpen] = useState(false);

  return (
    <div className="grid gap-[var(--space-4)]">
      <DataTable
        aria-label="학생 행 액션 목록"
        columns={columns}
        data={rows}
        keyField="id"
        selectable
        selectedIds={selectedIds}
        activeRowId={activeRowId}
        onSelectionChange={setSelectedIds}
        onRowContextMenu={(rowId, event) => {
          event.preventDefault();
          setActiveRowId(rowId);
          setPosition({ x: event.clientX, y: event.clientY });
          setMenuOpen(true);
        }}
      />
      <BulkActionBar
        count={selectedIds.size}
        onClear={() => setSelectedIds(new Set())}
        actions={[
          { key: "message", label: "메시지", onClick: () => undefined },
          { key: "remove", label: "제외", variant: "destructive", onClick: () => undefined },
        ]}
      />
      <ContextMenu open={menuOpen} onOpenChange={setMenuOpen} position={position}>
        <DropdownMenuLabel>행 작업</DropdownMenuLabel>
        <DropdownMenuItem>미리보기</DropdownMenuItem>
        <DropdownMenuItem>선택에 추가</DropdownMenuItem>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive">목록에서 제외</DropdownMenuItem>
      </ContextMenu>
    </div>
  );
}

필터와 선택

toolbar와 selectable table
3명
그룹
진도율
김지수
A반
82%
이민준
B반
64%
박서연
A반
91%
김지수
그룹
A반
진도율
82%
이민준
그룹
B반
진도율
64%
박서연
그룹
A반
진도율
91%
코드 보기tsx
import { useState } from "react";
import { BulkActionBar, Button, DataTable, FilterToolbar, SearchField, Select, type DataTableColumn } from "@olundot/ui";

type StudentRow = { id: string; name: string; group: string; progress: number };

const columns: DataTableColumn<StudentRow>[] = [
  { key: "name", header: "학생", width: "1.4fr", sortable: true },
  { key: "group", header: "그룹", width: "1fr" },
  { key: "progress", header: "진도율", width: "96px", align: "right", tabular: true, render: (row) => `${row.progress}%` },
];

const rows: StudentRow[] = [
  { id: "st-1", name: "김지수", group: "A반", progress: 82 },
  { id: "st-2", name: "이민준", group: "B반", progress: 64 },
  { id: "st-3", name: "박서연", group: "A반", progress: 91 },
];

export function FilteredSelectableStudents() {
  const [selectedIds, setSelectedIds] = useState(new Set<string>());

  return (
    <div className="grid gap-[var(--space-3)]">
      <FilterToolbar
        aria-label="학생 진도 필터"
        search={<SearchField surface="canvas" aria-label="학생 검색" size="sm" placeholder="학생명 검색" defaultValue="김" />}
        filters={<Select surface="canvas" aria-label="그룹" selectVariant="inline" selectSize="sm" defaultValue="all" options={[{ value: "all", label: "전체 반" }, { value: "a", label: "A반" }]} />}
        resultSummary="3명"
        actions={<Button variant="primary" size="xs">메시지 발송</Button>}
      />
      <DataTable
        surface="canvas"
        aria-label="필터된 학생 진도 목록"
        columns={columns}
        data={rows}
        keyField="id"
        selectable
        selectedIds={selectedIds}
        onSelectionChange={setSelectedIds}
      />
      <BulkActionBar
        count={selectedIds.size}
        onClear={() => setSelectedIds(new Set())}
        actions={[{ key: "message", label: "메시지", onClick: () => undefined }]}
      />
    </div>
  );
}

정렬

sortBy와 onSortChange
박서연
06.02 10:01
94
김지수
06.02 09:12
88
이민준
06.02 09:28
76
박서연
제출
06.02 10:01
점수
94
김지수
제출
06.02 09:12
점수
88
이민준
제출
06.02 09:28
점수
76
코드 보기tsx
import { useMemo, useState } from "react";
import { DataTable, type DataTableColumn, type DataTableSort } from "@olundot/ui";

type AttemptRow = { id: string; student: string; submittedAt: string; score: number };

const columns: DataTableColumn<AttemptRow>[] = [
  { key: "student", header: "학생", width: "1fr", sortable: true },
  { key: "submittedAt", header: "제출", width: "130px", sortable: true },
  { key: "score", header: "점수", width: "88px", align: "right", tabular: true, sortable: true },
];

const rows: AttemptRow[] = [
  { id: "a-1", student: "김지수", submittedAt: "06.02 09:12", score: 88 },
  { id: "a-2", student: "이민준", submittedAt: "06.02 09:28", score: 76 },
  { id: "a-3", student: "박서연", submittedAt: "06.02 10:01", score: 94 },
];

export function SortableAttempts() {
  const [sortBy, setSortBy] = useState<DataTableSort>({ key: "score", dir: "desc" });
  const sortedRows = useMemo(() => [...rows].sort((a, b) => {
    const left = a[sortBy.key as keyof AttemptRow];
    const right = b[sortBy.key as keyof AttemptRow];
    return sortBy.dir === "asc" ? String(left).localeCompare(String(right), "ko-KR") : String(right).localeCompare(String(left), "ko-KR");
  }), [sortBy]);

  return (
    <DataTable surface="canvas"
      aria-label="제출 목록"
      columns={columns}
      data={sortedRows}
      keyField="id"
      sortBy={sortBy}
      onSortChange={(key, dir) => dir ? setSortBy({ key, dir }) : setSortBy({ key: "submittedAt", dir: "desc" })}
    />
  );
}

모바일 카드

좁은 화면에서 카드 목록으로 전환
콘텐츠
담당자
마감
심전도 기본 판독
김교수
06.12
호흡곤란 감별
이교수
06.18
심전도 기본 판독
담당자
김교수
마감
06.12
호흡곤란 감별
담당자
이교수
마감
06.18
코드 보기tsx
import { DataTable, type DataTableColumn } from "@olundot/ui";

type CourseRow = { id: string; title: string; owner: string; due: string };

const columns: DataTableColumn<CourseRow>[] = [
  { key: "title", header: "콘텐츠", width: "1.4fr" },
  { key: "owner", header: "담당자", width: "1fr" },
  { key: "due", header: "마감", width: "112px" },
];

const rows: CourseRow[] = [
  { id: "c-1", title: "심전도 기본 판독", owner: "김교수", due: "06.12" },
  { id: "c-2", title: "호흡곤란 감별", owner: "이교수", due: "06.18" },
];

export function ResponsiveCourseTable() {
  return <DataTable surface="canvas" aria-label="콘텐츠 목록" columns={columns} data={rows} keyField="id" />;
}

비어 있음·로딩

emptyState와 skeleton
항목
상태

조건에 맞는 항목이 없습니다

검색어를 줄이거나 필터를 초기화하세요.

조건에 맞는 항목이 없습니다

검색어를 줄이거나 필터를 초기화하세요.

항목
상태
데이터 로드 중
코드 보기tsx
import { DataTable, EmptyState, type DataTableColumn } from "@olundot/ui";

type Row = { id: string; title: string; status: string };

const columns: DataTableColumn<Row>[] = [
  { key: "title", header: "항목", width: "1fr" },
  { key: "status", header: "상태", width: "120px" },
];

export function EmptyAndLoadingTables() {
  return (
    <div style={{ display: "grid", gap: "16px" }}>
      <DataTable surface="canvas"
        aria-label="빈 결과"
        columns={columns}
        data={[]}
        keyField="id"
        emptyState={<EmptyState bordered={false} title="조건에 맞는 항목이 없습니다" description="검색어를 줄이거나 필터를 초기화하세요." />}
      />
      <DataTable surface="canvas" aria-label="로딩 중인 결과" columns={columns} data={[]} keyField="id" loading skeletonRowCount={3} />
    </div>
  );
}