Table

A powerful, responsive table and datagrids built using Tanstack

Examples

Basic

PropDefaultTypeDescription
columns[]arrayTable columns.
data[]arrayTable data.
Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
JorgeCollier130relationship67
LyndaJohns2418single90
VickyEbert17467relationship20
EleanorHuels16241single90
JesusBarrows35191complicated48

Row Selection

Row selection allows you to select rows in the table. This is useful when you want to select rows in the table.

PropDefaultTypeDescription
rowSelection-objectSelected row state, can be binded with v-model.
enableRowSelectionfalsebooleanEnable row selection.
enableMultiRowSelectiontruebooleanEnable multiple row selection.
rowIdidstringRow id to uniquely identify each row.
enableSubRowSelectionfalsebooleanEnable sub row selection.
@select-event, rowEmitted when a row is selected.
@select-all-event, rowsEmitted when all rows are selected.
@row-event, rowEmitted when a row is clicked.

The checkboxes are NTableSelectionHeader and NTableSelectionCell, both NCheckbox underneath. Configure them through _tableSelectionHeader and _tableSelectionCell, or replace them with the select-header and select-cell slots — see Widgets.

Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
JeremiahEmmerich-Yundt3214single72
KeshaunFeil40232complicated36
FernandoErnser35768relationship58
TerriRuecker370relationship65
JosefinaHilll-Hilll27186single58
ArnoldO'Connell27794complicated95
IrmaSpinka2587complicated20
ScottCorwin3876complicated54
CathyDuBuque6748single13
CarolynLesch29479single15
of row(s) selected.

Empty

Empty allows you to show a message when the table is empty. This is useful when you want to show a message when the table is empty.

PropDefaultTypeDescription
empty-textNo results.stringEmpty text.
empty-iconi-tabler-database-xstringEmpty icon.
Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
No data.

Loading

Loading allows you to show a loading progress indicator in the table. This is useful when you want to show a loading progress indicator in the table.

PropDefaultTypeDescription
loadingfalsebooleanLoading state.
Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
LennaReilly-Green0675complicated67
ChelseaStokes9983single82
HenriettaMacejkovic2991complicated64
WhitneyHintz1736complicated12
RolandoMayert-Larson35486relationship79
RafaelGutmann33854relationship63
MonicaFeil32437relationship86
AlannaTillman19690complicated99
ArleneLehner9816relationship46
RudolphGreenfelder15160complicated58

Pagination

NTable owns the pagination state and, with show-pagination, renders shadcn's pagination bar below the table: the selection count (or row range) on the left, then rows per page, Page X of Y and the navigation on the right. Page numbers are off by default, as in shadcn; turn them on with _tablePagination: { showListItem: true }. Below the lg breakpoint the bar keeps only Page X of Y and the previous/next buttons.

PropDefaultTypeDescription
pagination{pageIndex: 0, pageSize: 10}{pageIndex: Number, pageSize: Number}Pagination state, can be binded with v-model.
manualPaginationfalsebooleanEnable manual pagination, ideal for server-side pagination.
rowCount-numberThe full row count, for manual pagination.
showPaginationfalsebooleanRender the built-in pagination bar.
_tablePagination{}objectProps for the bar, NTablePagination.
Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
HerminaReichert3727relationship29
MyrtleDibbert34240relationship22
OzellaMcClure5240relationship54
WebsterKulas27550complicated55
OscarFerry20948complicated12
Showing 1–5 of 50
Rows per page
Page 1 of 10

The bar is NTablePagination. It takes every NPagination prop except the state ones — page, itemsPerPage and total come from the table — plus:

PropDefaultTypeDescription
showInfotruebooleanRender the status text on the left.
showRowsPerPagetruebooleanRender the rows-per-page select.
table-TableThe table instance, when used outside NTable; read from context otherwise.

Pass them through _tablePagination for prop-only changes. For anything more — the status text, say, whose default is English — take over the pagination slot and render NTablePagination yourself. It reads the table from context, so it needs no props, and its status slot exposes selected, filtered, total, first and last. Providing the slot renders the bar on its own; show-pagination is only needed for the default one.

Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
JackieDach29931complicated78
RaleighBeatty14835relationship96
JessieFeil34524relationship50
JerrellFritsch1178relationship63
FannieBarrows25980single77
0 of 50 selected
Per page
Page 1 of 10

With manualPagination, pass rowCount so the bar knows the full size — the table only ever holds the current page; see Server-side. When row selection is on, the status shows the selection count instead of the row range. To compose the bar outside the root, NTablePagination also takes the table instance directly:

<NTable ref="table" :columns :data />

<NTablePagination v-if="table" :table />

Sorting

Sorting allows you to sort columns in ascending or descending order. This is useful when you want to sort columns in the table.

PropDefaultTypeDescription
sorting-arraySorting state, can be binded with v-model.
enableMultiSort-booleanEnable multi-column sorting
enableSorting-booleanEnable all column sorting
column.enableSorting-booleanEnable specific column sorting
enableSortingRemovaltruebooleanEnables the ability to remove sorting for the table.

Sortable headers render NTableSortButton, a NButton whose trailing icon follows the column's sort state. Configure it through _tableSortButton (any NButton prop), re-point the icons with the table-sort-asc-icon, table-sort-desc-icon and table-sort-none-icon preset aliases or the matching una keys, and replace its content with the {column}-header slot. Sortable headers also carry aria-sort.

Preview
Code
Data
Status
KendallSmitham8957relationship56
PasqualeBatz369single50
LeahGorczany0836single7
BrodyFeest3465single37
SheridanAdams-Ritchie6421single0
RuthBotsford2379relationship58
AlexzanderVeum16352relationship95
CorneliusMuller34787relationship5
EricaGleason9186single9
TanyaDickinson31803complicated28

Visibility

Visibility allows you to show or hide columns in the table. This is useful when you want to show or hide columns in the table.

PropDefaultTypeDescription
columnVisibility-objectColumn visibility state, can be binded with v-model.
Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
ShelleyMuller2180relationship33
MercedesNitzsche33139single69
GiovannyHartmann36760complicated28
DaphneeHeidenreich11946relationship32
DonnieJacobs13618complicated98

Global Filtering

Global filtering allows you to filter rows based on the value entered in the filter input. This is useful when you want to filter rows in the table.

