Table

데이터의 구조화된 표현을 제공하는 데 사용됩니다.

Table preview

이 문서는 대량의 데이터를 다루는 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>
    );
};