import { customElement, state } from 'lit/decorators.js'
import { LitElement, css, html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import {
FlexRender,
TableController,
cellSelectionFeature,
cellSpanningFeature,
columnFilteringFeature,
columnVisibilityFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
rowPaginationFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
tableFeatures,
} from '@tanstack/lit-table'
import { makeData, makeSummaryData } from './makeData'
import type { Cell } from '@tanstack/lit-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(' ')
}
@customElement('lit-cell-spanning-example')
export class LitCellSpanningExample extends LitElement {
private tableController = new TableController<typeof features, Shift>(this)
private summaryTableController = new TableController<
typeof summaryFeatures,
SummaryRow
>(this)
@state()
private _data = makeData()
@state()
private _spanningEnabled = true
private _summaryData = makeSummaryData()
static styles = css`
:host {
display: block;
font-family: sans-serif;
font-size: 14px;
}
table {
border: 1px solid lightgray;
border-collapse: collapse;
border-spacing: 0;
}
tbody {
border-bottom: 1px solid lightgray;
}
th {
border-bottom: 1px solid lightgray;
border-right: 1px solid lightgray;
padding: 2px 4px;
}
.demo-root {
padding: 0.5rem;
}
.spacer-sm {
height: 0.5rem;
}
.spacer-md {
height: 1rem;
}
.controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.demo-button,
.filter-input {
border: 1px solid currentColor;
border-radius: 0.25rem;
}
.demo-button {
padding: 0.5rem;
}
.demo-button-sm {
padding: 0.25rem;
}
.filter-input {
width: 6rem;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.2);
}
.section-title {
font-size: 1.25rem;
}
.sortable-header {
cursor: pointer;
user-select: none;
}
/* Lays the example panels side by side when the viewport allows it. */
.example-grid {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 1rem 1.5rem;
}
.example-panel {
flex: 0 1 auto;
}
/* Cell spanning demo styling. Cell borders make the merged cells obvious. */
td {
border: 1px solid lightgray;
padding: 2px 4px;
}
.span-cell {
vertical-align: middle;
background: #f8fafc;
font-weight: 600;
}
.subtotal-row {
background: #f3f4f6;
font-weight: 600;
}
.header-sort-button {
border: none;
background: none;
font: inherit;
font-weight: 600;
padding: 0;
}
/* Cell selection over merged cells: the tint and outline land on the
rendered anchor, drawn with inset box-shadows so a border-collapse table
never changes row heights as cells become selected. */
.cell-selectable {
user-select: none;
cursor: cell;
--cell-edge-top: 0 0 0 0 transparent;
--cell-edge-right: 0 0 0 0 transparent;
--cell-edge-bottom: 0 0 0 0 transparent;
--cell-edge-left: 0 0 0 0 transparent;
box-shadow:
inset var(--cell-edge-top),
inset var(--cell-edge-right),
inset var(--cell-edge-bottom),
inset var(--cell-edge-left);
}
.cell-selected {
background: #dbeafe;
}
.cell-focused {
background: #bfdbfe;
}
.cell-edge-top {
--cell-edge-top: 0 2px 0 0 #2563eb;
}
.cell-edge-right {
--cell-edge-right: -2px 0 0 0 #2563eb;
}
.cell-edge-bottom {
--cell-edge-bottom: 0 -2px 0 0 #2563eb;
}
.cell-edge-left {
--cell-edge-left: 2px 0 0 0 #2563eb;
}
`
protected render() {
const table = this.tableController.table(
{
debugTable: true,
features,
columns,
data: this._data,
enableCellSpanning: this._spanningEnabled,
initialState: {
pagination: { pageIndex: 0, pageSize: 12 },
},
},
(state) => state,
)
const summaryTable = this.summaryTableController.table(
{
debugTable: true,
features: summaryFeatures,
columns: summaryColumns,
data: this._summaryData,
},
(state) => state,
)
const visibleLeafCount = table.getVisibleLeafColumns().length
const renderHead = () => html`
<thead>
${repeat(
table.getHeaderGroups(),
(headerGroup) => headerGroup.id,
(headerGroup) => html`
<tr>
${repeat(
headerGroup.headers,
(header) => header.id,
(header) => html`
<th colspan="${header.colSpan}">
<button
type="button"
class="sortable-header header-sort-button"
@click=${header.column.getToggleSortingHandler()}
>
${FlexRender({ header })}
${
{ asc: ' 🔼', desc: ' 🔽' }[
header.column.getIsSorted() as string
] ?? ''
}
</button>
</th>
`,
)}
</tr>
`,
)}
</thead>
`
return html`
<div class="demo-root">
<div class="controls">
<button class="demo-button" @click=${() => (this._data = makeData())}>
Regenerate Data
</button>
<label>
<input
type="checkbox"
.checked=${this._spanningEnabled}
@change=${(event: Event) =>
(this._spanningEnabled = (
event.target as HTMLInputElement
).checked)}
/>
Row spanning
</label>
${['team', 'shift'].map((columnId) => {
const column = table.getColumn(columnId)!
return html`
<label>
<input
type="checkbox"
.checked=${column.getIsVisible()}
@change=${column.getToggleVisibilityHandler()}
/>
${String(column.columnDef.header)}
</label>
`
})}
<select
data-testid="status-filter"
.value=${
(table.getColumn('status')!.getFilterValue() as
string | undefined) ?? ''
}
@change=${(event: Event) =>
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=${(event: Event) =>
table
.getColumn('employee')!
.setFilterValue(
(event.target as HTMLInputElement).value || undefined,
)}
/>
</div>
<div class="spacer-sm"></div>
<div class="controls">
<button
class="demo-button-sm"
@click=${() => table.previousPage()}
?disabled=${!table.getCanPreviousPage()}
>
${'<'}
</button>
<button
class="demo-button-sm"
@click=${() => table.nextPage()}
?disabled=${!table.getCanNextPage()}
>
${'>'}
</button>
<span>
Page ${table.state.pagination.pageIndex + 1} of
${table.getPageCount()}
</span>
<select
data-testid="page-size"
.value=${String(table.state.pagination.pageSize)}
@change=${(event: Event) =>
table.setPageSize(
Number((event.target as HTMLSelectElement).value),
)}
>
${[10, 12, 36].map(
(pageSize) => html`
<option
value="${pageSize}"
?selected=${table.state.pagination.pageSize === 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 class="spacer-md"></div>
<!-- The panels wrap into a grid whenever the viewport is wide enough. -->
<div class="example-grid">
<section class="example-panel">
<h2 class="section-title">Row Spanning</h2>
<table data-testid="span-table">
${renderHead()}
<tbody>
${repeat(
table.getRowModel().rows,
(row) => row.id,
(row) => html`
<tr>
${row.getVisibleCells().map((cell) => {
const rowSpan = cell.getRowSpan()
const colSpan = cell.getColSpan()
if (rowSpan === 0 || colSpan === 0) return null
return html`
<td
rowspan="${rowSpan}"
colspan="${colSpan}"
class=${getCellClassName(cell)}
@mousedown=${cell.getSelectionStartHandler()}
@mouseenter=${cell.getSelectionExtendHandler()}
>
${FlexRender({ cell })}
</td>
`
})}
</tr>
`,
)}
</tbody>
</table>
</section>
<section class="example-panel">
<h2 class="section-title">Reference (no spanning)</h2>
<!-- The same table instance rendered flat. Under every sort,
filter, and page combination the merged panel must describe
exactly this grid. -->
<table data-testid="reference-table">
${renderHead()}
<tbody>
${repeat(
table.getRowModel().rows,
(row) => row.id,
(row) => html`
<tr>
${row
.getVisibleCells()
.map((cell) => html`<td>${FlexRender({ cell })}</td>`)}
</tr>
`,
)}
</tbody>
</table>
</section>
<section class="example-panel">
<h2 class="section-title">Summary Rows (colSpan)</h2>
<table data-testid="summary-table">
<thead>
${repeat(
summaryTable.getHeaderGroups(),
(headerGroup) => headerGroup.id,
(headerGroup) => html`
<tr>
${repeat(
headerGroup.headers,
(header) => header.id,
(header) => html`
<th colspan="${header.colSpan}">
${FlexRender({ header })}
</th>
`,
)}
</tr>
`,
)}
</thead>
<tbody>
${repeat(
summaryTable.getRowModel().rows,
(row) => row.id,
(row) => html`
<tr
class=${
row.original.kind === 'subtotal' ? 'subtotal-row' : ''
}
>
${row.getVisibleCells().map((cell) => {
const rowSpan = cell.getRowSpan()
const colSpan = cell.getColSpan()
if (rowSpan === 0 || colSpan === 0) return null
return html`
<td rowspan="${rowSpan}" colspan="${colSpan}">
${FlexRender({ cell })}
</td>
`
})}
</tr>
`,
)}
</tbody>
</table>
</section>
</div>
<div class="spacer-md"></div>
<pre data-testid="table-state">
${JSON.stringify(table.state, null, 2)}</pre>
</div>
`
}
}
declare global {
interface HTMLElementTagNameMap {
'lit-cell-spanning-example': LitCellSpanningExample
}
}