PropDefaultTypeDescription
globalFilter-stringGlobal filter state, can be binded with v-model.
manualFiltering-booleanEnable manual filtering. Ideal for server-side filtering.
Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
KrystalRobel-Quigley0893single78
MichelleBayer1324single61
GlendaD'Amore39859relationship31
PaulaWuckert33156relationship50
TerriKoch15733relationship23
JaimeHansen22relationship47
DanaMueller8442relationship31
CamillaRunte21919complicated17
TedDaniel234single66
KristopherStehr25660complicated65

Column Filtering

Column filtering allows you to filter columns based on the value entered in the filter input. This is useful when you want to filter columns in the table.

PropDefaultTypeDescription
columnFilters-arrayColumn filter state, can be binded with v-model.
enableColumnFilters-booleanEnable all column filtering
column.enableColumnFilter-booleanEnable specific column filtering

Each filter is NTableColumnFilter, a NInput bound to the column's filter value with the header text as its placeholder. Configure it through _tableColumnFilter (any NInput prop), or replace it per column with the {column}-filter slot.

Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
PerryWilderman4647complicated35
TommieDoyle40858single65
ChristinaWalker17608complicated5
RussellWest29488relationship59
KarenSporer10386relationship39

Column Ordering

Column ordering allows you to reorder columns by dragging and dropping them. This is useful when you want to change the order of columns in the table.

PropDefaultTypeDescription
columnOrder-arrayColumn order state, can be binded with v-model.
Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
JadonKunze18188relationship59
JohnathanOlson19717single58
RonnieGrady35338single15
RafaelParker3725relationship27
OtisKoss2665single12

Column Pinning

Column pinning allows you to pin columns to the left or right of the table. This is useful when you have a large number of columns and you want to keep some columns in view while scrolling.

PropDefaultTypeDescription
columnPinning-{ left: array, right: array }Column pinning state, can be binded with v-model.
Preview
Code
Data
StatusFirst NameLast NameAgeVisitsProfile Progress
relationshipCarmeloWard1579941
singleLelaCollins2278053
complicatedAbdielHammes2619534
relationshipIrmaOrtiz2413124
complicatedRebeccaDouglas25360

Expanding

Expanding allows you to expand rows to show additional information. This is useful when you want to show additional information about a row.

PropDefaultTypeDescription
expanded-objectExpanded state, can be binded with v-model.
@expand-event, rowEmitted when a row's expand toggle is clicked.

The toggle is NTableExpandButton, an icon-only NButton that turns its chevron while the row is open. Configure it through _tableExpandButton, re-point the icon with the table-expand-icon alias, or replace it with the expand-cell slot. expanded renders the row's content; expand-cell renders the toggle.

Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
RyleyOndricka39238complicated30
LyndaRutherford1340relationship56
JeannePowlowski25547single100
EugeneAuer33961complicated61
MandyBeer34347complicated77

Grouping

Grouping allows you to group rows based on a column value. This is useful when you want to group rows in the table.

PropDefaultTypeDescription
grouping-arrayGrouping state, can be binded with v-model.
manualGrouping-booleanEnable manual grouping.
Preview
Code
Data
InfoNameInfo
StatusProgressFirst NameLast NameAgeVisits
single0KayHeathcote20724
relationship94MargeRunolfsdottir834
relationship26OlgaBayer3962
complicated38ShawnaBuckridge20994
relationship33ByronKeeling27968

Server-side

Allows you to fetch data from the server. Set manualPagination so the table stops slicing the rows itself, and pass rowCount from the response so the pagination bar knows the full size — the table only ever holds the current page.

Preview
Code
Data
NameUrl
bulbasaurhttps://pokeapi.co/api/v2/pokemon/1/
ivysaurhttps://pokeapi.co/api/v2/pokemon/2/
venusaurhttps://pokeapi.co/api/v2/pokemon/3/
charmanderhttps://pokeapi.co/api/v2/pokemon/4/
charmeleonhttps://pokeapi.co/api/v2/pokemon/5/
charizardhttps://pokeapi.co/api/v2/pokemon/6/
squirtlehttps://pokeapi.co/api/v2/pokemon/7/
wartortlehttps://pokeapi.co/api/v2/pokemon/8/
blastoisehttps://pokeapi.co/api/v2/pokemon/9/
caterpiehttps://pokeapi.co/api/v2/pokemon/10/
Showing 1–10 of 1351
Rows per page
Page 1 of 136

Customization

Configure the table using the una prop and utility classes.

PropDefaultTypeDescription
columns.meta.una{}objectColumn Una meta data.
una{}objectGlobal Una attribute.
_tableSortButton{}objectProps for the sort button in sortable headers.
_tableColumnFilter{}objectProps for the per-column filter input.
_tableSelectionHeader{}objectProps for the select-all checkbox.
_tableSelectionCell{}objectProps for the per-row selection checkbox.
_tableExpandButton{}objectProps for the row expand toggle.
_tablePagination{}objectProps for the built-in pagination bar.

Set them for the whole table with the _table* props, or per column through columns.meta, which accepts the same keys:

const columns = [
  {
    header: 'Age',
    accessorKey: 'age',
    meta: {
      una: { tableHead: 'text-center' },
      _tableColumnFilter: { placeholder: 'Filter age…' },
    },
  },
]

Only una and the _table* keys are read from columns.meta; anything else you store there stays out of the DOM.

Preview
Code
Data
First NameLast NameAgeVisitsStatusProfile Progress
UlisesTorp12633relationship63
AngeloPagac10639single87
JeannieHansen13885complicated82
IrvinVolkman7541single11
StantonLynch20213complicated24

Widgets

Every widget NTable renders on your behalf is a component in its own right, so it takes the full prop surface of what it wraps, has una keys of its own, and can be swapped out through a slot:

ComponentWrapsPropsSlotIcon aliases
NTableSortButtonNButton_tableSortButton{column}-headertable-sort-asc-icon, table-sort-desc-icon, table-sort-none-icon
NTableColumnFilterNInput_tableColumnFilter{column}-filter-
NTableSelectionHeaderNCheckbox_tableSelectionHeaderselect-header-
NTableSelectionCellNCheckbox_tableSelectionCellselect-cell-
NTableExpandButtonNButton_tableExpandButtonexpand-celltable-expand-icon
NTablePaginationNPagination_tablePaginationpagination-

