<script setup lang="ts">
import { ref } from 'vue'
import {
FlexRender,
cellSelectionFeature,
cellSpanningFeature,
columnFilteringFeature,
columnVisibilityFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
rowPaginationFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
tableFeatures,
useTable,
} from '@tanstack/vue-table'
import { makeData, makeSummaryData } from './makeData'
import type { Cell } from '@tanstack/vue-table'
import type { Shift, SummaryRow } from './makeData'
const features = tableFeatures({
cellSelectionFeature,
cellSpanningFeature,
columnFilteringFeature,
columnVisibilityFeature,
rowPaginationFeature,
rowSortingFeature,
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
filterFns: { includesString: filterFn_includesString },
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
})
const columnHelper = createColumnHelper<typeof features, Shift>()
const columns = columnHelper.columns([
columnHelper.accessor('region', {
header: 'Region',
sortFn: 'alphanumeric',
spanRows: true,
}),
columnHelper.accessor('team', {
header: 'Team',
sortFn: 'alphanumeric',
spanRows: true,
}),
columnHelper.accessor('shift', {
header: 'Shift',
sortFn: 'alphanumeric',
spanRows: ({ column, value, anchorValue }) =>
column.getIsSorted() !== false && value === anchorValue,
}),
columnHelper.accessor('employee', {
header: 'Employee',
sortFn: 'alphanumeric',
filterFn: 'includesString',
}),
columnHelper.accessor('hours', {
header: 'Hours',
sortFn: 'basic',
}),
columnHelper.accessor('status', {
header: 'Status',
sortFn: 'alphanumeric',
filterFn: 'includesString',
}),
])
const summaryFeatures = tableFeatures({
cellSpanningFeature,
columnVisibilityFeature,
})
const summaryColumnHelper = createColumnHelper<
typeof summaryFeatures,
SummaryRow
>()
const summaryColumns = summaryColumnHelper.columns([
summaryColumnHelper.accessor('label', {
header: 'Shift',
spanColumns: ({ row }) => (row.original.kind === 'subtotal' ? Infinity : 1),
}),
summaryColumnHelper.accessor('region', {
header: 'Region',
}),
summaryColumnHelper.accessor('hours', {
header: 'Hours',
}),
])
function getCellClassName(cell: Cell<typeof features, Shift>): string {
const base =
cell.getRowSpan() > 1 ? 'cell-selectable span-cell' : 'cell-selectable'
if (!cell.getIsSelected()) {
return cell.getIsFocused() ? `${base} cell-focused` : base
}
const edges = cell.getSelectionEdges()
return [
base,
'cell-selected',
cell.getIsFocused() && 'cell-focused',
edges.top && 'cell-edge-top',
edges.right && 'cell-edge-right',
edges.bottom && 'cell-edge-bottom',
edges.left && 'cell-edge-left',
]
.filter(Boolean)
.join(' ')
}
const data = ref(makeData())
const summaryData = ref(makeSummaryData())
const spanningEnabled = ref(true)
const refreshData = () => (data.value = makeData())
const table = useTable({
debugTable: true,
features,
columns,
data,
enableCellSpanning: spanningEnabled,
initialState: {
pagination: { pageIndex: 0, pageSize: 12 },
},
})
const summaryTable = useTable({
debugTable: true,
features: summaryFeatures,
columns: summaryColumns,
data: summaryData,
})
</script>
<template>
<div class="demo-root">
<div class="controls">
<button class="demo-button" @click="refreshData">Regenerate Data</button>
<label>
<input v-model="spanningEnabled" type="checkbox" />
Row spanning
</label>
<label v-for="columnId in ['team', 'shift']" :key="columnId">
<input
type="checkbox"
:checked="table.getColumn(columnId)!.getIsVisible()"
@change="
table.getColumn(columnId)!.getToggleVisibilityHandler()($event)
"
/>
{{ String(table.getColumn(columnId)!.columnDef.header) }}
</label>
<select
data-testid="status-filter"
:value="
(table.getColumn('status')!.getFilterValue() as string | undefined) ??
''
"
@change="
table
.getColumn('status')!
.setFilterValue(
($event.target as HTMLSelectElement).value || undefined,
)
"
>
<option value="">All statuses</option>
<option value="Approved">Approved</option>
<option value="Pending">Pending</option>
<option value="Rejected">Rejected</option>
</select>
<input
data-testid="employee-filter"
class="filter-input"
placeholder="Filter employees..."
:value="
(table.getColumn('employee')!.getFilterValue() as
string | undefined) ?? ''
"
@input="
table
.getColumn('employee')!
.setFilterValue(
($event.target as HTMLInputElement).value || undefined,
)
"
/>
</div>
<div class="spacer-sm" />
<div class="controls">
<button
class="demo-button-sm"
:disabled="!table.getCanPreviousPage()"
@click="table.previousPage()"
>
{{ '<' }}
</button>
<button
class="demo-button-sm"
:disabled="!table.getCanNextPage()"
@click="table.nextPage()"
>
{{ '>' }}
</button>
<span>
Page {{ table.atoms.pagination.get().pageIndex + 1 }} of
{{ table.getPageCount() }}
</span>
<select
data-testid="page-size"
:value="table.atoms.pagination.get().pageSize"
@change="
table.setPageSize(Number(($event.target as HTMLSelectElement).value))
"
>
<option
v-for="pageSize in [10, 12, 36]"
:key="pageSize"
:value="pageSize"
>
Show {{ pageSize }}
</option>
</select>
<span>
Visible columns:
<span data-testid="visible-leaf-count">{{
table.getVisibleLeafColumns().length
}}</span>
</span>
<span>
Selected cells:
<span data-testid="selected-count">{{
table.getSelectedCellCount()
}}</span>
</span>
</div>
<div class="spacer-md" />
<div class="example-grid">
<section class="example-panel">
<h2 class="section-title">Row Spanning</h2>
<table data-testid="span-table">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
>
<button
type="button"
class="sortable-header header-sort-button"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender :header="header" />
{{
{ asc: ' 🔼', desc: ' 🔽' }[
header.column.getIsSorted() as string
] ?? ''
}}
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<template v-for="cell in row.getVisibleCells()" :key="cell.id">
<td
v-if="cell.getRowSpan() !== 0 && cell.getColSpan() !== 0"
:rowSpan="cell.getRowSpan()"
:colSpan="cell.getColSpan()"
:class="getCellClassName(cell)"
@mousedown="cell.getSelectionStartHandler()($event)"
@mouseenter="cell.getSelectionExtendHandler()($event)"
>
<FlexRender :cell="cell" />
</td>
</template>
</tr>
</tbody>
</table>
</section>
<section class="example-panel">
<h2 class="section-title">Reference (no spanning)</h2>
<table data-testid="reference-table">
<thead>
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
>
<button
type="button"
class="sortable-header header-sort-button"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<FlexRender :header="header" />
{{
{ asc: ' 🔼', desc: ' 🔽' }[
header.column.getIsSorted() as string
] ?? ''
}}
</button>
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in table.getRowModel().rows" :key="row.id">
<td v-for="cell in row.getVisibleCells()" :key="cell.id">
<FlexRender :cell="cell" />
</td>
</tr>
</tbody>
</table>
</section>
<section class="example-panel">
<h2 class="section-title">Summary Rows (colSpan)</h2>
<table data-testid="summary-table">
<thead>
<tr
v-for="headerGroup in summaryTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:colSpan="header.colSpan"
>
<FlexRender :header="header" />
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in summaryTable.getRowModel().rows"
:key="row.id"
:class="
row.original.kind === 'subtotal' ? 'subtotal-row' : undefined
"
>
<template v-for="cell in row.getVisibleCells()" :key="cell.id">
<td
v-if="cell.getRowSpan() !== 0 && cell.getColSpan() !== 0"
:rowSpan="cell.getRowSpan()"
:colSpan="cell.getColSpan()"
>
<FlexRender :cell="cell" />
</td>
</template>
</tr>
</tbody>
</table>
</section>
</div>
<div class="spacer-md" />
<pre data-testid="table-state">{{
JSON.stringify(table.store.get(), null, 2)
}}</pre>
</div>
</template>