Table
데이터의 구조화된 표현을 제공하는 데 사용됩니다.
이 문서는 대량의 데이터를 다루는 Data Table의 구현 예시를 안내합니다.
Data Table은 데이터를 효율적으로 시각화하고 관리하는 강력한 도구이지만, 내부적으로 복잡한 상태 관리와 로직을 필요로 합니다. 이러한 기능을 컴포넌트 내부에 강결합하면 유지보수가 어렵고 확장성이 떨어지는 문제가 발생할 수 있습니다.
이에 따라 Vapor UI의 Table 컴포넌트는 순수한 뷰(View) 역할에 집중하여 기본적인 구조와 스타일을 제공합니다. 이 문서에서는 @tanstack/react-table과 같은 Headless UI 라이브러리를 활용하여 다양한 기능을 유연하게 구현하는 방법을 예시로 제공합니다.
Basic
import { Badge, Table } from '@vapor-ui/core';
const datas = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const activeness: Record<string, Badge.Props['colorPalette']> = {
active: 'success',
inactive: 'hint',
};
export default function Basic() {
return (
<Table.Root width="100%">
<Table.Header>
<Table.Row backgroundColor="$gray-050">
<Table.Heading>Name</Table.Heading>
<Table.Heading>Status</Table.Heading>
<Table.Heading>Role</Table.Heading>
<Table.Heading>Last Active</Table.Heading>
</Table.Row>
</Table.Header>
<Table.Body>
{datas.map((data, index) => (
<Table.Row key={index}>
<Table.Cell>{data.name}</Table.Cell>
<Table.Cell>
<Badge colorPalette={activeness[data.status]} shape="pill">
{data.status.toUpperCase()}
</Badge>
</Table.Cell>
<Table.Cell>{data.role}</Table.Cell>
<Table.Cell>{data['last-active']}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
);
}Checkbox
import { useMemo, useState } from 'react';
import { type ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Badge, Card, Checkbox, Table } from '@vapor-ui/core';
export default function Basic() {
const [rowSelection, setRowSelection] = useState({});
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<Checkbox.Root
aria-label="Select all"
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onCheckedChange={(value) => table.toggleAllRowsSelected(value)}
style={{ justifySelf: 'center' }}
/>
),
cell: ({ row }) => (
<Checkbox.Root
aria-label="Select row"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
indeterminate={row.getIsSomeSelected()}
onCheckedChange={(value) => row.toggleSelected(value)}
style={{ justifySelf: 'center' }}
/>
),
},
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ getValue }) => {
const status = getValue<string>();
return (
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
);
},
},
{
header: 'Role',
accessorKey: 'role',
},
{
header: 'Last Active',
accessorKey: 'last-active',
},
],
[],
);
const table = useReactTable({
data: datas,
columns,
state: { rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
});
return (
<Card.Root width="100%">
<Card.Body padding="$000">
<Table.Root width="100%">
<Table.ColumnGroup>
<Table.Column width="10%" />
</Table.ColumnGroup>
<Table.Header backgroundColor="$gray-050">
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Heading key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => {
return (
<Table.Row
key={row.id}
backgroundColor={
row.getIsSelected() ? '$primary-100' : 'inherit'
}
>
{row.getVisibleCells().map((cell) => {
return (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
);
})}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
</Card.Body>
</Card.Root>
);
}
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};Ordering
import { useMemo, useState } from 'react';
import { type ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Badge, Box, Card, Table } from '@vapor-ui/core';
export default function Ordering() {
const [rowSelection, setRowSelection] = useState({});
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
id: 'select',
header: () => <Box textAlign="center">ID</Box>,
cell: ({ row }) => <Box textAlign="center">{row.index + 1}</Box>,
},
{
header: 'Name',
accessorKey: 'name',
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ getValue }) => {
const status = getValue<string>();
return (
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
);
},
},
{
header: 'Role',
accessorKey: 'role',
},
{
header: 'Last Active',
accessorKey: 'last-active',
},
],
[],
);
const table = useReactTable({
data: datas,
columns,
state: { rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
});
return (
<Card.Root width="100%">
<Card.Body padding="$000">
<Table.Root width="100%">
<Table.ColumnGroup>
<Table.Column width="10%" />
</Table.ColumnGroup>
<Table.Header backgroundColor="$gray-050">
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Heading key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => {
return (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
</Card.Body>
</Card.Root>
);
}
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};Sticky
import type { CSSProperties } from 'react';
import { useMemo, useState } from 'react';
import type { Column, Table as TanstackTable } from '@tanstack/react-table';
import { type ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Badge, Box, Card, Table } from '@vapor-ui/core';
export default function Basic() {
const [rowSelection, setRowSelection] = useState({});
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
header: () => <Box textAlign="center">ID</Box>,
accessorKey: 'id',
cell: ({ row }) => <Box textAlign="center">{row.index + 1}</Box>,
},
{
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => <Box style={{ textWrap: 'nowrap' }}>{row.getValue('name')}</Box>,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.getValue<string>('status');
return (
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
);
},
},
{
header: 'Role',
accessorKey: 'role',
},
{
header: 'Last Active',
accessorKey: 'last-active',
},
],
[],
);
const table = useReactTable({
data: datas,
columns,
state: { rowSelection, columnPinning: { left: ['id', 'name'] } },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
});
return (
<Card.Root width="100%">
<Card.Body overflow="auto" padding="$000">
<Table.Root width="200%">
<Table.ColumnGroup>
<Table.Column width="0" />
<Table.Column width="0" />
<Table.Column width="0" />
</Table.ColumnGroup>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Heading
key={header.id}
ref={(thElem) =>
columnSizingHandler(thElem, table, header.column)
}
backgroundColor="$gray-050"
style={{ ...getCommonPinningStyles(header.column) }}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => {
return (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell
key={cell.id}
style={{ ...getCommonPinningStyles(cell.column) }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
</Card.Body>
</Card.Root>
);
}
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const getCommonPinningStyles = (column: Column<Data>): CSSProperties => {
const isPinned = column.getIsPinned();
const lastPinnedColumn = isPinned === 'left' && column.getIsLastColumn('left');
return {
boxShadow: lastPinnedColumn ? '-3px 0 0 0 rgba(0, 0, 0, 0.06) inset' : undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
position: isPinned ? 'sticky' : 'unset',
zIndex: isPinned ? 1 : undefined,
};
};
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};
const columnSizingHandler = (
thElem: HTMLTableCellElement | null,
table: TanstackTable<Data>,
column: Column<Data>,
) => {
if (!thElem) return;
if (table.getState().columnSizing[column.id] !== undefined) return;
if (table.getState().columnSizing[column.id] === thElem.getBoundingClientRect().width) return;
table.setColumnSizing((prevSizes) => ({
...prevSizes,
[column.id]: thElem.getBoundingClientRect().width,
}));
};Collapsed
import type { CSSProperties } from 'react';
import { useMemo, useState } from 'react';
import { makeStateUpdater } from '@tanstack/react-table';
import type {
Column,
ColumnDef,
OnChangeFn,
RowData,
TableFeature,
Table as TanstackTable,
Updater,
} from '@tanstack/react-table';
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Badge, Box, Card, HStack, IconButton, Table } from '@vapor-ui/core';
import { ChevronDoubleLeftOutlineIcon, ChevronDoubleRightOutlineIcon } from '@vapor-ui/icons';
export default function Collapsed() {
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
header: () => <Box textAlign="center">ID</Box>,
accessorKey: 'id',
cell: ({ row }) => <Box textAlign="center">{row.index + 1}</Box>,
},
{
header: ({ column }) => {
const isCollapsed = column.getIsCollapsed();
const IconElement = isCollapsed
? ChevronDoubleRightOutlineIcon
: ChevronDoubleLeftOutlineIcon;
return (
<HStack justifyContent="space-between" alignItems="center">
{isCollapsed ? '' : 'Name'}
<IconButton
aria-label="Toggle Name column"
size="sm"
color="secondary"
variant="ghost"
onClick={() => column.toggleCollapsed()}
>
<IconElement />
</IconButton>
</HStack>
);
},
accessorKey: 'name',
cell: ({ row, column }) => {
const isCollapsed = column.getIsCollapsed();
return (
<Box
display={isCollapsed ? 'block' : 'flex'}
width={isCollapsed ? '32px' : '240px'}
overflow="hidden"
style={{
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
wordBreak: 'break-all',
}}
>
{row.getValue('name')}
</Box>
);
},
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.getValue<string>('status');
return (
<Box>
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
</Box>
);
},
},
{
header: () => <Box>Role</Box>,
accessorKey: 'role',
},
{
header: () => <Box>Last Active</Box>,
accessorKey: 'last-active',
},
],
[],
);
const [columnCollapsed, setColumnCollapsed] = useState<ColumnCollapsedState>(['name']); // 초기에 접힐 컬럼들
const table = useReactTable({
_features: [ColumnCollapsedFeature],
data: datas,
columns,
state: {
columnPinning: { left: ['id', 'name'] },
columnCollapsed,
},
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onColumnCollapsedChange: setColumnCollapsed,
});
return (
<Card.Root width="100%">
<Card.Body overflow="auto" padding="$000">
<Table.Root width="100%">
<Table.ColumnGroup>
<Table.Column width="10%" />
<Table.Column width="10%" />
</Table.ColumnGroup>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Heading
key={header.id}
ref={(thElem) =>
columnSizingHandler(thElem, table, header.column)
}
backgroundColor="$gray-050"
style={{ ...getCommonPinningStyles(header.column) }}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => {
return (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell
key={cell.id}
style={{ ...getCommonPinningStyles(cell.column) }}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
</Card.Body>
</Card.Root>
);
}
/* -----------------------------------------------------------------------------------------------*/
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const getCommonPinningStyles = (column: Column<Data>): CSSProperties => {
const isPinned = column.getIsPinned();
const lastPinnedColumn = isPinned === 'left' && column.getIsLastColumn('left');
return {
boxShadow: lastPinnedColumn ? '-3px 0 0 0 rgba(0, 0, 0, 0.06) inset' : undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
position: isPinned ? 'sticky' : 'unset',
zIndex: isPinned ? 1 : undefined,
};
};
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};
const columnSizingHandler = (
thElem: HTMLTableCellElement | null,
table: TanstackTable<Data>,
column: Column<Data>,
) => {
if (!thElem) return;
const currentSize = table.getState().columnSizing[column.id];
const elementWidth = thElem.getBoundingClientRect().width;
if (currentSize === elementWidth) return;
table.setColumnSizing((prevSizes) => ({
...prevSizes,
[column.id]: elementWidth,
}));
};
/* -----------------------------------------------------------------------------------------------*/
export type ColumnCollapsedState = string[];
export interface ColumnCollapsedTableState {
columnCollapsed: ColumnCollapsedState;
}
export interface ColumnCollapsedOptions {
enableColumnCollapsed?: boolean;
onColumnCollapsedChange?: OnChangeFn<ColumnCollapsedState>;
}
export interface ColumnCollapsedColumnInstance {
getIsCollapsed: () => boolean;
toggleCollapsed: () => void;
}
declare module '@tanstack/react-table' {
interface TableState extends ColumnCollapsedTableState {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface TableOptionsResolved<TData extends RowData> extends ColumnCollapsedOptions {}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Column<TData extends RowData, TValue = unknown>
extends ColumnCollapsedColumnInstance {}
}
export const ColumnCollapsedFeature: TableFeature<unknown> = {
getInitialState: (state): ColumnCollapsedTableState => {
return {
columnCollapsed: [],
...state,
};
},
getDefaultOptions: <TData extends RowData>(
table: TanstackTable<TData>,
): ColumnCollapsedOptions => {
return {
enableColumnCollapsed: true,
onColumnCollapsedChange: makeStateUpdater('columnCollapsed', table),
};
},
createColumn: <TData extends RowData>(
column: Column<TData, unknown>,
table: TanstackTable<TData>,
): void => {
column.getIsCollapsed = () => {
return table.getState().columnCollapsed?.includes(column.id) ?? false;
};
column.toggleCollapsed = () => {
const currentState = column.getIsCollapsed();
const updater: Updater<ColumnCollapsedState> = (old) => {
if (currentState) return old.filter((id) => id !== column.id);
return [...old, column.id];
};
table.options.onColumnCollapsedChange?.(updater);
};
},
};Sort
import { useMemo, useState } from 'react';
import type { SortingState } from '@tanstack/react-table';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Badge, Box, Card, HStack, IconButton, Table } from '@vapor-ui/core';
import { ControlCommonIcon } from '@vapor-ui/icons';
export default function Sort() {
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
id: 'index',
header: 'ID',
accessorFn: (_, index) => index + 1,
cell: ({ getValue }) => <Box textAlign="center">{String(getValue() ?? '.')}</Box>,
},
{
header: 'Name',
accessorKey: 'name',
cell: (info) => info.getValue(),
},
{
accessorKey: 'status',
cell: ({ getValue }) => {
const status = getValue<string>();
return (
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
);
},
},
{
accessorKey: 'role',
cell: (info) => info.getValue(),
},
{
accessorKey: 'last-active',
cell: (info) => info.getValue(),
sortingFn: 'datetime',
},
],
[],
);
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data: datas,
columns,
state: { sorting },
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
onSortingChange: setSorting,
});
return (
<Card.Root width="100%">
<Card.Body padding="$000">
<Table.Root width="100%">
<Table.ColumnGroup>
<Table.Column width="10%" />
</Table.ColumnGroup>
<Table.Header borderRadius="inherit">
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id} backgroundColor="$gray-050">
{headerGroup.headers.map((header) => (
<Table.Heading key={header.id}>
<HStack
justifyContent={
header.id === 'index' ? 'center' : 'flex-start'
}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
<IconButton
aria-label={`${header.id} sort`}
color="secondary"
variant="ghost"
size="sm"
onClick={header.column.getToggleSortingHandler()}
>
<ControlCommonIcon />
</IconButton>
</HStack>
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => {
return (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => {
return (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
);
})}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
</Card.Body>
</Card.Root>
);
}
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};Scroll
import { useMemo, useState } from 'react';
import { type ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Badge, Box, Card, Table } from '@vapor-ui/core';
export default function Scroll() {
const [rowSelection, setRowSelection] = useState({});
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
accessorKey: 'id',
header: () => <Box textAlign="center">ID</Box>,
cell: ({ row }) => <Box textAlign="center">{row.index + 1}</Box>,
},
{
header: 'Name',
accessorKey: 'name',
cell: ({ row }) => <Box style={{ textWrap: 'nowrap' }}>{row.getValue('name')}</Box>,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.getValue<string>('status');
return (
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
);
},
},
{
header: () => 'Role',
accessorKey: 'role',
},
{
header: () => 'Last Active',
accessorKey: 'last-active',
},
],
[],
);
const table = useReactTable({
data: datas,
columns,
state: { rowSelection, columnPinning: { left: ['id', 'name'] } },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
});
return (
<Card.Root width="100%">
<Card.Body overflow="auto" padding="$000">
<Table.Root width="200%">
<Table.ColumnGroup>
<Table.Column width="5%" />
</Table.ColumnGroup>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id} backgroundColor="$gray-050">
{headerGroup.headers.map((header) => (
<Table.Heading key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.map((row) => {
return (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
))}
</Table.Row>
);
})}
</Table.Body>
</Table.Root>
</Card.Body>
</Card.Root>
);
}
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};Filter
import { useMemo, useState } from 'react';
import type { ColumnFiltersState, Row } from '@tanstack/react-table';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
Badge,
Box,
Button,
Card,
HStack,
MultiSelect,
Select,
Table,
Text,
TextInput,
} from '@vapor-ui/core';
import { PlusOutlineIcon, SearchOutlineIcon } from '@vapor-ui/icons';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const customFilterFn = (row: Row<Data>, columnId: string, filterValue: any) => {
if (!filterValue || filterValue.length === 0) return true;
const cellValue = row.getValue(columnId) as string;
return filterValue.includes(cellValue);
};
export default function Scroll() {
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
header: () => <Box textAlign="center"> ID</Box>,
accessorKey: 'id',
size: 0, // prevent cumulative layout shift
cell: ({ row }) => <Box textAlign="center">{row.index + 1}</Box>,
},
{
header: 'Name',
accessorKey: 'name',
size: 0, // prevent cumulative layout shift
cell: ({ row }) => <div style={{ textWrap: 'nowrap' }}>{row.getValue('name')}</div>,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.getValue<string>('status');
return (
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
);
},
filterFn: customFilterFn,
},
{
header: 'Role',
accessorKey: 'role',
filterFn: customFilterFn,
},
{
header: 'Last Active',
accessorKey: 'last-active',
},
],
[],
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data: datas,
columns,
state: { columnFilters },
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onColumnFiltersChange: setColumnFilters,
});
return (
<Card.Root width="100%">
<Card.Header>
<HStack justifyContent="space-between" alignItems="center">
<Text typography="heading6" foreground="normal-200" style={{ flexShrink: 0 }}>
출석부
</Text>
<HStack alignItems="center" gap="$100">
<HStack
alignItems="center"
gap="10px"
paddingX="$150"
border="1px solid"
borderColor="$normal"
borderRadius="$300"
>
<SearchOutlineIcon />
<TextInput
placeholder="이름으로 검색"
border="none"
paddingX="$000"
onValueChange={(value) =>
table.getColumn('name')?.setFilterValue(value)
}
/>
</HStack>
<FilterSelect
triggerLabel="Status"
onValueChange={(value) => {
table.getColumn('status')?.setFilterValue(value);
}}
content={
<>
<MultiSelect.Item value="active">Active</MultiSelect.Item>
<MultiSelect.Item value="inactive">Inactive</MultiSelect.Item>
</>
}
/>
<FilterSelect
triggerLabel="Role"
onValueChange={(value) =>
table.getColumn('role')?.setFilterValue(value)
}
content={
<>
<MultiSelect.Item value="designer">Designer</MultiSelect.Item>
<MultiSelect.Item value="developer">Developer</MultiSelect.Item>
</>
}
/>
<FilterSelect
triggerLabel="Columns"
value={table
.getAllColumns()
.filter((col) => col.getIsVisible())
.map((col) => col.id)}
content={table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<MultiSelect.Item
key={column.id}
value={column.id}
onClick={() => column.toggleVisibility()}
>
{column.id}
</MultiSelect.Item>
))}
/>
<Button>
<PlusOutlineIcon size="16px" /> 추가
</Button>
</HStack>
</HStack>
</Card.Header>
<Card.Body style={{ overflow: 'auto', padding: 0 }}>
<Table.Root style={{ width: '100%' }}>
<Table.ColumnGroup>
<Table.Column width="10%" />
</Table.ColumnGroup>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<Table.Heading key={header.id} backgroundColor="$gray-050">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => {
return (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
))}
</Table.Row>
);
})
) : (
<Table.Row>
<Table.Cell
colSpan={columns.length}
textAlign="center"
height="410px"
>
검색 결과가 없습니다.
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>
<Card.Footer display="flex" justifyContent="flex-end">
<Select.Root
value={table.getState().pagination.pageSize}
onValueChange={(value) => table.setPageSize(Number(value))}
>
<Select.TriggerPrimitive>
<Select.ValuePrimitive>
{(value) => `${value}개씩 보기`}
</Select.ValuePrimitive>
<Select.TriggerIconPrimitive />
</Select.TriggerPrimitive>
<Select.Popup>
{[5, 10, 20, 30, 40, 50].map((pageSize) => (
<Select.Item key={pageSize} value={pageSize}>
{pageSize}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</Card.Footer>
</Card.Body>
</Card.Root>
);
}
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};
/* -----------------------------------------------------------------------------------------------*/
interface FilterSelectProps extends React.ComponentProps<typeof MultiSelect.Root> {
triggerLabel: string;
content: React.ReactNode;
}
const FilterSelect = ({ content, triggerLabel, ...props }: FilterSelectProps) => {
return (
<MultiSelect.Root {...props}>
<MultiSelect.TriggerPrimitive
render={<Button variant="fill" color="secondary" />}
style={{ width: 'unset' }}
>
{triggerLabel}
<MultiSelect.TriggerIconPrimitive />
</MultiSelect.TriggerPrimitive>
<MultiSelect.Popup positionerElement={<MultiSelect.PositionerPrimitive align="end" />}>
{content}
</MultiSelect.Popup>
</MultiSelect.Root>
);
};Pagination
import { useMemo, useState } from 'react';
import type { ColumnFiltersState, Row } from '@tanstack/react-table';
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table';
import {
Badge,
Button,
Card,
HStack,
MultiSelect,
Pagination,
Select,
Table,
Text,
TextInput,
} from '@vapor-ui/core';
import { PlusOutlineIcon, SearchOutlineIcon } from '@vapor-ui/icons';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const customFilterFn = (row: Row<Data>, columnId: string, filterValue: any) => {
if (!filterValue || filterValue.length === 0) return true;
const cellValue = row.getValue(columnId) as string;
return filterValue.includes(cellValue);
};
export default function WithPagination() {
const columns = useMemo<ColumnDef<Data>[]>(
() => [
{
header: () => <div style={{ textAlign: 'center' }}>ID</div>,
accessorKey: 'id',
size: 0, // prevent cumulative layout shift
cell: ({ row }) => <div style={{ textAlign: 'center' }}>{row.index + 1}</div>,
},
{
header: 'Name',
accessorKey: 'name',
size: 0, // prevent cumulative layout shift
cell: ({ row }) => <div style={{ textWrap: 'nowrap' }}>{row.getValue('name')}</div>,
},
{
header: 'Status',
accessorKey: 'status',
cell: ({ row }) => {
const status = row.getValue<string>('status');
return (
<Badge color={activeness[status]} shape="pill">
{status.toUpperCase()}
</Badge>
);
},
filterFn: customFilterFn,
},
{
header: 'Role',
accessorKey: 'role',
filterFn: customFilterFn,
},
{
header: 'Last Active',
accessorKey: 'last-active',
},
],
[],
);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data: datas,
columns,
state: { columnFilters },
enableRowSelection: true,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onColumnFiltersChange: setColumnFilters,
});
return (
<Card.Root style={{ width: '100%' }}>
<Card.Header>
<HStack justifyContent="space-between" alignItems="center">
<Text typography="heading6" foreground="normal-200" style={{ flexShrink: 0 }}>
출석부
</Text>
<HStack alignItems="center" gap="$100">
<HStack
alignItems="center"
gap="10px"
paddingX="$150"
border="1px solid var(--vapor-color-border-normal)"
borderRadius="$300"
>
<SearchOutlineIcon />
<TextInput
placeholder="이름으로 검색"
style={{ border: 'none', paddingInline: 0 }}
onValueChange={(value) =>
table.getColumn('name')?.setFilterValue(value)
}
/>
</HStack>
<FilterSelect
triggerLabel="Status"
onValueChange={(value) => {
table.getColumn('status')?.setFilterValue(value);
}}
content={
<>
<MultiSelect.Item value="active">Active</MultiSelect.Item>
<MultiSelect.Item value="inactive">Inactive</MultiSelect.Item>
</>
}
/>
<FilterSelect
triggerLabel="Role"
onValueChange={(value) =>
table.getColumn('role')?.setFilterValue(value)
}
content={
<>
<MultiSelect.Item value="designer">Designer</MultiSelect.Item>
<MultiSelect.Item value="developer">Developer</MultiSelect.Item>
</>
}
/>
<FilterSelect
triggerLabel="Columns"
value={table
.getAllColumns()
.filter((col) => col.getIsVisible())
.map((col) => col.id)}
content={table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<MultiSelect.Item
key={column.id}
value={column.id}
onClick={() => column.toggleVisibility()}
>
{column.id}
</MultiSelect.Item>
))}
/>
<Button>
<PlusOutlineIcon size="16px" /> 추가
</Button>
</HStack>
</HStack>
</Card.Header>
<Card.Body style={{ overflow: 'auto', padding: 0 }}>
<Table.Root style={{ width: '100%' }}>
<Table.ColumnGroup>
<Table.Column width="10%" />
</Table.ColumnGroup>
<Table.Header>
{table.getHeaderGroups().map((headerGroup) => (
<Table.Row key={headerGroup.id} backgroundColor="$gray-050">
{headerGroup.headers.map((header) => (
<Table.Heading key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</Table.Heading>
))}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => {
return (
<Table.Row key={row.id}>
{row.getVisibleCells().map((cell) => (
<Table.Cell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</Table.Cell>
))}
</Table.Row>
);
})
) : (
<Table.Row>
<Table.Cell
colSpan={columns.length}
style={{ textAlign: 'center', height: 410 }}
>
검색 결과가 없습니다.
</Table.Cell>
</Table.Row>
)}
</Table.Body>
</Table.Root>
<Card.Footer position="relative" display="flex" justifyContent="center">
<Pagination.Root
totalPages={table.getPageCount()}
page={table.getState().pagination.pageIndex + 1}
onPageChange={(page) => table.setPageIndex(page - 1)}
>
<Pagination.Previous />
<Pagination.Items />
<Pagination.Next />
</Pagination.Root>
<Select.Root
value={table.getState().pagination.pageSize}
onValueChange={(value) => table.setPageSize(Number(value))}
>
<Select.TriggerPrimitive
position="absolute"
style={{ right: 24, top: '50%', transform: 'translateY(-50%)' }}
>
<Select.ValuePrimitive>
{(value) => `${value}개씩 보기`}
</Select.ValuePrimitive>
<Select.TriggerIconPrimitive />
</Select.TriggerPrimitive>
<Select.Popup>
{[5, 10, 20, 30, 40, 50].map((pageSize) => (
<Select.Item key={pageSize} value={pageSize}>
{pageSize}
</Select.Item>
))}
</Select.Popup>
</Select.Root>
</Card.Footer>
</Card.Body>
</Card.Root>
);
}
type Data = {
name: string;
status: 'active' | 'inactive';
role: string;
'last-active': string;
};
const datas: Data[] = [
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
{ name: 'Olivia Park', status: 'active', role: 'designer', 'last-active': '2 hours ago' },
{ name: 'Ethan Kim', status: 'active', role: 'developer', 'last-active': '3 days ago' },
{ name: 'Mia Choi', status: 'inactive', role: 'developer', 'last-active': '10 minutes ago' },
{ name: 'Noah Lee', status: 'active', role: 'designer', 'last-active': '1 day ago' },
{ name: 'Ava Jung', status: 'active', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Liam Han', status: 'inactive', role: 'developer', 'last-active': '5 days ago' },
{ name: 'Emma Seo', status: 'active', role: 'designer', 'last-active': '7 days ago' },
{ name: 'Mason Yoo', status: 'active', role: 'designer', 'last-active': '30 minutes ago' },
{ name: 'Sophia Lim', status: 'inactive', role: 'designer', 'last-active': '4 hours ago' },
{ name: 'Lucas Park', status: 'active', role: 'developer', 'last-active': '1 hour ago' },
];
const activeness: Record<string, Badge.Props['color']> = {
active: 'success',
inactive: 'hint',
};
/* -----------------------------------------------------------------------------------------------*/
interface FilterSelectProps extends React.ComponentProps<typeof MultiSelect.Root> {
triggerLabel: string;
content: React.ReactNode;
}
const FilterSelect = ({ content, triggerLabel, ...props }: FilterSelectProps) => {
return (
<MultiSelect.Root {...props}>
<MultiSelect.TriggerPrimitive
render={<Button variant="fill" color="secondary" />}
style={{ width: 'unset' }}
>
{triggerLabel}
<MultiSelect.TriggerIconPrimitive />
</MultiSelect.TriggerPrimitive>
<MultiSelect.Popup positionerElement={<MultiSelect.PositionerPrimitive align="end" />}>
{content}
</MultiSelect.Popup>
</MultiSelect.Root>
);
};