Their una keys — tableSortButton, tableSortIconBase, tableSortAscIcon, tableSelection, tableExpandButton, tablePagination and the rest — are listed under Props; the *Icon keys take an icon name, the others take classes. The icon aliases are preset shortcuts, so they can also be re-pointed for the whole app from your UnoCSS config. One thing to know: the sort button's font-normal is marked important in the table-sort-button shortcut, so override its weight with font-medium!.

Preview
Code
Data
Status
DarrinDouglas-Bode6relationship
SandrineLakin23relationship
AlberthaKilback38relationship
GuillermoKuhn23single
MaeveKoss0relationship

Slots

NamePropsDescription
{column}-filtercolumnColumn filter slot.
{column}-headercolumnColumn header slot.
{column}-cellcellColumn cell slot.
{column}-footercolumnColumn footer slot.
headertableHeader slot.
bodytableBody slot.
rowrowRow slot.
footertableFooter slot.
select-headercolumnSelect-all checkbox slot, when enableRowSelection is set.
select-cellcellPer-row selection checkbox slot.
expand-cellcellRow expand toggle slot, when an expanded slot is provided.
expandedrowExpanded row content slot.
empty-Empty slot.
loading-Loading slot.
paginationtable, paginationReplaces the built-in pagination bar.
Preview
Code
Data
Account
ML
Marty Lakin
Edwina_Kuhn@hotmail.com
MartyLakinrelationship
81%
SM
Shirley McCullough
Bernadette72@yahoo.com
ShirleyMcCulloughsingle
98%
BH
Brian Hermann
Roger84@yahoo.com
BrianHermanncomplicated
97%
NR
Nellie Reilly
Melyna_Abbott@gmail.com
NellieReillycomplicated
17%
CY
Ciara Yost
Joey54@yahoo.com
CiaraYostsingle
22%
IC
Inez Cassin
Jeremy.Brown@gmail.com
InezCassinrelationship
35%
VR
Vera Rogahn
Jennie_Adams@hotmail.com
VeraRogahnsingle
27%
SK
Sophia Kohler
Salvador.Collins94@hotmail.com
SophiaKohlerrelationship
97%
MC
Mauricio Conroy
Rachel_Haley@gmail.com
MauricioConroycomplicated
4%
NM
Ned Mertz
Emmie_Bednar16@hotmail.com
NedMertzrelationship
7%
0 of 50 row(s) selected.
Rows per page
Page 1 of 5

Migrating

The widgets NTable injected into its columns were built inline, with literal props and no way to adjust them. They are now sub-components, which moved a handful of names and defaults:

- <template #selection-header>…</template>
- <template #selection-cell>…</template>
- <template #expanded-cell>…</template>
+ <template #select-header>…</template>
+ <template #select-cell>…</template>
+ <template #expand-cell>…</template>

- :column-pinning="{ left: ['selection', 'firstName'] }"
+ :column-pinning="{ left: ['select', 'firstName'] }"
BeforeAfter
Injected columns with the ids selection and expandedselect and expand, as in shadcn. Rename them wherever a column id appears — columnOrder, columnPinning, columnVisibility.
#selection-header, #selection-cell, #expanded-cell#select-header, #select-cell, #expand-cell. #expanded, the row content, is unchanged.
A column of your own with the id select or expandRename it. TanStack keys columns by id and silently drops one of the two; NTable now warns in dev.
Any key on columns.meta landed on <th> and <td> as an attributeOnly una and the _table* keys are read. Set attributes through meta._tableHead and meta._tableCell.
Sort button, filter input, checkboxes and expand toggle hardcodedNTableSortButton, NTableColumnFilter, NTableSelectionHeader, NTableSelectionCell, NTableExpandButton — configured through the _table* props and una keys.
Sort and expand icons hardcodedThe table-sort-asc-icon, table-sort-desc-icon, table-sort-none-icon and table-expand-icon preset aliases, or the matching una keys.
The selection checkboxes and the expand toggle had no accessible namearia-label on all three, aria-sort on sortable headers, data-expanded on the toggle.
table-default-variant, table-loading-icon, table-loading-icon-nameRemoved — none of them rendered anything.
NTableLoading ignored its colspanHonoured; NTable passes its leaf-column count.
NTable rendered a single root elementIt renders a fragment — the root plus, with show-pagination, the bar. Attributes still land on <table>; if you relied on $el, wrap it yourself.

The pagination bar — showPagination, _tablePagination, the pagination slot — is additive; a table without them renders as before.

Presets

shortcuts/table.ts
type TablePrefix = 'table'

