@olundot/ui · Navigation/Data
DataTable
운영 데이터 목록을 데스크톱 테이블과 모바일 카드 목록으로 안정적으로 보여주는 데이터 표시 컴포넌트입니다.
- 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과목
응시자
상태
정렬
코드 보기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, 헤더 체크 = 불러온 행 전체학생 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, BulkActionBar1개 선택됨
그룹
진도율
김지수
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, BulkActionBar2개 선택됨
그룹
진도율
김지수
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 table3명
그룹
진도율
김지수
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>
);
}