columns
열 key, header, width, align, render, sortable을 선언해 표시 구조를 고정합니다.
keyField
행 선택과 접근 가능한 이름에 사용할 고유 값 필드입니다.
selection bar
selectable 테이블은 선택 전에도 header와 같은 toolbar strip을 유지해 layout shift를 막습니다.
selectionBar
`top`이 기본이며 `none`이면 내장 상단 선택 strip을 숨겨 외부 BulkActionBar와 조합합니다.
activeRowId
selectedIds와 독립인 현재 행 강조 값입니다. 데스크톱 row와 모바일 card에 같은 selected fill을 적용합니다.
row context
row와 mobile card는 data-row-id를 노출하고 onRowContextMenu(rowId, event)를 호출합니다.
mobile
같은 data와 columns로 좁은 화면에서는 카드 목록과 모바일 정렬 버튼을 렌더링합니다.
loading
데이터가 없으면 skeleton row, 데이터가 있으면 상단 progress bar로 갱신 중임을 표시합니다.

데스크톱 테이블

열 폭, 숫자 정렬, 행 이동
과목
응시자
상태
2026 중간고사
해부학
124
진행 예정
임상술기 평가
OSCE
42
채점 완료
심혈관 보충 시험
내과학
88
검토 중
2026 중간고사
과목
해부학
응시자
124
상태
진행 예정
임상술기 평가
과목
OSCE
응시자
42
상태
채점 완료
심혈관 보충 시험
과목
내과학
응시자
88
상태
검토 중
코드 보기tsx
import { DataTable, Badge, 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) => <Badge tone={row.status === "채점 완료" ? "success" : "info"}>{row.status}</Badge> },
];

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}`}
    />
  );
}

선택 행

selectable, selectedIds, bulkActions
1개 선택됨
그룹
진도율
김지수
A반
82%
이민준
B반
64%
박서연
A반
91%
김지수
그룹
A반
진도율
82%
이민준
그룹
B반
진도율
64%
박서연
그룹
A반
진도율
91%
코드 보기tsx
import { useState } from "react";
import { DataTable, Button, 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 (
    <DataTable surface="canvas"
      aria-label="학생 진도 목록"
      columns={columns}
      data={rows}
      keyField="id"
      selectable
      selectedIds={selectedIds}
      onSelectionChange={setSelectedIds}
      bulkActions={[
        { label: "메시지", onClick: () => undefined },
        { label: "제외", variant: "destructive", onClick: () => undefined },
      ]}
    />
  );
}

행 액션과 하단 일괄바

selectionBar=none, activeRowId, onRowContextMenu
그룹
진도율
김지수
A반
82%
이민준
B반
64%
박서연
A반
91%
김지수
그룹
A반
진도율
82%
이민준
그룹
B반
진도율
64%
박서연
그룹
A반
진도율
91%
2개 선택됨
코드 보기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
        selectionBar="none"
        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 { 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}
        bulkActions={[{ 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 surface="canvas" title="조건에 맞는 항목이 없습니다" description="검색어를 줄이거나 필터를 초기화하세요." />}
      />
      <DataTable surface="canvas" aria-label="로딩 중인 결과" columns={columns} data={[]} keyField="id" loading skeletonRowCount={3} />
    </div>
  );
}