export const staticTable: Record<`${TablePrefix}-${string}` | TablePrefix, string> = {
  // icons
  'table-sort-asc-icon': 'i-lucide-arrow-up-wide-narrow',
  'table-sort-desc-icon': 'i-lucide-arrow-down-narrow-wide',
  'table-sort-none-icon': 'i-lucide-arrow-up-down',
  'table-expand-icon': 'i-radix-icons-chevron-down',

  // table-root
  'table-root': 'relative w-full overflow-x-auto overflow-y-hidden border border-border rounded-md',
  'table': 'w-full caption-bottom text-sm',
  'table-body': '[&_tr:last-child]:border-0',
  'table-caption': 'mt-4 text-sm text-muted-foreground',

  // table-head
  'table-head': 'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',
  'table-head-pinned': 'sticky bg-background',
  'table-head-pinned-left': 'left-0',
  'table-head-pinned-right': 'right-0',

  // table-header
  'table-header': '[&_tr]:border-b [&_tr]:border-border',

  // table-row
  'table-row': 'border-b border-border transition-colors hover:bg-muted/50 data-[filter=true]:hover:bg-background data-[state=selected]:bg-muted',

  // table-cell
  'table-cell': 'p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-0.5',
  'table-cell-pinned': 'sticky bg-background',
  'table-cell-pinned-left': 'left-0',
  'table-cell-pinned-right': 'right-0',

  // table-empty
  'table-empty-row': '',
  'table-empty-cell': 'p-4 whitespace-nowrap align-middle text-sm text-muted-foreground bg-background',
  'table-empty': 'flex items-center flex-col justify-center py-10 gap-4',
  'table-empty-text': 'text-center text-wrap',
  'table-empty-icon-name': 'i-tabler-database-x size-2xl',

  // table-loading
  'table-loading-row': 'data-[loading=true]:border-0 absolute inset-x-0 -mt-1.5px',
  'table-loading-cell': '',
  'table-loading': 'absolute inset-x-0 overflow-hidden p-0',

  // table-sort-button
  // `-ml-1em` cancels the button's own padding so sortable headers align with
  // plain ones. `font-normal!` because `.btn`'s font-medium is emitted after
  // this shortcut in the same layer and would otherwise win.
  'table-sort-button': 'font-normal! -ml-1em',
  // sized by width/height, not font-size: `btn-trailing` sets font-size on the
  // same element and is emitted later, so `text-sm` here was inert
  'table-sort-icon-base': 'square-0.875rem',

  // table-column-filter
  'table-column-filter': 'w-auto',

  // table-selection
  'table-selection': '',
  'table-selection-header': '',
  'table-selection-cell': '',

  // table-expand-button
  'table-expand-button': '',
  'table-expand-icon-base': 'transform transition-transform duration-200',

  // table-pagination
  // shadcn's DataTablePagination, part for part: status on the left, then rows
  // per page, "Page X of Y" and the navigation on the right; a sibling below
  // `table-root`, hence the `mt-4`
  'table-pagination': 'mt-4 flex items-center justify-between px-4',
  'table-pagination-status': 'hidden flex-1 text-sm text-muted-foreground lg:flex',
  'table-pagination-controls': 'flex w-full items-center gap-8 lg:w-fit',
  'table-pagination-page-size': 'hidden items-center gap-2 lg:flex',
  'table-pagination-page': 'flex w-fit items-center justify-center text-sm',
  'table-pagination-nav': 'ml-auto flex items-center lg:ml-0',

  // table-footer
  'table-footer': 'border-t border-border bg-muted font-medium [&>tr]:last:border-b-0',
}

export const dynamicTable: [RegExp, (params: RegExpExecArray) => string][] = [
]

export const table = [
  ...dynamicTable,
  staticTable,
]

Props

types/table.ts
import type {
  Column,
  ColumnDef,
  CoreOptions,
  FilterFn,
  FilterFnOption,
  GroupColumnDef,
  Row,
  RowData,
  Table,
} from '@tanstack/vue-table'
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { NButtonProps } from './button'
import type { NCheckboxProps } from './checkbox'
import type { NInputProps } from './input'
import type { NPaginationProps } from './pagination'
import type { NProgressProps } from './progress'
import type { NScrollAreaProps, NScrollAreaUnaProps } from './scroll-area'

export interface NTableProps<TData, TValue> extends Omit<CoreOptions<TData>, 'data' | 'columns' | 'getCoreRowModel' | 'state' | 'onStateChange' | 'renderFallbackValue'>, Pick<NTableEmptyProps, 'emptyText' | 'emptyIcon'> {
  class?: HTMLAttributes['class']
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/table#state
   */
  state?: CoreOptions<TData>['state']
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/table#onstatechange
   */
  onStateChange?: CoreOptions<TData>['onStateChange']
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/table#renderfallbackvalue
   */
  renderFallbackValue?: CoreOptions<TData>['renderFallbackValue']
  /**
   * @see https://tanstack.com/table/latest/docs/guide/data
   */
  data: TData[]
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/column
   */
  columns: ColumnDef<TData, TValue>[] | GroupColumnDef<TData, TValue>[]
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/table#getrowid
   */
  rowId?: string
  /**
   * @see https://tanstack.com/table/latest/docs/api/core/table#autoresetall
   */
  autoResetAll?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/row-selection#enablerowselection
   */
  enableRowSelection?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/row-selection#enablemultirowselection
   */
  enableMultiRowSelection?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/row-selection#enablesubrowselection
   */
  enableSubRowSelection?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/column-filtering#enablecolumnfilters
   */
  enableColumnFilters?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/column-filtering#manualfiltering
   */
  manualFiltering?: boolean
  /**
   * The filter function to use for global filtering.
   * Can be a built-in filter function name or a custom filter function.
   *
   * @see https://tanstack.com/table/latest/docs/api/features/global-filtering#globalfilterfn
   */
  globalFilterFn?: FilterFnOption<TData>
  /**
   * Custom filter functions that can be referenced by columns or globalFilterFn by string key.
   *
   * @see https://tanstack.com/table/latest/docs/api/features/column-filtering#filterfns
   */
  filterFns?: Record<string, FilterFn<TData>>
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablesorting
   */
  enableSorting?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablemultisort
   */
  enableMultiSort?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablemultiremove
   */
  enableMultiRemove?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#enablesortingremoval
   */
  enableSortingRemoval?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#manualsorting
   */
  manualSorting?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#maxmultisortcolcount
   */

  maxMultiSortColCount?: number
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#manualpagination
   */
  manualPagination?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#pagecount
   */
  pageCount?: number
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#rowcount
   */
  rowCount?: number
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/pagination#autoresetpageindex
   */
  autoResetPageIndex?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#sortingfns
   */
  sortingFns?: Record<string, (a: any, b: any) => number>
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#sortdescfirst-1
   */
  sortDescFirst?: boolean
  /**
   * @see https://tanstack.com/table/latest/docs/api/features/sorting#ismultisortevent
   */
  isMultiSortEvent?: (e: unknown) => boolean

  // sub-components props
  _tableHead?: NTableHeadProps
  _tableHeader?: NTableHeaderProps
  _tableFooter?: NTableFooterProps
  _tableBody?: NTableBodyProps
  _tableCaption?: NTableCaptionProps
  _tableRow?: NTableRowProps | ((row?: TData) => NTableRowProps)
  _tableCell?: NTableCellProps
  _tableEmpty?: NTableEmptyProps
  _tableLoading?: NTableLoadingProps
  _tableSortButton?: Omit<NTableSortButtonProps<TData, TValue>, 'column'>
  _tableColumnFilter?: Omit<NTableColumnFilterProps<TData, TValue>, 'column'>
  _tableSelectionHeader?: Omit<NTableSelectionHeaderProps<TData>, 'table'>
  _tableSelectionCell?: Omit<NTableSelectionCellProps<TData>, 'row'>
  _tableExpandButton?: Omit<NTableExpandButtonProps<TData>, 'row'>
  _tablePagination?: Omit<NTablePaginationProps, 'table'>
  _scrollArea?: NScrollAreaProps

