import { useState } from 'preact/hooks'
import { render } from 'preact'
import './index.css'
import {
cellSelectionFeature,
cellSpanningFeature,
columnFilteringFeature,
columnVisibilityFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
rowPaginationFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
tableFeatures,
useTable,
} from '@tanstack/preact-table'
import { makeData, makeSummaryData } from './makeData'
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: {
getRowSpan: () => number
getIsSelected: () => boolean
getIsFocused: () => boolean
getSelectionEdges: () => {
top: boolean
right: boolean
bottom: boolean
left: boolean
}
}): 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(' ')
}
function App() {
const [data, setData] = useState(() => makeData())
const [summaryData] = useState(() => makeSummaryData())
const [spanningEnabled, setSpanningEnabled] = useState(true)
const refreshData = () => setData(makeData())
const table = useTable(
{
debugTable: true,
features,
columns,
data,
enableCellSpanning: spanningEnabled,
initialState: {
pagination: { pageIndex: 0, pageSize: 12 },
},
},
(state) => state,
)
const summaryTable = useTable(
{
debugTable: true,
features: summaryFeatures,
columns: summaryColumns,
data: summaryData,
},
(state) => state,
)
const visibleLeafCount = table.getVisibleLeafColumns().length
const renderHead = () => (
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan}>
<button
type="button"
className="sortable-header header-sort-button"
onClick={header.column.getToggleSortingHandler()}
>
<table.FlexRender header={header} />
{{ asc: ' 🔼', desc: ' 🔽' }[
header.column.getIsSorted() as string
] ?? null}
</button>
</th>
))}
</tr>
))}
</thead>
)
return (
<div className="demo-root">
<div className="controls">
<button onClick={() => refreshData()} className="demo-button">
Regenerate Data
</button>
<label>
<input
type="checkbox"
checked={spanningEnabled}
onChange={(event) =>
setSpanningEnabled(event.currentTarget.checked)
}
/>{' '}
Row spanning
</label>
{['team', 'shift'].map((columnId) => {
const column = table.getColumn(columnId)!
return (
<label key={columnId}>
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>{' '}
{String(column.columnDef.header)}
</label>
)
})}
<select
data-testid="status-filter"
value={
(table.getColumn('status')!.getFilterValue() as
string | undefined) ?? ''
}
onChange={(event) =>
table
.getColumn('status')!
.setFilterValue(event.currentTarget.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"
className="filter-input"
placeholder="Filter employees..."
value={
(table.getColumn('employee')!.getFilterValue() as
string | undefined) ?? ''
}
onInput={(event) =>
table
.getColumn('employee')!
.setFilterValue(event.currentTarget.value || undefined)
}
/>
</div>
<div className="spacer-sm" />
<div className="controls">
<button
className="demo-button-sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<button
className="demo-button-sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<span>
Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<select
data-testid="page-size"
value={table.state.pagination.pageSize}
onChange={(event) =>
table.setPageSize(Number(event.currentTarget.value))
}
>
{[10, 12, 36].map((pageSize) => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
<span>
Visible columns:{' '}
<span data-testid="visible-leaf-count">{visibleLeafCount}</span>
</span>
<span>
Selected cells:{' '}
<span data-testid="selected-count">
{table.getSelectedCellCount()}
</span>
</span>
</div>
<div className="spacer-md" />
{}
<div className="example-grid">
<section className="example-panel">
<h2 className="section-title">Row Spanning</h2>
<table data-testid="span-table">
{renderHead()}
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => {
const rowSpan = cell.getRowSpan()
const colSpan = cell.getColSpan()
if (rowSpan === 0 || colSpan === 0) return null
return (
<td
key={cell.id}
rowSpan={rowSpan}
colSpan={colSpan}
className={getCellClassName(cell)}
onMouseDown={cell.getSelectionStartHandler()}
onMouseEnter={cell.getSelectionExtendHandler()}
>
<table.FlexRender cell={cell} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
</section>
<section className="example-panel">
<h2 className="section-title">Reference (no spanning)</h2>
{}
<table data-testid="reference-table">
{renderHead()}
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
<table.FlexRender cell={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
</section>
<section className="example-panel">
<h2 className="section-title">Summary Rows (colSpan)</h2>
<table data-testid="summary-table">
<thead>
{summaryTable.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan}>
<summaryTable.FlexRender header={header} />
</th>
))}
</tr>
))}
</thead>
<tbody>
{summaryTable.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={
row.original.kind === 'subtotal'
? 'subtotal-row'
: undefined
}
>
{row.getVisibleCells().map((cell) => {
const rowSpan = cell.getRowSpan()
const colSpan = cell.getColSpan()
if (rowSpan === 0 || colSpan === 0) return null
return (
<td key={cell.id} rowSpan={rowSpan} colSpan={colSpan}>
<summaryTable.FlexRender cell={cell} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
</section>
</div>
<div className="spacer-md" />
<pre data-testid="table-state">
{JSON.stringify(table.state, null, 2)}
</pre>
</div>
)
}
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
render(<App />, rootElement)