@olundot/ui · Navigation/Data
DataTable
운영 데이터 목록을 데스크톱 테이블과 모바일 카드 목록으로 안정적으로 보여주는 데이터 표시 컴포넌트입니다.
- 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, bulkActions1개 선택됨
그룹
진도율
김지수
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 table3명
선택된 행 없음
그룹
진도율
김지수
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>
);
}