  /**
   * Whether the table is loading.
   */
  loading?: boolean
  /**
   * Render the built-in pagination bar below the root, as a sibling rather
   * than inside it. Configure it through `_tablePagination`, or replace it
   * with the `pagination` slot.
   *
   * @default false
   */
  showPagination?: boolean
  /**
   * `UnaUI` preset configuration
   *
   * @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/table.ts
   */
  una?: NTableUnaProps & NScrollAreaUnaProps
}

export interface NTableBodyProps extends PrimitiveProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  una?: Pick<NTableUnaProps, 'tableBody'>
}

export interface NTableHeadProps extends PrimitiveProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  dataPinned?: 'left' | 'right' | false
  una?: Pick<NTableUnaProps, 'tableHead'>
}

export interface NTableHeaderProps extends PrimitiveProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  una?: Pick<NTableUnaProps, 'tableHeader'>
}

export interface NTableFooterProps extends PrimitiveProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  una?: Pick<NTableUnaProps, 'tableFooter'>
}

export interface NTableRowProps extends PrimitiveProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  una?: Pick<NTableUnaProps, 'tableRow'>
}

export interface NTableCellProps extends PrimitiveProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  dataPinned?: 'left' | 'right' | false
  una?: Pick<NTableUnaProps, 'tableCell'>
}

export interface NTableEmptyProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  colspan?: number
  /**
   * The text to display when the table is empty.
   */
  emptyText?: string
  /**
   * The icon to display when the table is empty.
   */
  emptyIcon?: string
  _tableCell?: NTableCellProps
  _tableRow?: NTableRowProps

  una?: Pick<NTableUnaProps, 'tableEmpty' | 'tableRow' | 'tableCell' | 'tableEmptyText' | 'tableEmptyIcon'>
}

export interface NTableLoadingProps {
  [key: string]: any
  size?: HTMLAttributes['class']
  enabled?: boolean
  class?: HTMLAttributes['class']
  colspan?: number
  _tableCell?: NTableCellProps
  _tableRow?: NTableRowProps
  _tableProgress?: NProgressProps
  una?: Pick<NTableUnaProps, 'tableLoading' | 'tableLoadingCell' | 'tableLoadingRow'>
}

export interface NTableCaptionProps extends PrimitiveProps {
  [key: string]: any
  class?: HTMLAttributes['class']
  una?: Pick<NTableUnaProps, 'tableCaption'>
}

export interface NTableSortButtonProps<TData = any, TValue = any> extends Omit<NButtonProps, 'una'> {
  /** The TanStack column this button sorts. Supplied by `NTable`. */
  column: Column<TData, TValue>
  una?: Pick<NTableUnaProps, 'tableSortButton' | 'tableSortIconBase' | 'tableSortAscIcon' | 'tableSortDescIcon' | 'tableSortNoneIcon'> & NButtonProps['una']
}

export interface NTableColumnFilterProps<TData = any, TValue = any> extends Omit<NInputProps, 'una'> {
  class?: HTMLAttributes['class']
  /** The TanStack column this input filters. Supplied by `NTable`. */
  column: Column<TData, TValue>
  una?: Pick<NTableUnaProps, 'tableColumnFilter'> & NInputProps['una']
}

export interface NTableSelectionHeaderProps<TData = any> extends Omit<NCheckboxProps, 'una'> {
  /** The TanStack table instance. Supplied by `NTable`. */
  table: Table<TData>
  una?: Pick<NTableUnaProps, 'tableSelection' | 'tableSelectionHeader'> & NCheckboxProps['una']
}

export interface NTableSelectionCellProps<TData = any> extends Omit<NCheckboxProps, 'una'> {
  /** The TanStack row this checkbox selects. Supplied by `NTable`. */
  row: Row<TData>
  una?: Pick<NTableUnaProps, 'tableSelection' | 'tableSelectionCell'> & NCheckboxProps['una']
}

export interface NTableExpandButtonProps<TData = any> extends Omit<NButtonProps, 'una'> {
  /** The TanStack row this button expands. Supplied by `NTable`. */
  row: Row<TData>
  una?: Pick<NTableUnaProps, 'tableExpandButton' | 'tableExpandIconBase' | 'tableExpandIcon'> & NButtonProps['una']
}

export interface NTablePaginationProps extends Omit<NPaginationProps, 'page' | 'defaultPage' | 'itemsPerPage' | 'total' | 'una' | 'showInfo' | 'showRowsPerPage'> {
  /**
   * The TanStack table instance. Supplied through context when rendered by
   * `NTable`; pass it directly to use the bar on its own, outside the root.
   */
  table?: Table<any>
  /**
   * Render the status text on the left: the selection count when the table
   * has row selection enabled, otherwise the visible row range.
   *
   * Unlike `NPagination`'s flag of the same name, this never toggles
   * `NPaginationInfo` — the bar always renders "Page X of Y" among its
   * controls.
   *
   * @default true
   */
  showInfo?: boolean
  /**
   * Render the rows-per-page select among the controls.
   *
   * @default true
   */
  showRowsPerPage?: boolean
  una?: Pick<NTableUnaProps, 'tablePagination' | 'tablePaginationStatus' | 'tablePaginationControls' | 'tablePaginationPageSize' | 'tablePaginationPage' | 'tablePaginationNav'> & NPaginationProps['una']
}

export interface NTableUnaProps {
  table?: HTMLAttributes['class']
  tableRoot?: HTMLAttributes['class']
  tableBody?: HTMLAttributes['class']
  tableHead?: HTMLAttributes['class']
  tableHeader?: HTMLAttributes['class']
  tableFooter?: HTMLAttributes['class']
  tableRow?: HTMLAttributes['class']
  tableCell?: HTMLAttributes['class']
  tableCaption?: HTMLAttributes['class']
  tableLoading?: HTMLAttributes['class']
  tableLoadingRow?: HTMLAttributes['class']
  tableLoadingCell?: HTMLAttributes['class']
  tableEmpty?: HTMLAttributes['class']
  tableEmptyText?: HTMLAttributes['class']
  tableEmptyIcon?: HTMLAttributes['class']

