Table
Data Display
열 정의로 만드는 데이터 표.
Usage
행·열 데이터를 정렬·선택·페이지네이션과 함께 표로. 단순 나열이면 표가 아니라 목록으로
import
import
import { Table } from '@mildang/design-system/Table';예제를 복사해 쓸 때 필요한 준비
• @mildang/icons 를 따로 설치한다. DS 패키지에 아이콘 컴포넌트가 포함되지 않는다.
• 외부 패키지를 따로 설치한다. DS 의 전이 의존성에 기대지 않는다.
API Reference
Table Props
Prop
Type
Default
ColumnDef<TData, unknown>[]
지정 안 함
TData[]
지정 안 함
string
목록이 없습니다.
(row: Row<TData>) => Partial<TableRowProps>
지정 안 함
InfiniteScrollConfig
지정 안 함
boolean
false
(newData: TData[], info: RowOrderChangeInfo<TData>) => void
지정 안 함
TablePaginationConfig
지정 안 함
(row: Row<TData>) => ReactNode
지정 안 함
boolean | SelectionBarConfig
지정 안 함
TableServerSortingConfig
지정 안 함
Omit<TableOptions<TData>, "columns" | "data" | "getCoreRowModel">
지정 안 함
VirtualizationConfig
지정 안 함
AutoCompleteCell
Prop
Type
Default
((value: string) => void) | ((values: string[]) => void)
지정 안 함
ComboBoxCellOption[]
지정 안 함
string | string[]
지정 안 함
boolean
false
string | number
지정 안 함
boolean
false
boolean
false
string
미정
boolean
false
boolean
true
SystemStyleObject
지정 안 함
AvatarCell
Prop
Type
Default
AvatarData | AvatarData[]
지정 안 함
number
3
"xs" | "sm" | "md" | "lg" | "xl"
sm
SystemStyleObject
지정 안 함
CheckBoxCell
Prop
Type
Default
CellContext<unknown, unknown>
지정 안 함
SystemStyleObject
지정 안 함
ChipCell
Prop
Type
Default
string | TValue[]
지정 안 함
Partial<Record<TValue, ChipMapValue>>
지정 안 함
"blue" | "green" | "orange" | "red" | "neutral" | "brand"
neutral
ReactNode
지정 안 함
"xs" | "sm" | "md" | "lg"
sm
SystemStyleObject
지정 안 함
"fill" | "text" | "transparent" | "outlined"
text
ComboBoxCell
Prop
Type
Default
((value: string) => void) | ((values: string[]) => void)
지정 안 함
ComboBoxCellOption[]
지정 안 함
string | string[]
지정 안 함
boolean
false
string | number
지정 안 함
boolean
false
boolean
false
string
미정
boolean
false
boolean
true
SystemStyleObject
지정 안 함
ControllerCell
Prop
Type
Default
ReactNode
지정 안 함
SystemStyleObject
지정 안 함
DateCell
Prop
Type
Default
string | number | Date
지정 안 함
ReactNode
지정 안 함
DateTimeFormatOptions
지정 안 함
boolean
지정 안 함
SystemStyleObject
지정 안 함
TypographyVariantProps
지정 안 함
"date" | "time" | "dateTime" | "yearMonth" | "fullDate" | "fullDateTime"
fullDate
DragHandleCell
Prop
Type
Default
CellContext<unknown, unknown>
지정 안 함
SystemStyleObject
지정 안 함
"always" | "hover"
지정 안 함
ExpandCell
Prop
Type
Default
CellContext<unknown, unknown>
지정 안 함
SystemStyleObject
지정 안 함
InputCell
Prop
Type
Default
(value: string) => void
지정 안 함
string
지정 안 함
boolean
false
string
지정 안 함
NumberCell
Prop
Type
Default
number
지정 안 함
NumberFormatOptions
지정 안 함
SystemStyleObject
지정 안 함
TypographyVariantProps
지정 안 함
string
지정 안 함
PhoneCell
Prop
Type
Default
string
지정 안 함
"AC" | "AD" | "AE" | "AF" | "AG" | "AI" | "AL" | "AM" | "AO" | "AR" | "AS" | "AT" | "AU" | "AW" | "AX" | "AZ" | "BA" | "BB" | "BD" | "BE" | "BF" | "BG" | "BH" | "BI" | "BJ" | "BL" | "BM" | "BN" | "BO" | "BQ" | "BR" | "BS" | "BT" | "BW" | "BY" | "BZ" | "CA" | "CC" | "CD" | "CF" | "CG" | "CH" | "CI" | "CK" | "CL" | "CM" | "CN" | "CO" | "CR" | "CU" | "CV" | "CW" | "CX" | "CY" | "CZ" | "DE" | "DJ" | "DK" | "DM" | "DO" | "DZ" | "EC" | "EE" | "EG" | "EH" | "ER" | "ES" | "ET" | "FI" | "FJ" | "FK" | "FM" | "FO" | "FR" | "GA" | "GB" | "GD" | "GE" | "GF" | "GG" | "GH" | "GI" | "GL" | "GM" | "GN" | "GP" | "GQ" | "GR" | "GT" | "GU" | "GW" | "GY" | "HK" | "HN" | "HR" | "HT" | "HU" | "ID" | "IE" | "IL" | "IM" | "IN" | "IO" | "IQ" | "IR" | "IS" | "IT" | "JE" | "JM" | "JO" | "JP" | "KE" | "KG" | "KH" | "KI" | "KM" | "KN" | "KP" | "KR" | "KW" | "KY" | "KZ" | "LA" | "LB" | "LC" | "LI" | "LK" | "LR" | "LS" | "LT" | "LU" | "LV" | "LY" | "MA" | "MC" | "MD" | "ME" | "MF" | "MG" | "MH" | "MK" | "ML" | "MM" | "MN" | "MO" | "MP" | "MQ" | "MR" | "MS" | "MT" | "MU" | "MV" | "MW" | "MX" | "MY" | "MZ" | "NA" | "NC" | "NE" | "NF" | "NG" | "NI" | "NL" | "NO" | "NP" | "NR" | "NU" | "NZ" | "OM" | "PA" | "PE" | "PF" | "PG" | "PH" | "PK" | "PL" | "PM" | "PR" | "PS" | "PT" | "PW" | "PY" | "QA" | "RE" | "RO" | "RS" | "RU" | "RW" | "SA" | "SB" | "SC" | "SD" | "SE" | "SG" | "SH" | "SI" | "SJ" | "SK" | "SL" | "SM" | "SN" | "SO" | "SR" | "SS" | "ST" | "SV" | "SX" | "SY" | "SZ" | "TA" | "TC" | "TD" | "TG" | "TH" | "TJ" | "TK" | "TL" | "TM" | "TN" | "TO" | "TR" | "TT" | "TV" | "TW" | "TZ" | "UA" | "UG" | "US" | "UY" | "UZ" | "VA" | "VC" | "VE" | "VG" | "VI" | "VN" | "VU" | "WF" | "WS" | "XK" | "YE" | "YT" | "ZA" | "ZM" | "ZW"
지정 안 함
ReactNode
지정 안 함
"national" | "international"
national
SystemStyleObject
지정 안 함
TypographyVariantProps
지정 안 함
RowIndexCell
Prop
Type
Default
boolean
지정 안 함
CellContext<unknown, unknown>
지정 안 함
boolean
지정 안 함
number
지정 안 함
SystemStyleObject
지정 안 함
number
지정 안 함
SpacerCell
공개 Props 없음
TableInfiniteScroll
Prop
Type
Default
boolean
지정 안 함
() => void
지정 안 함
boolean
false
boolean
false
TableSelectionBar
Prop
Type
Default
() => void
지정 안 함
() => void
지정 안 함
number
지정 안 함
number
지정 안 함
string
지정 안 함
SystemStyleObject
지정 안 함
TableStatus
Prop
Type
Default
boolean
지정 안 함
boolean
지정 안 함
string
지정 안 함
number
10
TextCell
Prop
Type
Default
string
지정 안 함
ReactNode
지정 안 함
ReactNode
지정 안 함
boolean
지정 안 함
boolean
false
ReactNode
지정 안 함
SystemStyleObject
지정 안 함
TypographyVariantProps
지정 안 함
"none" | "end" | "start" | "middle"
end
TextFieldCell
Prop
Type
Default
(value: string) => void
지정 안 함
string
지정 안 함
boolean
false
string
지정 안 함
TextTemplateCell
Prop
Type
Default
string
지정 안 함
ReactNode[]
지정 안 함
boolean
false
BreadcrumbItem[]
지정 안 함
{ label: ReactNode; color?: "blue" | "green" | "orange" | "red" | "neutral" | "brand"; }
지정 안 함
ReactNode
지정 안 함
ReactNode
지정 안 함
ReactNode
지정 안 함
SystemStyleObject
지정 안 함
컬럼 정의 기본
cellColumnHelper 는 TanStack Table 의 컬럼 헬퍼를 감싸 두 갈래로 나눈다 — 실제 데이터를 그리는 Accessor Cell 과 데이터 없이 UI 만 담당하는 Display Cell.
| 구분 | 종류 |
|---|---|
| Accessor Cell | text, number, chip, avatar, date, phone, textTemplate |
| Display Cell | dragHandle, rowIndex, checkBox, expand, spacer |
두 갈래 모두 size, minSize, maxSize, enableResizing, CellProps, HeaderProps, depthIndent 를 공통으로 받는다 — 셀 종류마다 옵션 이름이 바뀌지 않는다.
size 를 지정한 컬럼은 그 px 로 고정되고(sized), 지정하지 않은 컬럼은 남은 너비를 서로 비율로 나눠 갖는다(flex) — 이 분류는 enableResizing 과 무관하게 size 유무만 본다.
전체 기능 조합
선택·페이지네이션·정렬·다양한 셀을 하나의 테이블에 조합한 예시다.
코드
'use client';
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { cellColumnHelper, Table, useTablePagination, useTableServerSelection } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableAllFeaturesExample() {
const [data, setData] = useState(sampleData);
const pagination = useTablePagination();
const pageItems = data.slice(
pagination.pageIndex * pagination.pageSize,
(pagination.pageIndex + 1) * pagination.pageSize,
);
const selection = useTableServerSelection({
pageData: pageItems,
getRowId: (row) => String(row.id),
totalCount: data.length,
allRowIds: data.map((d) => String(d.id)),
});
const col = cellColumnHelper<User>();
const columns = [
col.dragHandle(),
col.rowIndex(),
col.checkBox(),
col.text('name', '이름', { size: 80, enableSorting: true }),
col.number('age', '나이', { size: 80, enableSorting: true, ContentProps: { trailingText: '세' } }),
col.phone('phone', '전화번호', { size: 140, ContentProps: { countryCode: 'KR' } }),
col.text('email', '이메일', {
enableResizing: true,
CellProps: { truncate: 'end' },
}),
col.number('progress', '진도율', {
enableSorting: true,
ContentProps: { trailingText: '%' },
HeaderProps: { tooltip: '전체 학습 과정 대비 진도율 입니다' },
}),
col.date('createdAt', '등록일', { size: 120, enableSorting: true }),
col.chip('status', '상태', {
size: 80,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
col.chip('tags', () => <Trans>수강 과목</Trans>, {
enableResizing: true,
ContentProps: {
chipMap: {
국어: { color: 'blue', label: <Trans>국어</Trans> },
영어: { color: 'green', label: <Trans>영어</Trans> },
수학: { color: 'red', label: <Trans>수학</Trans> },
},
size: 'md',
},
}),
col.avatar('assignee', '선생님', { size: 80 }),
];
const handleRowOrderChange = (reorderedPage: User[]) => {
const start = pagination.pageIndex * pagination.pageSize;
setData((prev) => {
const next = [...prev];
next.splice(start, reorderedPage.length, ...reorderedPage);
return next;
});
};
return (
<Table
data={pageItems}
columns={columns}
tableOptions={{
getRowId: (row) => String(row.id),
state: { rowSelection: selection.rowSelection },
onRowSelectionChange: selection.onRowSelectionChange,
}}
onRowOrderChange={handleRowOrderChange}
selectionBar={selection.selectionBar}
pagination={{
...pagination,
rowCount: data.length,
showTrailing: true,
}}
/>
);
}
자주 걸리는 함정
cellColumnHelper 와 Table 을 쓸 때 자주 걸리는 함정 네 가지.
- dragHandle 컬럼으로 행을 드래그하려면 onRowOrderChange 를 반드시 함께 넘겨야 한다 — 컬럼만 추가해서는 순서가 바뀌지 않는다.
- 컬럼 id 가 중복되면 콘솔에 "[DS Table] 컬럼 ID 중복이 발견되었습니다!" 로 시작하는 에러가 뜬다. accessor 컬럼은 필드명이 곧 id 라 같은 필드를 두 번 정의하면 걸린다 — 다른 필드를 쓰거나 세 번째 인자로 id 를 직접 지정한다.
- columns 배열은 매 렌더마다 새로 만들지 않는다 — useMemo 나 컴포넌트 바깥 상수로 고정해야 컬럼 리사이징·정렬 상태가 렌더 사이에 유지된다.
- 행 가상화와 드래그 앤 드롭 재정렬은 함께 쓸 수 없다 — 화면에 없는 행은 드래그 대상이 될 수 없어서다. 행이 아주 많으면서 순서도 바꿔야 한다면 페이지네이션으로 화면에 걸리는 행 수를 줄인다.
전체 기능 조합
선택·페이지네이션·정렬·다양한 셀을 하나의 테이블에 조합한 예시다.
코드
'use client';
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { cellColumnHelper, Table, useTablePagination, useTableServerSelection } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableAllFeaturesExample() {
const [data, setData] = useState(sampleData);
const pagination = useTablePagination();
const pageItems = data.slice(
pagination.pageIndex * pagination.pageSize,
(pagination.pageIndex + 1) * pagination.pageSize,
);
const selection = useTableServerSelection({
pageData: pageItems,
getRowId: (row) => String(row.id),
totalCount: data.length,
allRowIds: data.map((d) => String(d.id)),
});
const col = cellColumnHelper<User>();
const columns = [
col.dragHandle(),
col.rowIndex(),
col.checkBox(),
col.text('name', '이름', { size: 80, enableSorting: true }),
col.number('age', '나이', { size: 80, enableSorting: true, ContentProps: { trailingText: '세' } }),
col.phone('phone', '전화번호', { size: 140, ContentProps: { countryCode: 'KR' } }),
col.text('email', '이메일', {
enableResizing: true,
CellProps: { truncate: 'end' },
}),
col.number('progress', '진도율', {
enableSorting: true,
ContentProps: { trailingText: '%' },
HeaderProps: { tooltip: '전체 학습 과정 대비 진도율 입니다' },
}),
col.date('createdAt', '등록일', { size: 120, enableSorting: true }),
col.chip('status', '상태', {
size: 80,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
col.chip('tags', () => <Trans>수강 과목</Trans>, {
enableResizing: true,
ContentProps: {
chipMap: {
국어: { color: 'blue', label: <Trans>국어</Trans> },
영어: { color: 'green', label: <Trans>영어</Trans> },
수학: { color: 'red', label: <Trans>수학</Trans> },
},
size: 'md',
},
}),
col.avatar('assignee', '선생님', { size: 80 }),
];
const handleRowOrderChange = (reorderedPage: User[]) => {
const start = pagination.pageIndex * pagination.pageSize;
setData((prev) => {
const next = [...prev];
next.splice(start, reorderedPage.length, ...reorderedPage);
return next;
});
};
return (
<Table
data={pageItems}
columns={columns}
tableOptions={{
getRowId: (row) => String(row.id),
state: { rowSelection: selection.rowSelection },
onRowSelectionChange: selection.onRowSelectionChange,
}}
onRowOrderChange={handleRowOrderChange}
selectionBar={selection.selectionBar}
pagination={{
...pagination,
rowCount: data.length,
showTrailing: true,
}}
/>
);
}
행 확장
col.expand() 는 인자에 따라 동작이 갈린다.
- 인자 없이 호출하면 확장·축소 토글 버튼만 있는 컬럼 하나를 만든다.
- 컬럼을 인자로 넘기면 그 컬럼들과 토글 버튼을 한 컬럼으로 합치고, row.depth 에 비례해 왼쪽 여백을 자동으로 넣는다 — 하위 행일수록 더 들여쓴다.
하위 행을 펼치려면 tableOptions 에 getSubRows 로 자식 배열 접근법을 알려줘야 한다. col.rowIndex 에 nested: true 를 주면 번호가 항상 숫자만 나오는 게 아니라, 하위 행에서는 "4-1", "4-2-1" 처럼 깊이를 하이픈으로 이어 붙인 번호를 보여준다.
하위 행을 드래그로 재정렬하면 onRowOrderChange 의 두 번째 인자 안에 parentRow 가 담겨 온다 — 최상위 행 재정렬이면 null, 특정 부모의 자식들 사이 재정렬이면 그 부모 행 객체다. 재정렬된 배열을 최상위에 반영할지 특정 부모의 children 에 반영할지는 이 값으로 구분한다.
확장 컬럼
확장 컬럼만 사용해 하위 행을 여닫는 예시다.
코드
'use client';
import { useState } from 'react';
import type { ExpandedState } from '@tanstack/react-table';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface MenuItem {
id: number;
name: string;
status: string;
type: string;
access: string;
isAddRow?: boolean;
children?: MenuItem[];
}
const menuData: MenuItem[] = [
{ id: 1, name: '서비스 소개', status: '공개중', type: 'HTML 임베드형', access: '모든 사용자' },
{ id: 2, name: '실시간 강좌', status: '공개중', type: '강좌 리스트형', access: '모든 사용자' },
{ id: 3, name: '콘텐츠 강좌', status: '공개중', type: '강좌 리스트형', access: '모든 사용자' },
{
id: 4,
name: '알림마당',
status: '공개중',
type: '',
access: '',
children: [
{ id: 41, name: '공지사항', status: '공개중', type: '게시판형', access: '전체 사용자' },
{ id: 42, name: '1:1 문의', status: '공개중', type: '게시판형', access: '전체 사용자' },
{ id: 43, name: '소식지', status: '공개중', type: '게시판형', access: '전체 사용자' },
],
},
{
id: 5,
name: '소통마당',
status: '공개중',
type: '',
access: '',
children: [
{ id: 51, name: '자유게시판', status: '공개중', type: '게시판형', access: '전체 사용자' },
{
id: 52,
name: '갤러리',
status: '공개중',
type: '',
access: '',
children: [
{ id: 521, name: '사진 갤러리', status: '공개중', type: '갤러리형', access: '전체 사용자' },
{ id: 522, name: '동영상 갤러리', status: '공개중', type: '갤러리형', access: '전체 사용자' },
{ id: 523, name: '문서 갤러리', status: '비공개', type: '갤러리형', access: '전체 사용자' },
],
},
],
},
{ id: 6, name: '설정', status: '비공개', type: '설정형', access: '관리자' },
];
const updateChildren = (items: MenuItem[], parentId: number, newChildren: MenuItem[]): MenuItem[] =>
items.map((item) =>
item.id === parentId
? { ...item, children: newChildren }
: item.children
? { ...item, children: updateChildren(item.children, parentId, newChildren) }
: item,
);
export default function TableExpandStandaloneExample() {
const [expanded, setExpanded] = useState<ExpandedState>({});
const [data, setData] = useState(menuData);
const col = cellColumnHelper<MenuItem>();
const columns = [
col.expand(),
col.checkBox(),
col.text('name', '메뉴명'),
col.chip('status', '공개 상태', {
size: 100,
ContentProps: {
chipMap: {
공개중: { color: 'green', label: '공개중' },
비공개: { color: 'red', label: '비공개' },
},
type: 'fill',
},
}),
col.text('type', '유형', { size: 120 }),
col.text('access', '화면 접근 권한', { size: 120 }),
];
return (
<Table<MenuItem>
data={data}
columns={columns}
onRowOrderChange={(newData, { parentRow }) => {
if (!parentRow) {
setData(newData);
} else {
setData((prev) => updateChildren(prev, parentRow.id, newData));
}
}}
tableOptions={{
getRowId: (row) => String(row.id),
getSubRows: (row) => row.children,
state: { expanded },
onExpandedChange: setExpanded,
}}
/>
);
}
정렬
컬럼에 enableSorting을 켜고 sorting 상태와 onSortingChange를 연결하면 헤더 클릭으로 정렬 결과가 바뀐다.
제어형 정렬
정렬 상태를 직접 제어하는 예시다.
코드
'use client';
import { useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableFeaturesSortingExample() {
const [sorting, setSorting] = useState<SortingState>([]);
// 나이가 겹치는 데이터로 다중 정렬 효과 확인
const sortingData: User[] = [
{ ...sampleData[0]!, age: 12, progress: 90 },
{ ...sampleData[1]!, age: 14, progress: 60 },
{ ...sampleData[2]!, age: 12, progress: 70 },
{ ...sampleData[3]!, age: 14, progress: 85 },
{ ...sampleData[4]!, age: 12, progress: 55 },
{ ...sampleData[5]!, age: 11, progress: 70 },
{ ...sampleData[6]!, age: 14, progress: 70 },
{ ...sampleData[7]!, age: 11, progress: 95 },
];
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80, enableSorting: true }),
col.number('age', '나이', { size: 100, enableSorting: true, ContentProps: { trailingText: '세' } }),
col.number('progress', '진도율', {
enableSorting: true,
enableResizing: true,
ContentProps: { trailingText: '%' },
}),
col.chip('status', '상태', {
size: 80,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
];
const getSortLabel = () => {
if (sorting.length === 0) return '정렬 없음';
return sorting
.map((sort, i) => `${i + 1}. ${sort.id} ${sort.desc ? '내림차순 ↓' : '오름차순 ↑'}`)
.join(' ');
};
return (
<div>
<Table
data={sortingData}
columns={columns}
tableOptions={{
onSortingChange: setSorting,
state: { sorting },
}}
/>
<div
style={{
marginTop: 16,
padding: '8px 12px',
background: sorting.length > 0 ? '#E8F4FD' : '#F5F5F5',
borderRadius: 6,
fontSize: 13,
color: sorting.length > 0 ? '#1976D2' : '#666',
display: 'inline-block',
}}
>
{getSortLabel()}
</div>
</div>
);
}
페이지네이션
useTablePagination의 페이지 상태를 데이터 조회 범위와 pagination prop에 연결해 페이지를 전환한다.
서버 페이지네이션
페이지 상태를 서버 데이터 조회 범위와 연결하는 예시다.
'use client';
import { cellColumnHelper, Table, useTablePagination } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
function fakeFetch(pageIndex: number, pageSize: number) {
const start = pageIndex * pageSize;
return {
items: sampleData.slice(start, start + pageSize),
totalCount: sampleData.length,
};
}
interface TablePaginationServerSideExampleProps {
showTrailing?: boolean;
}
export default function TablePaginationServerSideExample({
showTrailing = false,
}: TablePaginationServerSideExampleProps) {
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80 }),
col.number('age', '나이', { size: 80, ContentProps: { trailingText: '세' } }),
col.text('email', '이메일', { CellProps: { truncate: 'end' } }),
];
const pagination = useTablePagination({ pageSizeOptions: [5, 10, 30] });
// GraphQL offset 기반 페이지네이션:
// pagination.offset → (pageIndex * pageSize), pagination.limit → pageSize
const fetch = fakeFetch(pagination.pageIndex, pagination.pageSize);
return (
<Table
data={fetch.items}
columns={columns}
pagination={{
...pagination,
rowCount: fetch.totalCount,
showTrailing,
}}
/>
);
}
ComboBox 셀
col.comboBox 의 ContentProps 는 multiple 값에 따라 onChange 시그니처가 달라진다.
- multiple 을 생략하거나 false 로 두면 단일 선택이고, onChange 는 (선택값 하나, 해당 행) 을 받는다.
- multiple: true 로 두면 다중 선택이고, onChange 는 (선택값 배열, 해당 행) 을 받는다.
chipOptions: true 를 주면 드롭다운 목록과 선택된 값 모두 Chip 으로 렌더된다. 지정하지 않으면 드롭다운 목록은 일반 텍스트로 나오고, 트리거만 옵션의 color 값에 따라 색이 들어간 Chip 으로 표시된다.
ComboBox·Input 조합
ComboBox 셀과 Input 셀을 함께 사용하는 편집 테이블 예시다.
'use client';
import { useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
import { Button } from '@mildang/design-system/Button';
interface LectureRow {
id: number;
dateTime: string;
periods: string[];
classStatus: string;
attendance: string;
memo: string;
}
const periodOptions = [
{ label: '1차시', value: '1차시' },
{ label: '2차시', value: '2차시' },
{ label: '3차시', value: '3차시' },
{ label: '4차시', value: '4차시' },
{ label: '5차시', value: '5차시' },
{ label: '6차시', value: '6차시' },
{ label: '7차시', value: '7차시' },
{ label: '8차시', value: '8차시' },
];
export default function TableCellsComboBoxAndInputExample() {
const [data, setData] = useState<LectureRow[]>([
{
id: 1,
dateTime: '2026-04-08T14:00:00Z',
periods: ['3차시', '4차시'],
classStatus: '수업 예정',
attendance: '입력 전',
memo: '준비물 확인',
},
{
id: 2,
dateTime: '2026-04-09T10:00:00Z',
periods: ['1차시', '2차시', '3차시'],
classStatus: '수업 완료',
attendance: '입력 전',
memo: '',
},
{
id: 3,
dateTime: '2026-04-10T09:00:00Z',
periods: [],
classStatus: '수업 예정',
attendance: '입력 전',
memo: '보강 예정',
},
]);
const handlePeriodsChange = (values: string[], row: LectureRow) => {
setData((prev) => prev.map((r) => (r.id === row.id ? { ...r, periods: values } : r)));
};
const handleStatusChange = (value: string, row: LectureRow) => {
setData((prev) => prev.map((r) => (r.id === row.id ? { ...r, classStatus: value } : r)));
};
const handleMemoChange = (value: string, row: LectureRow) => {
setData((prev) => prev.map((r) => (r.id === row.id ? { ...r, memo: value } : r)));
};
const classStatusOptions = [
{ label: '수업 예정', value: '수업 예정' },
{ label: '수업 완료', value: '수업 완료', color: 'green' as const },
];
const col = cellColumnHelper<LectureRow>();
const columns = [
col.rowIndex(),
col.date('dateTime', '날짜', { size: 120 }),
col.comboBox('periods', '수강 차시', {
size: 240,
ContentProps: {
multiple: true,
options: periodOptions,
placeholder: '미정',
onChange: handlePeriodsChange,
},
}),
col.comboBox('classStatus', '수업 현황', {
size: 140,
ContentProps: {
options: classStatusOptions,
placeholder: '미정',
onChange: handleStatusChange,
},
}),
col.text('attendance', '출석 현황', { size: 130 }),
col.input('memo', '메모', {
ContentProps: {
placeholder: '메모를 입력하세요.',
onChange: handleMemoChange,
},
}),
col.controller(
'강의실',
() => (
<Button variant="tertiary" size="sm">
강의실 개설
</Button>
),
{ size: 130 },
),
];
return <Table data={data} columns={columns} />;
}
예제
다국어 헤더
헤더와 셀 라벨을 Trans로 감싼 테이블 예시다.
코드
import { Trans } from '@lingui/react/macro';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableWithTransExample() {
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex(() => <Trans>번호</Trans>),
col.text('name', () => <Trans>이름</Trans>, { size: 80 }),
col.number('age', () => <Trans>나이</Trans>, { size: 80, ContentProps: { trailingText: '세' } }),
col.text('email', () => <Trans>이메일</Trans>),
col.chip('status', () => <Trans>상태</Trans>, {
size: 80,
ContentProps: {
chipMap: {
활성: { color: 'green', label: <Trans>활성</Trans> },
비활성: { color: 'red', label: <Trans>비활성</Trans> },
},
type: 'fill',
},
}),
];
return <Table data={sampleData.slice(0, 5)} columns={columns} />;
}
Avatar 셀
Avatar 셀과 행 액션을 조합한 테이블 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
import { IconButton } from '@mildang/design-system/IconButton';
import MoreHorizIcon from '@mildang/icons/react/more-horiz';
interface Content {
id: number;
type: string;
subject: string;
title: string;
createdAt: string;
assignee: { initial: string; src?: string };
authors: { initial: string; src?: string }[];
}
const contentData: Content[] = [
{
id: 1,
type: '일반 교재',
subject: '영어',
title: '교과서 문법 드릴 - 중1 (22개정)',
createdAt: '2026-02-11T09:30:00Z',
assignee: { initial: '혜인', src: 'https://picsum.photos/seed/avatar1/150/150' },
authors: [
{ initial: '혜인', src: 'https://picsum.photos/seed/avatar1/150/150' },
{ initial: '가영', src: 'https://picsum.photos/seed/avatar2/150/150' },
],
},
{
id: 2,
type: '일반 교재',
subject: '수학',
title: '[메가PT 수학] 김기현 - [수학2] 2027 IDEA',
createdAt: '2026-02-11T10:15:00Z',
assignee: { initial: '가영', src: 'https://picsum.photos/seed/avatar2/150/150' },
authors: [
{ initial: '가영', src: 'https://picsum.photos/seed/avatar2/150/150' },
{ initial: '은영', src: 'https://picsum.photos/seed/avatar5/150/150' },
{ initial: '예슬' },
],
},
{
id: 3,
type: '일반 교재',
subject: '수학',
title: 'M-수능) 2027 수능특강 컴데기',
createdAt: '2026-02-11T14:00:00Z',
assignee: { initial: '예슬' },
authors: [{ initial: '예슬' }],
},
{
id: 4,
type: '일반 교재',
subject: '수학',
title: 'M-수능) 수능 Stride 2027',
createdAt: '2026-02-10T11:20:00Z',
assignee: { initial: '예슬' },
authors: [{ initial: '예슬' }, { initial: '효섭' }],
},
{
id: 5,
type: '일반 교재',
subject: '수학',
title: 'M-AI) 최상위수학 6-1_쌍둥이',
createdAt: '2026-02-10T16:45:00Z',
assignee: { initial: '은영', src: 'https://picsum.photos/seed/avatar5/150/150' },
authors: [
{ initial: '은영', src: 'https://picsum.photos/seed/avatar5/150/150' },
{ initial: '혜인', src: 'https://picsum.photos/seed/avatar1/150/150' },
{ initial: '가영', src: 'https://picsum.photos/seed/avatar2/150/150' },
{ initial: '예슬' },
],
},
{
id: 6,
type: '문제은행',
subject: '수학',
title: '[연구용, 사용절대금지(저작권위배됨)] M-AI) 최상위수학 6-1',
createdAt: '2026-02-10T08:00:00Z',
assignee: { initial: '은영', src: 'https://picsum.photos/seed/avatar5/150/150' },
authors: [{ initial: '은영', src: 'https://picsum.photos/seed/avatar5/150/150' }],
},
{
id: 7,
type: '일반 교재',
subject: '영어',
title: '영단어_시험_테스트',
createdAt: '2026-02-09T13:10:00Z',
assignee: { initial: '효리' },
authors: [
{ initial: '효리' },
{ initial: '혜인', src: 'https://picsum.photos/seed/avatar1/150/150' },
{ initial: '예슬' },
],
},
];
export default function TableCellsAvatarExample() {
const col = cellColumnHelper<Content>();
const columns = [
col.checkBox(),
col.chip('type', '유형', {
size: 100,
ContentProps: {
chipMap: {
'일반 교재': { color: 'brand', label: '일반 교재' },
문제은행: { color: 'blue', label: '문제은행' },
시험: { color: 'orange', label: '시험' },
},
},
}),
col.text('subject', '과목', { size: 80, CellProps: { align: 'center' } }),
col.text('title', '제목', { CellProps: { truncate: 'end' } }),
col.date('createdAt', '생성 날짜', { size: 160, ContentProps: { type: 'dateTime' } }),
col.avatar('assignee', '담당자', { size: 80 }),
col.avatar('authors', '작성자', { size: 120, ContentProps: { max: 3 } }),
col.controller(
() => (
<IconButton size="xs" onClick={() => {}}>
<MoreHorizIcon />
</IconButton>
),
{ size: 50 },
),
];
return <Table data={contentData} columns={columns} />;
}
빈 셀
빈 값과 여러 셀 타입을 처리하는 테이블 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableCellsBlankExample() {
const col = cellColumnHelper<User>();
const columns = [
col.checkBox(),
col.rowIndex('번호'),
col.text('name', '이름', { size: 80 }),
col.date('createdAt', '등록일', { size: 120, ContentProps: { shortYear: true } }),
col.phone('phone', '전화번호', { ContentProps: { countryCode: 'SV', format: 'international' } }),
col.blank(
'진도',
(row) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
<div
style={{
flex: 1,
height: 6,
borderRadius: 3,
backgroundColor: '#eee',
overflow: 'hidden',
}}
>
<div
style={{
width: `${row.progress}%`,
height: '100%',
borderRadius: 3,
backgroundColor: row.progress >= 85 ? '#22c55e' : row.progress < 60 ? '#f59e0b' : '#3b82f6',
}}
/>
</div>
<span style={{ fontSize: 12, color: '#666', whiteSpace: 'nowrap' }}>{row.progress}%</span>
</div>
),
{ enableResizing: true, size: 500 },
),
col.blank('빈칸 1', () => <></>),
col.blank('빈칸 2'),
];
const data = sampleData.slice(0, 5).map((user, i) => ({
...user,
progress: [95, 42, 78, 15, 63][i]!,
}));
return <Table data={data} columns={columns} />;
}
ComboBox Chip 옵션
행마다 다른 ComboBox 옵션을 Chip으로 표시하는 예시다.
'use client';
import { useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface StatusRow {
id: number;
name: string;
chipStatus: string;
textStatus: string;
assignee: string;
}
export default function TableCellsComboBoxChipOptionsExample() {
const [data, setData] = useState<StatusRow[]>([
{ id: 1, name: '홍길동', chipStatus: 'done', textStatus: 'scheduled', assignee: 'kim' },
{ id: 2, name: '김철수', chipStatus: 'in_progress', textStatus: 'completed', assignee: 'park' },
{ id: 3, name: '이영희', chipStatus: 'pending', textStatus: '', assignee: '' },
]);
const chipStatusOptions = [
{ label: '완료', value: 'done', color: 'green' as const },
{ label: '진행 중', value: 'in_progress', color: 'blue' as const },
{ label: '대기', value: 'pending', color: 'orange' as const },
{ label: '오류', value: 'error', color: 'red' as const },
];
const textStatusOptions = [
{ label: '수업 예정', value: 'scheduled' },
{ label: '수업 완료', value: 'completed' },
];
const assigneeOptions = [
{ label: '김선생', value: 'kim' },
{ label: '박선생', value: 'park' },
{ label: '이선생', value: 'lee' },
];
const handleChipStatusChange = (value: string, row: StatusRow) => {
setData((prev) => prev.map((r) => (r.id === row.id ? { ...r, chipStatus: value } : r)));
};
const handleTextStatusChange = (value: string, row: StatusRow) => {
setData((prev) => prev.map((r) => (r.id === row.id ? { ...r, textStatus: value } : r)));
};
const handleAssigneeChange = (value: string, row: StatusRow) => {
setData((prev) => prev.map((r) => (r.id === row.id ? { ...r, assignee: value } : r)));
};
const col = cellColumnHelper<StatusRow>();
const columns = [
col.rowIndex(),
col.text('name', '이름', { size: 120 }),
col.comboBox('chipStatus', 'chipOptions: true', {
size: 200,
ContentProps: {
options: chipStatusOptions,
placeholder: '상태',
chipOptions: true,
onChange: handleChipStatusChange,
},
}),
col.comboBox('textStatus', 'chipOptions: false (default)', {
size: 240,
ContentProps: {
options: textStatusOptions,
placeholder: '상태',
onChange: handleTextStatusChange,
},
}),
col.comboBox('assignee', '담당자', {
size: 160,
ContentProps: {
options: assigneeOptions,
placeholder: '담당자',
onChange: handleAssigneeChange,
},
}),
];
return <Table data={data} columns={columns} />;
}
행 액션
Controller 셀에 행별 액션 버튼을 배치한 예시다.
'use client';
import { useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
import { IconButton } from '@mildang/design-system/IconButton';
import { ToggleSwitch } from '@mildang/design-system/ToggleSwitch';
import EditIcon from '@mildang/icons/react/edit';
import DeleteIcon from '@mildang/icons/react/delete';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableCellsControllerExample() {
const [data, setData] = useState(sampleData.slice(0, 5));
const col = cellColumnHelper<User>();
const handleDelete = (id: number) => {
setData((prev) => prev.filter((row) => row.id !== id));
};
const handleToggleStatus = (id: number) => {
setData((prev) =>
prev.map((row) => (row.id === id ? { ...row, status: row.status === '활성' ? '비활성' : '활성' } : row)),
);
};
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80 }),
col.date('createdAt', '등록일시', {
size: 180,
ContentProps: { type: 'fullDateTime' },
}),
col.phone('phone', '전화번호', {
size: 170,
ContentProps: { countryCode: 'KR', format: 'international' },
}),
col.chip('status', '상태', {
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
col.controller(
'액션',
(row) => (
<>
<IconButton size="xs" onClick={() => alert(`${row.name} 수정`)}>
<EditIcon />
</IconButton>
<IconButton size="xs" onClick={() => handleDelete(row.id)}>
<DeleteIcon />
</IconButton>
<ToggleSwitch size="sm" checked={row.status === '활성'} onChange={() => handleToggleStatus(row.id)} />
</>
),
{ size: 300 },
),
];
return <Table data={data} columns={columns} />;
}
TextTemplate 셀
제목·보조 설명·상태를 조합한 TextTemplate 셀 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
import { IconButton } from '@mildang/design-system/IconButton';
import CheckIcon from '@mildang/icons/react/check';
import EditIcon from '@mildang/icons/react/edit';
import DeleteIcon from '@mildang/icons/react/delete';
import MoreHorizIcon from '@mildang/icons/react/more-horiz';
interface Lesson {
id: number;
name: string;
category: string;
subCategory: string;
description: string;
status: string;
subject: string;
assignee: { initial: string; src?: string };
progress: number;
createdAt: string;
}
const lessonData: Lesson[] = [
{
id: 1,
name: '1강 be동사의 현재형',
category: '밀당영어',
subCategory: 'G1 기본문법',
description: '주어에 따른 be동사 변화',
status: '완료',
subject: '영어',
assignee: { initial: '혜인', src: 'https://picsum.photos/seed/avatar1/150/150' },
progress: 1.0,
createdAt: '2026-03-01T09:00:00Z',
},
{
id: 2,
name: '2강 일반동사의 현재형',
category: '밀당영어',
subCategory: 'G1 기본문법',
description: '일반동사의 3인칭 단수 변화',
status: '진행중',
subject: '영어',
assignee: { initial: '가영', src: 'https://picsum.photos/seed/avatar2/150/150' },
progress: 0.65,
createdAt: '2026-03-05T10:30:00Z',
},
{
id: 3,
name: '3강 현재진행형',
category: '밀당영어',
subCategory: 'G1 기본문법',
description: 'be동사 + -ing 형태',
status: '완료',
subject: '영어',
assignee: { initial: '예슬' },
progress: 1.0,
createdAt: '2026-03-10T14:00:00Z',
},
{
id: 4,
name: '1강 덧셈과 뺄셈',
category: '밀당수학',
subCategory: '초등 3학년',
description: '세 자리 수의 덧셈과 뺄셈',
status: '대기',
subject: '수학',
assignee: { initial: '은영', src: 'https://picsum.photos/seed/avatar5/150/150' },
progress: 0.0,
createdAt: '2026-03-12T11:00:00Z',
},
{
id: 5,
name: '2강 곱셈',
category: '밀당수학',
subCategory: '초등 3학년',
description:
'두 자리 수 × 한 자리 수 두 자리 수 × 한 자리 수 두 자리 수 × 한 자리 수 두 자리 수 × 한 자리 수 두 자리 수 × 한 자리 수 두 자리 수 × 한 자리 수 ',
status: '진행중',
subject: '수학',
assignee: { initial: '효섭' },
progress: 0.3,
createdAt: '2026-03-15T16:00:00Z',
},
];
export default function TableCellsTextTemplateExample() {
const col = cellColumnHelper<Lesson>();
const columns = [
col.checkBox(),
col.rowIndex(),
col.textTemplate('name', '강의명', {
size: 400,
ContentProps: (row) => ({
leadingIcon: <CheckIcon />,
breadcrumb: [
{ label: row.category, href: '#' },
{ label: row.subCategory, href: '#' },
],
description: row.description,
chip: {
label: row.status,
color: row.status === '완료' ? 'green' : row.status === '진행중' ? 'blue' : 'brand',
},
actions: [
<IconButton key="edit" size="xs">
<EditIcon />
</IconButton>,
<IconButton key="delete" size="xs">
<DeleteIcon />
</IconButton>,
],
actionsHoverReveal: true,
}),
}),
col.chip('subject', '과목', {
size: 80,
ContentProps: {
chipMap: {
영어: { color: 'green', label: '영어' },
수학: { color: 'red', label: '수학' },
},
type: 'fill',
},
}),
col.number('progress', '진도율', { ContentProps: { formatOptions: { style: 'percent' } } }),
col.date('createdAt', '등록일'),
col.avatar('assignee', '담당자', { size: 80 }),
col.controller(
(row) => (
<IconButton size="xs" onClick={() => alert(`${row.name} 더보기`)}>
<MoreHorizIcon />
</IconButton>
),
{ size: 48 },
),
];
return <Table data={lessonData} columns={columns} />;
}
텍스트 말줄임
긴 텍스트를 말줄임으로 표시하는 테이블 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableCellsTruncateExample() {
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80, enableResizing: true }),
col.text('email', '이메일 (default)', { enableResizing: true }),
col.text('email', '이메일 (end)', {
id: 'email-end',
enableResizing: true,
CellProps: { truncate: 'end' },
}),
col.text('email', '이메일 (start)', {
id: 'email-start',
enableResizing: true,
CellProps: { truncate: 'start' },
}),
col.text('email', '이메일 (middle)', {
id: 'email-middle',
enableResizing: true,
CellProps: { truncate: 'middle' },
}),
col.text('email', '이메일 (none)', {
id: 'email-none',
enableResizing: true,
CellProps: { truncate: 'none' },
}),
];
return <Table data={sampleData.slice(0, 5)} columns={columns} />;
}
그룹 행 확장
그룹 헤더와 하위 행 확장을 함께 구성한 예시다.
'use client';
import { useMemo, useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface MenuItem {
id: number;
name: string;
status: string;
type: string;
access: string;
isAddRow?: boolean;
children?: MenuItem[];
}
const menuData: MenuItem[] = [
{ id: 1, name: '서비스 소개', status: '공개중', type: 'HTML 임베드형', access: '모든 사용자' },
{ id: 2, name: '실시간 강좌', status: '공개중', type: '강좌 리스트형', access: '모든 사용자' },
{ id: 3, name: '콘텐츠 강좌', status: '공개중', type: '강좌 리스트형', access: '모든 사용자' },
{
id: 4,
name: '알림마당',
status: '공개중',
type: '',
access: '',
children: [
{ id: 41, name: '공지사항', status: '공개중', type: '게시판형', access: '전체 사용자' },
{ id: 42, name: '1:1 문의', status: '공개중', type: '게시판형', access: '전체 사용자' },
{ id: 43, name: '소식지', status: '공개중', type: '게시판형', access: '전체 사용자' },
],
},
{
id: 5,
name: '소통마당',
status: '공개중',
type: '',
access: '',
children: [
{ id: 51, name: '자유게시판', status: '공개중', type: '게시판형', access: '전체 사용자' },
{
id: 52,
name: '갤러리',
status: '공개중',
type: '',
access: '',
children: [
{ id: 521, name: '사진 갤러리', status: '공개중', type: '갤러리형', access: '전체 사용자' },
{ id: 522, name: '동영상 갤러리', status: '공개중', type: '갤러리형', access: '전체 사용자' },
{ id: 523, name: '문서 갤러리', status: '비공개', type: '갤러리형', access: '전체 사용자' },
],
},
],
},
{ id: 6, name: '설정', status: '비공개', type: '설정형', access: '관리자' },
];
const updateChildren = (items: MenuItem[], parentId: number, newChildren: MenuItem[]): MenuItem[] =>
items.map((item) =>
item.id === parentId
? { ...item, children: newChildren }
: item.children
? { ...item, children: updateChildren(item.children, parentId, newChildren) }
: item,
);
export default function TableExpandGroupExample() {
const [data, setData] = useState(menuData);
const columns = useMemo(() => {
const col = cellColumnHelper<MenuItem>();
return [
col.dragHandle(),
col.expand(col.checkBox(), col.rowIndex({ ContentProps: { nested: true } }), col.text('name', '메뉴명')),
col.chip('status', '공개 상태', {
size: 90,
ContentProps: {
chipMap: {
공개중: { color: 'green', label: '공개중' },
비공개: { color: 'red', label: '비공개' },
},
type: 'fill',
},
}),
col.text('type', '유형', { size: 120 }),
col.text('access', '화면 접근 권한', { size: 120 }),
];
}, []);
return (
<Table<MenuItem>
data={data}
columns={columns}
tableOptions={{
getRowId: (row) => String(row.id),
getSubRows: (row) => row.children,
}}
onRowOrderChange={(newData, { parentRow }) => {
if (parentRow === null) {
setData(newData);
} else {
setData((prev) => updateChildren(prev, parentRow.id, newData));
}
}}
/>
);
}
행 드래그
dragHandle 컬럼으로 행 순서를 변경하는 예시다.
'use client';
import { useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableFeaturesDragHandleExample() {
const [hoverData, setHoverData] = useState(sampleData.slice(0, 5));
const [alwaysData, setAlwaysData] = useState(sampleData.slice(0, 5));
const col = cellColumnHelper<User>();
const hoverColumns = [
col.dragHandle({ ContentProps: { visibility: 'hover' } }),
col.rowIndex(),
col.checkBox(),
col.text('name', '이름', { size: 80 }),
col.text('email', '이메일', { CellProps: { truncate: 'end' } }),
];
const alwaysColumns = [
col.dragHandle({ ContentProps: { visibility: 'always' } }),
col.rowIndex(),
col.checkBox(),
col.text('name', '이름', { size: 80 }),
col.text('email', '이메일', { CellProps: { truncate: 'end' } }),
];
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '24px' }}>
<div>
<p style={{ fontSize: '13px', color: '#666', margin: '0 0 8px' }}>visibility: hover</p>
<Table
data={hoverData}
columns={hoverColumns}
tableOptions={{ getRowId: (row) => String(row.id) }}
onRowOrderChange={(newData) => setHoverData(newData)}
/>
</div>
<div>
<p style={{ fontSize: '13px', color: '#666', margin: '0 0 8px' }}>visibility: always</p>
<Table
data={alwaysData}
columns={alwaysColumns}
tableOptions={{ getRowId: (row) => String(row.id) }}
onRowOrderChange={(newData) => setAlwaysData(newData)}
/>
</div>
</div>
);
}
컬럼 리사이징
컬럼 경계를 드래그해 폭을 조절하는 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableFeaturesColumnResizingExample() {
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80, enableResizing: true }),
col.number('age', '나이', { size: 80, ContentProps: { trailingText: '세' } }),
col.text('email', '이메일', { enableResizing: true, CellProps: { truncate: 'end' } }),
col.text(
() =>
'이 문장은 테이블 UI에서 셀 너비가 고정되어 있을 때 정상적으로 overflow가 발생하는지 확인하기 위한 매우 매우 매우 긴 테스트 문자열입니다',
'UI가_깨지지_않는지_확인하기_위한_의미없이_매우_매우_매우_매우_긴_테스트용_긴_문자열_입니다.',
{ enableResizing: true, enableSorting: true, CellProps: { truncate: 'end' } },
),
col.chip('status', '상태', {
size: 80,
enableResizing: true,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
},
}),
];
return <Table data={sampleData.slice(0, 5)} columns={columns} />;
}
서버 정렬
서버 정렬 훅으로 정렬 상태를 연결하는 예시다.
'use client';
import { useMemo } from 'react';
import { cellColumnHelper, Table, useTableServerSorting } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableFeaturesServerSortingExample() {
const col = cellColumnHelper<User>();
const serverSorting = useTableServerSorting();
// 컬럼 id → 백엔드 enum 매핑 (실제로는 codegen된 enum 사용)
const SORT_FIELD_MAP: Record<string, string> = {
name: 'NAME',
age: 'AGE',
progress: 'PROGRESS',
};
// 서버 요청에 넘길 order 변환
const order = serverSorting.sorting[0]
? {
field: SORT_FIELD_MAP[serverSorting.sorting[0].id],
direction: serverSorting.sorting[0].desc ? 'DESC' : 'ASC',
}
: undefined;
// 서버 정렬 시뮬레이션: order에 따라 정렬된 데이터 반환
const sortedData = useMemo(() => {
const baseData: User[] = [
{ ...sampleData[0]!, age: 12, progress: 90 },
{ ...sampleData[1]!, age: 14, progress: 60 },
{ ...sampleData[2]!, age: 12, progress: 70 },
{ ...sampleData[3]!, age: 14, progress: 85 },
{ ...sampleData[4]!, age: 12, progress: 55 },
{ ...sampleData[5]!, age: 11, progress: 70 },
{ ...sampleData[6]!, age: 14, progress: 70 },
{ ...sampleData[7]!, age: 11, progress: 95 },
];
if (!serverSorting.sorting[0]) return baseData;
const sort = serverSorting.sorting[0];
const key = sort.id as keyof User;
return [...baseData].sort((a, b) => {
const aVal = a[key];
const bVal = b[key];
if (aVal === bVal) return 0;
const cmp = aVal! < bVal! ? -1 : 1;
return sort.desc ? -cmp : cmp;
});
}, [serverSorting.sorting]);
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80, enableSorting: true }),
col.number('age', '나이', { size: 100, enableSorting: true, ContentProps: { trailingText: '세' } }),
col.number('progress', '진도율', {
enableSorting: true,
enableResizing: true,
ContentProps: { trailingText: '%' },
}),
col.chip('status', '상태', {
size: 80,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
];
return (
<div>
<Table data={sortedData} columns={columns} serverSorting={serverSorting} />
<div style={{ marginTop: 16, display: 'flex', gap: 8, alignItems: 'center' }}>
<div
style={{
padding: '8px 12px',
background: order ? '#E8F4FD' : '#F5F5F5',
borderRadius: 6,
fontSize: 13,
color: order ? '#1976D2' : '#666',
}}
>
서버 요청:{' '}
{order
? `order: { field: ${order.field}, direction: ${order.direction} }`
: '정렬 없음 (서버 기본 정렬)'}
</div>
{order && (
<button
onClick={serverSorting.resetSorting}
style={{
padding: '6px 12px',
borderRadius: 6,
border: '1px solid #ccc',
background: '#fff',
cursor: 'pointer',
fontSize: 13,
}}
>
정렬 초기화
</button>
)}
</div>
</div>
);
}
헤더 툴팁
컬럼 헤더에 설명 툴팁을 추가하는 예시다.
코드
'use client';
import { useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableFeaturesHeaderTooltipExample() {
const [sorting, setSorting] = useState<SortingState>([]);
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80 }),
col.number('age', '나이', {
size: 120,
enableSorting: true,
ContentProps: { trailingText: '세' },
HeaderProps: { tooltip: '만 나이 기준입니다' },
}),
col.number('progress', '진도율', {
enableSorting: true,
ContentProps: { trailingText: '%' },
HeaderProps: { tooltip: '전체 학습 과정 대비 진도율 입니다' },
}),
col.text('email', '이메일', { CellProps: { truncate: 'end' } }),
col.chip('status', '상태', {
size: 80,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
];
return (
<div style={{ padding: '50px' }}>
<Table
data={sampleData.slice(0, 5)}
columns={columns}
tableOptions={{
onSortingChange: setSorting,
state: { sorting },
}}
/>
</div>
);
}
그룹 헤더
여러 컬럼을 그룹 헤더 아래에 묶는 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface StudentScore {
id: number;
name: string;
koreanMid: number;
koreanFinal: number;
mathMid: number;
mathFinal: number;
englishMid: number;
englishFinal: number;
average: number;
}
const scoreData: StudentScore[] = [
{
id: 1,
name: '김서윤',
koreanMid: 92,
koreanFinal: 88,
mathMid: 85,
mathFinal: 90,
englishMid: 78,
englishFinal: 82,
average: 85.8,
},
{
id: 2,
name: '이도현',
koreanMid: 76,
koreanFinal: 80,
mathMid: 95,
mathFinal: 92,
englishMid: 88,
englishFinal: 91,
average: 87.0,
},
{
id: 3,
name: '박하은',
koreanMid: 88,
koreanFinal: 91,
mathMid: 72,
mathFinal: 78,
englishMid: 95,
englishFinal: 93,
average: 86.2,
},
{
id: 4,
name: '정우진',
koreanMid: 65,
koreanFinal: 70,
mathMid: 80,
mathFinal: 85,
englishMid: 72,
englishFinal: 75,
average: 74.5,
},
{
id: 5,
name: '최예린',
koreanMid: 95,
koreanFinal: 97,
mathMid: 90,
mathFinal: 88,
englishMid: 92,
englishFinal: 96,
average: 93.0,
},
];
export default function TableFeaturesGroupedHeadersExample() {
const col = cellColumnHelper<StudentScore>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80, enableResizing: true }),
col.headerGroup('국어', [
col.number('koreanMid', '중간', {
enableSorting: true,
ContentProps: { trailingText: '점' },
HeaderProps: { tooltip: '중간고사 점수 (백분율)' },
}),
col.number('koreanFinal', '기말', {
enableSorting: true,
ContentProps: { trailingText: '점' },
HeaderProps: { tooltip: '기말고사 점수 (백분율)' },
}),
]),
col.headerGroup('수학', [
col.number('mathMid', '중간', {
enableSorting: true,
ContentProps: { trailingText: '점' },
}),
col.number('mathFinal', '기말', {
enableSorting: true,
ContentProps: { trailingText: '점' },
}),
]),
col.headerGroup('영어', [
col.number('englishMid', '중간', {
enableSorting: true,
ContentProps: { trailingText: '점' },
}),
col.number('englishFinal', '기말', {
enableSorting: true,
ContentProps: { trailingText: '점' },
}),
]),
col.headerGroup('영어2', [
col.number('englishMid', '중간', { id: 'english-mid2', ContentProps: { trailingText: '점' } }),
col.number('englishFinal', '기말', { id: 'english-final2', ContentProps: { trailingText: '점' } }),
]),
col.headerGroup('영어3', [
col.number('englishMid', '중간', { id: 'english-mid3', ContentProps: { trailingText: '점' } }),
col.number('englishFinal', '기말', { id: 'english-final3', ContentProps: { trailingText: '점' } }),
]),
col.number('average', '평균', {
size: 80,
enableSorting: true,
HeaderProps: { tooltip: '전 과목 평균' },
}),
];
return <Table data={scoreData} columns={columns} tableOptions={{ enableMultiSort: true }} />;
}
컬럼 고정
컬럼을 좌우에 고정해 스크롤하는 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableFeaturesColumnPinningExample() {
const col = cellColumnHelper<User>();
const columns = [
col.text('name', '이름', { size: 100 }),
col.number('age', '나이', { size: 80, ContentProps: { trailingText: '세' } }),
col.phone('phone', '전화번호', { size: 140, ContentProps: { countryCode: 'KR' } }),
col.text('email', '이메일', { size: 300, CellProps: { truncate: 'end' } }),
col.number('progress', '진도율', { size: 120, ContentProps: { trailingText: '%' } }),
col.date('createdAt', '등록일', { size: 120 }),
col.chip('status', '상태', {
size: 100,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
col.chip('tags', '수강 과목', {
size: 200,
ContentProps: {
chipMap: {
국어: { color: 'blue', label: '국어' },
영어: { color: 'green', label: '영어' },
수학: { color: 'red', label: '수학' },
},
},
}),
col.avatar('assignee', '선생님', { size: 80 }),
];
return (
<div style={{ maxWidth: 1100 }}>
<Table
data={sampleData.slice(0, 10)}
columns={columns}
tableOptions={{
getRowId: (row) => String(row.id),
state: {
columnPinning: {
left: ['name', 'age'],
right: ['assignee'],
},
},
}}
/>
</div>
);
}
페이지네이션 무한 스크롤
페이지를 이어 붙이며 스크롤하는 예시다.
'use client';
import { useCallback, useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TablePaginationInfiniteScrollExample() {
const PAGE_SIZE = 5;
const [data, setData] = useState(() => sampleData.slice(0, PAGE_SIZE));
const [isLoading, setIsLoading] = useState(false);
const hasMore = data.length < sampleData.length;
const loadMore = useCallback(() => {
setIsLoading(true);
// 네트워크 지연 시뮬레이션
setTimeout(() => {
setData((prev) => sampleData.slice(0, prev.length + PAGE_SIZE));
setIsLoading(false);
}, 500);
}, []);
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80 }),
col.number('age', '나이', { size: 80, ContentProps: { trailingText: '세' } }),
col.text('email', '이메일', { CellProps: { truncate: 'end' } }),
col.chip('status', '상태', {
size: 80,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
},
}),
];
return (
<div style={{ height: 'calc(100vh - 60px)' }}>
<Table data={data} columns={columns} infiniteScroll={{ onReachEnd: loadMore, hasMore, isLoading }} />
</div>
);
}
페이지 로딩
페이지 이동 중 로딩 상태를 표시하는 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TablePaginationLoadingExample() {
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80 }),
col.text('email', '이메일', { CellProps: { truncate: 'end' } }),
];
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<div>
<p style={{ fontSize: '13px', color: '#666', margin: '0 0 8px' }}>초기 로딩</p>
<Table data={[]} columns={columns} loading={true} emptyRowMessage="데이터가 없습니다." />
</div>
<div>
<p style={{ fontSize: '13px', color: '#666', margin: '0 0 8px' }}>리프레시 로딩</p>
<Table data={sampleData.slice(0, 3)} columns={columns} loading={true} emptyRowMessage="데이터가 없습니다." />
</div>
<div>
<p style={{ fontSize: '13px', color: '#666', margin: '0 0 8px' }}>빈 상태</p>
<Table data={[]} columns={columns} loading={false} emptyRowMessage="데이터가 없습니다." />
</div>
</div>
);
}
행 가상화
많은 행을 가상화해 렌더링하는 예시다.
'use client';
import { useMemo, useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
import { Button } from '@mildang/design-system/Button';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const generateLargeData = (count: number): User[] =>
Array.from({ length: count }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + (i % 365));
return {
id: i + 1,
name,
email: `user${i + 1}@example.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TablePaginationVirtualizationExample() {
const [rowCount, setRowCount] = useState(5000);
const data = useMemo(() => generateLargeData(rowCount), [rowCount]);
const col = cellColumnHelper<User>();
const columns = useMemo(
() => [
col.rowIndex(),
col.text('name', '이름', { size: 120 }),
col.text('email', '이메일', { size: 250 }),
col.phone('phone', '전화번호', {
size: 140,
ContentProps: { countryCode: 'KR', format: 'international' },
}),
col.chip('status', '상태', {
size: 100,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
},
}),
col.text('age', '나이', { size: 80 }),
col.number('progress', '진행률', { size: 100 }),
],
[],
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<span>행 개수:</span>
{[100, 1000, 5000, 10000].map((count) => (
<Button
key={count}
size="sm"
variant={rowCount === count ? 'primary' : 'tertiary'}
onClick={() => setRowCount(count)}
>
{count.toLocaleString()}
</Button>
))}
</div>
<div style={{ height: 500, border: '1px solid #ddd', borderRadius: 8 }}>
<Table<User> data={data} columns={columns} virtualization={{ rowHeight: 44, overscan: 5 }} />
</div>
</div>
);
}
가상화·무한 스크롤
행 가상화와 무한 스크롤을 조합한 예시다.
'use client';
import { useCallback, useMemo, useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const generateLargeData = (count: number): User[] =>
Array.from({ length: count }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + (i % 365));
return {
id: i + 1,
name,
email: `user${i + 1}@example.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TablePaginationVirtualizationWithInfiniteScrollExample() {
const [data, setData] = useState(() => generateLargeData(100));
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const loadMore = useCallback(() => {
if (isLoading || !hasMore) return;
setIsLoading(true);
setTimeout(() => {
setData((prev) => {
const newData = [
...prev,
...generateLargeData(100).map((item, i) => ({ ...item, id: prev.length + i + 1 })),
];
if (newData.length >= 1000) {
setHasMore(false);
}
return newData;
});
setIsLoading(false);
}, 500);
}, [isLoading, hasMore]);
const col = cellColumnHelper<User>();
const columns = useMemo(
() => [
col.rowIndex(),
col.text('name', '이름', { size: 120 }),
col.text('email', '이메일', { size: 250 }),
col.chip('status', '상태', {
size: 100,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
},
}),
col.text('age', '나이', { size: 80 }),
],
[],
);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
로드된 행: {data.length} / 1000 {!hasMore && '(완료)'}
</div>
<div style={{ height: 500, border: '1px solid #ddd', borderRadius: 8 }}>
<Table<User>
data={data}
columns={columns}
virtualization={{ rowHeight: 44, overscan: 5 }}
infiniteScroll={{
onReachEnd: loadMore,
hasMore,
isLoading,
}}
/>
</div>
</div>
);
}
셀 스타일 속성
CellProps로 셀의 표시 스타일을 지정하는 예시다.
'use client';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
interface TableStylingCellPropsExampleProps {
cellAlign?: 'left' | 'center' | 'right';
showRightLine?: boolean;
}
export default function TableStylingCellPropsExample({
cellAlign: align = 'left',
showRightLine = true,
}: TableStylingCellPropsExampleProps) {
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex(),
col.checkBox(),
// CellProps — 컬럼 전체에 정적 스타일
col.text('name', '이름', { enableResizing: true, size: 80, CellProps: { align, showRightLine } }),
col.text('email', '이메일 (CellProps)', {
enableResizing: true,
CellProps: { align, showRightLine, truncate: 'end' },
}),
// getCellProps — row 데이터에 따라 동적 스타일
col.text('status', '상태 (getCellProps)', {
getCellProps: (context) => {
const status = context.row.original.status;
return status === '비활성'
? { backgroundColor: 'critical.ghostBg.base', color: 'critical.text.base' }
: {};
},
}),
];
return <Table data={sampleData.slice(0, 5)} columns={columns} />;
}
행 스타일 속성
RowProps로 행별 표시 스타일을 지정하는 예시다.
'use client';
import { useState } from 'react';
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
export default function TableStylingRowPropsExample() {
const [activeId, setActiveId] = useState<number | null>(null);
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호'),
col.text('name', '이름', { size: 80 }),
col.text('email', '이메일', { CellProps: { truncate: 'end' } }),
col.text('status', '상태', { size: 80 }),
];
return (
<Table
data={sampleData.slice(0, 5)}
columns={columns}
getRowProps={(row) => ({
isActive: row.original.id === activeId,
isSelected: row.original.status === '비활성',
onClick: () => setActiveId(row.original.id),
style: { cursor: 'pointer' },
})}
/>
);
}
선 없는 표
행 구분선을 제거한 테이블 예시다.
import { cellColumnHelper, Table } from '@mildang/design-system/Table';
interface User {
id: number;
name: string;
email: string;
phone: string;
tags: string[];
status: string;
age: number;
progress: number;
createdAt: string;
assignee: { initial: string; src?: string };
team: { initial: string; src?: string }[];
}
const names = [
'김서윤',
'이도현',
'박하은',
'정우진',
'최예린',
'강민준',
'윤서아',
'한지호',
'오수빈',
'임시우',
'송채원',
];
const avatar_src = [
'https://picsum.photos/seed/avatar1/150/150',
undefined,
'https://picsum.photos/seed/avatar3/150/150',
undefined,
'https://picsum.photos/seed/avatar5/150/150',
'https://picsum.photos/seed/avatar6/150/150',
undefined,
'https://picsum.photos/seed/avatar8/150/150',
undefined,
'https://picsum.photos/seed/avatar10/150/150',
'https://picsum.photos/seed/avatar11/150/150',
];
const statuses = ['활성', '비활성'];
const phones = [
'01012345678',
'01098765432',
'01055551234',
'01011112222',
'01033334444',
'01066667777',
'01077778888',
'01088889999',
'01099990000',
'01022223333',
'01044445555',
];
const sampleData: User[] = Array.from({ length: 30 }, (_, i) => {
const name = names[i % names.length]!;
const src = avatar_src[i % avatar_src.length];
const teamSize = (i % 4) + 1;
const date = new Date(2025, 0, 1 + i);
return {
id: i + 1,
name,
email: `mildangUser${i + 1}@ihateflyingbugs.com`,
phone: phones[i % phones.length]!,
tags: ['국어', '영어', '수학'].slice(0, (i % 3) + 1),
status: statuses[i % statuses.length]!,
age: 10 + (i % 10),
progress: 50 + (i % 50),
createdAt: date.toISOString(),
assignee: { initial: name, src },
team: Array.from({ length: teamSize }, (__, j) => ({
initial: names[(i + j) % names.length]!,
src: avatar_src[(i + j) % avatar_src.length],
})),
};
});
const noLineProps = {
HeaderProps: { showTopLine: false, showBottomLine: false, showRightLine: false },
CellProps: { showRightLine: false },
} as const;
export default function TableStylingNoLinesExample() {
const col = cellColumnHelper<User>();
const columns = [
col.rowIndex('번호', noLineProps),
col.text('name', '이름', { size: 80, ...noLineProps }),
col.number('age', '나이', { size: 80, ContentProps: { trailingText: '세' }, ...noLineProps }),
col.text('email', '이메일', {
CellProps: { ...noLineProps.CellProps, truncate: 'end' },
HeaderProps: noLineProps.HeaderProps,
}),
col.chip('status', '상태', {
size: 80,
...noLineProps,
ContentProps: {
chipMap: {
활성: { color: 'green', label: '활성' },
비활성: { color: 'red', label: '비활성' },
},
type: 'fill',
},
}),
];
return (
<Table
data={sampleData.slice(0, 5)}
columns={columns}
getRowProps={() => ({ showBottomLine: false })}
/>
);
}