  // injected widgets
  tableSortButton?: HTMLAttributes['class']
  tableSortIconBase?: HTMLAttributes['class']
  tableSortAscIcon?: HTMLAttributes['class']
  tableSortDescIcon?: HTMLAttributes['class']
  tableSortNoneIcon?: HTMLAttributes['class']
  tableColumnFilter?: HTMLAttributes['class']
  tableSelection?: HTMLAttributes['class']
  tableSelectionHeader?: HTMLAttributes['class']
  tableSelectionCell?: HTMLAttributes['class']
  tableExpandButton?: HTMLAttributes['class']
  tableExpandIconBase?: HTMLAttributes['class']
  tableExpandIcon?: HTMLAttributes['class']
  tablePagination?: HTMLAttributes['class']
  tablePaginationStatus?: HTMLAttributes['class']
  tablePaginationControls?: HTMLAttributes['class']
  tablePaginationPageSize?: HTMLAttributes['class']
  tablePaginationPage?: HTMLAttributes['class']
  tablePaginationNav?: HTMLAttributes['class']
}

/**
 * Per-column configuration, read from `columnDef.meta`.
 *
 * `NTable` passes only these keys through to its parts — anything else on
 * `meta` stays out of the DOM.
 */
declare module '@tanstack/vue-table' {

  interface ColumnMeta<TData extends RowData, TValue> {
    una?: NTableUnaProps
    _tableHead?: NTableHeadProps
    _tableCell?: NTableCellProps
    _tableSortButton?: Omit<NTableSortButtonProps<TData, TValue>, 'column'>
    _tableColumnFilter?: Omit<NTableColumnFilterProps<TData, TValue>, 'column'>
    _tableSelectionHeader?: Omit<NTableSelectionHeaderProps<TData>, 'table'>
    _tableSelectionCell?: Omit<NTableSelectionCellProps<TData>, 'row'>
    _tableExpandButton?: Omit<NTableExpandButtonProps<TData>, 'row'>
  }
}

Components

Table.vue
TableHeader.vue
TableHead.vue
TableBody.vue
TableFooter.vue
TableCell.vue
TableRow.vue
TableEmpty.vue
TableLoading.vue
TableCaption.vue
TableSortButton.vue
TableColumnFilter.vue
TableSelectionHeader.vue
TableSelectionCell.vue
TableExpandButton.vue
TablePagination.vue
<script setup lang="ts" generic="TData, TValue">
import type {
  Column,
  ColumnDef,
  ColumnFiltersState,
  ColumnOrderState,
  ColumnPinningState,
  ExpandedState,
  GroupingState,
  Header,
  PaginationState,
  RowSelectionState,
  SortingState,
  VisibilityState,
} from '@tanstack/vue-table'
import type { NTableProps } from '../../../types'

import {
  FlexRender,
  getCoreRowModel,
  getExpandedRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useVueTable,
} from '@tanstack/vue-table'

import { computed } from 'vue'

import { cn, valueUpdater } from '../../../utils'
import ScrollArea from '../../scroll-area/ScrollArea.vue'
import TableBody from './TableBody.vue'
import TableCell from './TableCell.vue'
import TableColumnFilter from './TableColumnFilter.vue'
import TableEmpty from './TableEmpty.vue'
import TableExpandButton from './TableExpandButton.vue'
import TableFooter from './TableFooter.vue'
import TableHead from './TableHead.vue'
import TableHeader from './TableHeader.vue'
import TableLoading from './TableLoading.vue'
import TablePagination from './TablePagination.vue'
import TableRow from './TableRow.vue'
import TableSelectionCell from './TableSelectionCell.vue'
import TableSelectionHeader from './TableSelectionHeader.vue'
import TableSortButton from './TableSortButton.vue'
import { provideTableContext } from './useTable'

// the root is a fragment once the pagination bar renders beside `table-root`,
// so attrs cannot be inherited — they keep going to `<table>` through the
// explicit `v-bind="$attrs"` there, as they always have
defineOptions({ inheritAttrs: false })

const props = withDefaults(defineProps <NTableProps<TData, TValue>>(), {
  enableMultiRowSelection: true,
  enableSortingRemoval: true,
  showPagination: false,
})
const emit = defineEmits<{
  select: [row: TData]
  selectAll: [rows: TData[]]
  expand: [row: TData]
  row: [event: Event, row: TData]
}>()
const slots = defineSlots()
/**
 * Column ids `NTable` injects for its own widgets. A user column resolving to
 * either would be silently dropped — TanStack keys columns by id in a plain
 * map, so the later definition wins without warning.
 */
const SELECT_COLUMN_ID = 'select'
const EXPAND_COLUMN_ID = 'expand'

const rowSelection = defineModel<RowSelectionState>('rowSelection')
const sorting = defineModel<SortingState>('sorting')
const columnVisibility = defineModel<VisibilityState>('columnVisibility')
const columnFilters = defineModel<ColumnFiltersState>('columnFilters')
const globalFilter = defineModel<string>('globalFilter')
const columnOrder = defineModel<ColumnOrderState>('columnOrder')
const columnPinning = defineModel<ColumnPinningState>('columnPinning')
const expanded = defineModel<ExpandedState>('expanded')
const grouping = defineModel<GroupingState>('grouping')
const pagination = defineModel<PaginationState>('pagination', {
  default: () => ({
    pageIndex: 0,
    pageSize: 10,
  }),
})

const columnsWithMisc = computed(() => {
  if (import.meta.dev) {
    const reservedIds = [SELECT_COLUMN_ID, EXPAND_COLUMN_ID]
    for (const column of props.columns ?? []) {
      const id = (column as any).id ?? (column as any).accessorKey
      if (reservedIds.includes(id)) {
        console.warn(`[NTable]: The column id '${id}' is reserved for the built-in ${id === SELECT_COLUMN_ID ? 'row selection' : 'row expansion'} column. TanStack keys columns by id, so one of the two will be silently dropped. Please choose a different id.`)
      }
    }
  }

  let data = props.columns as ColumnDef<TData, TValue>[]

  // add selection column
  data = props.enableRowSelection
    ? [
        {
          id: SELECT_COLUMN_ID,
          enableSorting: false,
          enableHiding: false,
          enableColumnFilter: false,
        },
        ...data,
      ]
    : data

  // add expanded column
  data = slots.expanded
    ? [
        {
          id: EXPAND_COLUMN_ID,
          enableSorting: false,
          enableHiding: false,
          enableColumnFilter: false,
        },
        ...data,
      ]
    : data

  return data
})

const table = useVueTable({
  get data() {
    return props.data ?? []
  },
  get columns() {
    return columnsWithMisc.value ?? []
  },
  state: {
    get sorting() { return sorting.value },
    get columnFilters() { return columnFilters.value },
    get globalFilter() { return globalFilter.value },
    get rowSelection() { return rowSelection.value },
    get columnVisibility() { return columnVisibility.value },
    get pagination() { return pagination.value },
    get columnOrder() { return columnOrder.value },
    get columnPinning() { return columnPinning.value },
    get expanded() { return expanded.value },
    get grouping() { return grouping.value },
  },

  // getters, like `data` and `columns` above: the Vue adapter reads options
  // through a lazy proxy, so a plain `x: props.x` is captured once at setup
  // and never sees the prop change — a `rowCount` arriving after a fetch, or
  // a `pageCount` recomputed for a new page size, left the table on a stale
  // count
  get enableMultiRowSelection() {
    return props.enableMultiRowSelection
  },
  get enableSubRowSelection() {
    return props.enableSubRowSelection
  },
  get autoResetAll() {
    return props.autoResetAll
  },
  get enableRowSelection() {
    return props.enableRowSelection
  },
  get enableColumnFilters() {
    return props.enableColumnFilters
  },
  get manualPagination() {
    return props.manualPagination
  },
  get manualSorting() {
    return props.manualSorting
  },
  get manualFiltering() {
    return props.manualFiltering
  },
  get globalFilterFn() {
    return props.globalFilterFn
  },
  get filterFns() {
    return props.filterFns
  },
  get pageCount() {
    return props.pageCount
  },
  get rowCount() {
    return props.rowCount
  },
  get autoResetPageIndex() {
    return props.autoResetPageIndex
  },
  get enableSorting() {
    return props.enableSorting
  },
  get enableSortingRemoval() {
    return props.enableSortingRemoval
  },
  get enableMultiSort() {
    return props.enableMultiSort
  },
  get enableMultiRemove() {
    return props.enableMultiRemove
  },
  get maxMultiSortColCount() {
    return props.maxMultiSortColCount
  },
  get sortingFns() {
    return props.sortingFns
  },
  get isMultiSortEvent() {
    return props.isMultiSortEvent
  },

  getCoreRowModel: getCoreRowModel(),
  getSortedRowModel: getSortedRowModel(),
  getFilteredRowModel: getFilteredRowModel(),
  getPaginationRowModel: getPaginationRowModel(),
  getRowId: (row: any) => props.rowId ? row[props.rowId] : row.id,
  getSubRows: (row: any) => row.subRows,
  getExpandedRowModel: getExpandedRowModel(),

  onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
  onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
  onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
  onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
  onGlobalFilterChange: updaterOrValue => valueUpdater(updaterOrValue, globalFilter),
  onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
  onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder),
  onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning),
  onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
  onGroupingChange: updaterOrValue => valueUpdater(updaterOrValue, grouping),
})

// the seam the built-in pagination bar — and anything composed into
// `#pagination` — reads the instance through, without a template ref
provideTableContext({
  table,
  pagination: computed(() => pagination.value),
  setPageIndex: index => table.setPageIndex(index),
  setPageSize: size => table.setPageSize(size),
})

function getHeaderColumnFiltersCount(headers: Header<unknown, unknown>[]): number {
  let count = 0
  headers.forEach((header) => {
    if (header.column.columnDef.enableColumnFilter)
      count++
  })

  return count
}

function getRowAttrs(data?: TData) {
  if (typeof props._tableRow === 'function') {
    return props._tableRow(data)
  }
  return props._tableRow
}

function isReserved(column: Column<TData, any>) {
  return column.id === SELECT_COLUMN_ID || column.id === EXPAND_COLUMN_ID
}

function isSortable(column: Column<TData, any>) {
  return Boolean(
    column.columnDef.enableSorting
    || (column.columnDef.enableSorting !== false && props.enableSorting),
  )
}

function getAriaSort(column: Column<TData, any>) {
  if (!isSortable(column))
    return undefined

  const sorted = column.getIsSorted()
  return sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : 'none'
}

/**
 * `columnDef.meta` is an arbitrary user bag, so only the keys `NTable`
 * understands are read from it — the rest never reaches the DOM.
 */
function columnUna(column: Column<TData, any>) {
  return { ...props.una, ...column.columnDef.meta?.una }
}

defineExpose({
  ...table,
})
</script>

<template>
  <div
    :class="cn('table-root', props.una?.tableRoot)"
  >
    <ScrollArea
      orientation="horizontal"
      v-bind="props._scrollArea"
      :una
    >
      <table
        v-bind="$attrs"
        :class="cn(
          'table',
          props.una?.table,
          props.class,
        )"
      >
        <!-- header -->
        <TableHeader
          :una
          v-bind="props._tableHeader"
        >
          <slot name="header" :table="table">
            <TableRow
              v-for="headerGroup in table.getHeaderGroups()"
              :key="headerGroup.id"
              :una
              v-bind="getRowAttrs()"
            >
              <!-- headers -->
              <TableHead
                v-for="header in headerGroup.headers"
                :key="header.id"
                :colspan="header.colSpan"
                :data-pinned="header.column.getIsPinned()"
                :aria-sort="getAriaSort(header.column)"
                :una="columnUna(header.column)"
                v-bind="{ ...props._tableHead, ...header.column.columnDef.meta?._tableHead }"
              >
                <TableSortButton
                  v-if="isSortable(header.column)"
                  :column="header.column"
                  :una="columnUna(header.column)"
                  v-bind="{ ...props._tableSortButton, ...header.column.columnDef.meta?._tableSortButton }"
                >
                  <slot
                    :name="`${header.id}-header`"
                    :column="header.column"
                  >
                    <FlexRender
                      v-if="!header.isPlaceholder"
                      :render="header.column.columnDef.header"
                      :props="header.getContext()"
                    />
                  </slot>
                </TableSortButton>

                <slot
                  v-else
                  :name="`${header.id}-header`"
                  :column="header.column"
                >
                  <TableSelectionHeader
                    v-if="!header.isPlaceholder && header.column.id === SELECT_COLUMN_ID && enableMultiRowSelection"
                    :table
                    :una="columnUna(header.column)"
                    v-bind="{ ...props._tableSelectionHeader, ...header.column.columnDef.meta?._tableSelectionHeader }"
                    @change="emit('selectAll', $event)"
                  />

                  <FlexRender
                    v-else-if="!header.isPlaceholder"
                    :render="header.column.columnDef.header"
                    :props="header.getContext()"
                  />
                </slot>
              </TableHead>
            </TableRow>

            <!-- column filters -->
            <template
              v-for="headerGroup in table.getHeaderGroups()"
              :key="headerGroup.id"
            >
              <TableRow
                v-if="getHeaderColumnFiltersCount(headerGroup.headers) > 0 || enableColumnFilters"
                data-filter="true"
                :una
                v-bind="getRowAttrs()"
              >
                <TableHead
                  v-for="header in headerGroup.headers"
                  :key="header.id"
                  :colspan="header.colSpan"
                  class="font-normal"
                  :data-pinned="header.column.getIsPinned()"
                  :una="columnUna(header.column)"
                  v-bind="{ ...props._tableHead, ...header.column.columnDef.meta?._tableHead }"
                >
                  <slot
                    v-if="!isReserved(header.column) && ((header.column.columnDef.enableColumnFilter !== false && enableColumnFilters) || header.column.columnDef.enableColumnFilter)"
                    :name="`${header.id}-filter`"
                    :column="header.column"
                  >
                    <TableColumnFilter
                      :column="header.column"
                      :una="columnUna(header.column)"
                      v-bind="{ ...props._tableColumnFilter, ...header.column.columnDef.meta?._tableColumnFilter }"
                    />
                  </slot>
                </TableHead>
              </TableRow>
            </template>
          </slot>

          <TableLoading
            :enabled="props.loading"
            :colspan="table.getAllLeafColumns().length"
            :una
            v-bind="props._tableLoading"
          >
            <slot name="loading" />
          </TableLoading>
        </TableHeader>

        <!-- body -->
        <TableBody
          :una
          v-bind="props._tableBody"
        >
          <slot name="body" :table="table">
            <template v-if="table.getRowModel().rows?.length">
              <template
                v-for="row in table.getRowModel().rows"
                :key="row.id"
              >
                <TableRow
                  :data-state="row.getIsSelected() && 'selected'"
                  :una
                  v-bind="getRowAttrs(row.original)"
                  @click="emit('row', $event, row.original)"
                >
                  <slot
                    name="row"
                    :row="row"
                  >
                    <!-- rows -->
                    <TableCell
                      v-for="cell in row.getVisibleCells()"
                      :key="cell.id"
                      :data-pinned="cell.column.getIsPinned()"
                      :una="columnUna(cell.column)"
                      v-bind="{ ...props._tableCell, ...cell.column.columnDef.meta?._tableCell }"
                    >
                      <slot
                        :name="`${cell.column.id}-cell`"
                        :cell="cell"
                      >
                        <TableSelectionCell
                          v-if="cell.column.id === SELECT_COLUMN_ID"
                          :row
                          :una="columnUna(cell.column)"
                          v-bind="{ ...props._tableSelectionCell, ...cell.column.columnDef.meta?._tableSelectionCell }"
                          @change="emit('select', $event)"
                        />

                        <TableExpandButton
                          v-else-if="cell.column.id === EXPAND_COLUMN_ID"
                          :row
                          :una="columnUna(cell.column)"
                          v-bind="{ ...props._tableExpandButton, ...cell.column.columnDef.meta?._tableExpandButton }"
                          @change="emit('expand', $event)"
                        />

                        <FlexRender
                          v-else
                          :render="cell.column.columnDef.cell"
                          :props="cell.getContext()"
                        />
                      </slot>
                    </TableCell>
                  </slot>
                </TableRow>

                <!-- expanded -->
                <TableRow
                  v-if="row.getIsExpanded() && $slots.expanded"
                  :una
                  v-bind="getRowAttrs(row.original)"
                >
                  <TableCell
                    :colspan="row.getAllCells().length"
                    :una
                    v-bind="props._tableCell"
                  >
                    <slot name="expanded" :row="row" />
                  </TableCell>
                </TableRow>
              </template>
            </template>

            <TableEmpty
              v-else
              :colspan="table.getAllLeafColumns().length"
              :una
              :empty-text="props.emptyText"
              :empty-icon="props.emptyIcon"
              v-bind="props._tableEmpty"
            >
              <slot name="empty" />
            </TableEmpty>
          </slot>
        </TableBody>

        <!-- footer -->
        <TableFooter
          v-if="table.getFooterGroups().length > 0"
          :una
          v-bind="props._tableFooter"
        >
          <slot name="footer" :table="table">
            <template
              v-for="footerGroup in table.getFooterGroups()"
              :key="footerGroup.id"
            >
              <TableRow
                v-if="footerGroup.headers.length > 0"
                :una
                v-bind="getRowAttrs()"
              >
                <template
                  v-for="header in footerGroup.headers"
                  :key="header.id"
                >
                  <TableHead
                    v-if="header.column.columnDef.footer"
                    :colspan="header.colSpan"
                    :una="columnUna(header.column)"
                    v-bind="{ ...props._tableHead, ...header.column.columnDef.meta?._tableHead }"
                  >
                    <slot :name="`${header.id}-footer`" :column="header.column">
                      <FlexRender
                        v-if="!header.isPlaceholder"
                        :render="header.column.columnDef.footer"
                        :props="header.getContext()"
                      />
                    </slot>
                  </TableHead>
                </template>
              </TableRow>
            </template>
          </slot>
        </TableFooter>
      </table>
    </ScrollArea>
  </div>

  <!-- pagination bar: a sibling below the root, where shadcn's
       DataTablePagination sits relative to the bordered table -->
  <slot
    v-if="showPagination || $slots.pagination"
    name="pagination"
    :table="table"
    :pagination="pagination"
  >
    <TablePagination
      :una
      v-bind="props._tablePagination"
    />
  </slot>
</template>