import { customElement, state } from 'lit/decorators.js'
import { LitElement, html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { styleMap } from 'lit/directives/style-map.js'
import { faker } from '@faker-js/faker'
import {
FlexRender,
TableController,
aggregationFns,
createColumnHelper,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFns,
sortFns,
stockFeatures,
} from '@tanstack/lit-table'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import { makeData } from './makeData'
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import type { Person } from './makeData'
import type {
Cell,
CellData,
Column,
ColumnDef,
FilterFn,
Header,
LitTable,
Row,
RowData,
SortFn,
TableFeatures,
} from '@tanstack/lit-table'
declare module '@tanstack/lit-table' {
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue extends CellData = CellData,
> {
filterVariant?: 'text' | 'range' | 'select'
}
interface FilterFns {
fuzzy: FilterFn<typeof stockFeatures, Person>
}
interface FilterMeta {
itemRank?: RankingInfo
}
}
const fuzzyFilter: FilterFn<typeof stockFeatures, Person> = (
row,
columnId,
value,
addMeta,
) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta?.({ itemRank })
return itemRank.passed
}
const fuzzySort: SortFn<typeof stockFeatures, Person> = (
rowA,
rowB,
columnId,
) => {
let dir = 0
if (rowA.columnFiltersMeta[columnId]) {
dir = compareItems(
rowA.columnFiltersMeta[columnId].itemRank!,
rowB.columnFiltersMeta[columnId].itemRank!,
)
}
return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir
}
const sortStatusFn: SortFn<typeof stockFeatures, Person> = (rowA, rowB) => {
const statusOrder = ['single', 'complicated', 'relationship']
return (
statusOrder.indexOf(rowA.original.status) -
statusOrder.indexOf(rowB.original.status)
)
}
const columnHelper = createColumnHelper<typeof stockFeatures, Person>()
const columns: Array<ColumnDef<typeof stockFeatures, Person>> =
columnHelper.columns([
columnHelper.display({
id: 'select',
size: 80,
minSize: 80,
maxSize: 80,
enableSorting: false,
enableGrouping: false,
enableHiding: false,
enableResizing: false,
header: '',
cell: '',
}),
columnHelper.accessor('firstName', {
id: 'firstName',
size: 200,
header: 'First Name',
filterFn: 'fuzzy',
sortFn: fuzzySort,
meta: { filterVariant: 'text' },
getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
}),
columnHelper.accessor((row) => row.lastName, {
id: 'lastName',
size: 180,
header: 'Last Name',
meta: { filterVariant: 'text' },
}),
columnHelper.accessor('age', {
id: 'age',
size: 200,
header: 'Age',
meta: { filterVariant: 'range' },
aggregationFn: 'median',
aggregatedCell: ({ getValue }) =>
Math.round(getValue<number>() * 100) / 100,
}),
columnHelper.accessor('visits', {
id: 'visits',
size: 200,
header: 'Visits',
meta: { filterVariant: 'range' },
aggregationFn: 'sum',
aggregatedCell: ({ getValue }) => getValue<number>().toLocaleString(),
}),
columnHelper.accessor('status', {
id: 'status',
size: 200,
header: 'Status',
sortFn: sortStatusFn,
meta: { filterVariant: 'select' },
}),
columnHelper.accessor('progress', {
id: 'progress',
size: 200,
header: 'Profile Progress',
meta: { filterVariant: 'range' },
aggregationFn: 'mean',
cell: ({ getValue }) => `${Math.round(getValue<number>() * 100) / 100}%`,
aggregatedCell: ({ getValue }) =>
`${Math.round(getValue<number>() * 100) / 100}%`,
}),
])
@customElement('lit-table-example')
class LitTableExample extends LitElement {
@state()
private _data: Array<Person> = makeData(1_000)
private tableController = new TableController<typeof stockFeatures, Person>(
this,
)
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
protected render() {
const table = this.tableController.table(
{
features: stockFeatures,
rowModels: {
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel({
...filterFns,
fuzzy: fuzzyFilter,
}),
facetedRowModel: createFacetedRowModel(),
facetedMinMaxValues: createFacetedMinMaxValues(),
facetedUniqueValues: createFacetedUniqueValues(),
groupedRowModel: createGroupedRowModel(aggregationFns),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
columns,
data: this._data,
getSubRows: (row) => row.subRows,
globalFilterFn: 'fuzzy',
columnResizeMode: 'onChange',
defaultColumn: { minSize: 200, maxSize: 800 },
initialState: {
columnOrder: columns.map((c) => c.id!),
columnPinning: { left: ['select'], right: [] },
pagination: { pageIndex: 0, pageSize: 20 },
},
keepPinnedRows: true,
debugTable: true,
},
(state) => state,
)
return html`
<div class="demo-root">
<h1>Kitchen Sink - All Features</h1>
<div class="toolbar">
<div class="toolbar-row">
<input
class="global-filter-input"
placeholder="Fuzzy search all columns..."
.value=${String(table.state.globalFilter ?? '')}
@input=${(event: InputEvent) =>
this.debounceSet('global', () =>
table.setGlobalFilter(
(event.target as HTMLInputElement).value,
),
)}
/>
</div>
<div class="toolbar-row">
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(1_000))}
>
Flat 1k
</button>
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(100, 5, 3))}
>
Nested 100x5x3
</button>
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(10_000))}
>
Stress 10k (flat)
</button>
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(100_000))}
>
Stress 100k (flat)
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.reset()}
>
Reset Table
</button>
<button
class="demo-button demo-button-sm"
@click=${() => this.shuffleColumns(table)}
>
Shuffle Columns
</button>
<span class="nowrap">
${table.getSelectedRowModel().flatRows.length.toLocaleString()} of
${table.getCoreRowModel().flatRows.length.toLocaleString()}
selected
</span>
</div>
<details class="column-toggle-panel">
<summary class="column-toggle-panel-header">
Column visibility
</summary>
<div class="column-toggle-row">
<label>
<input
type="checkbox"
?checked=${table.getIsAllColumnsVisible()}
@change=${table.getToggleAllColumnsVisibilityHandler()}
/>
Toggle All
</label>
</div>
${table.getAllLeafColumns().map(
(column) => html`
<div class="column-toggle-row">
<label>
<input
type="checkbox"
?checked=${column.getIsVisible()}
?disabled=${!column.getCanHide()}
@change=${column.getToggleVisibilityHandler()}
/>
${column.id}
</label>
</div>
`,
)}
</details>
</div>
<div class="table-container">
<table style=${this.tableStyle(table)}>
<thead>
${repeat(
table.getHeaderGroups(),
(headerGroup) => headerGroup.id,
(headerGroup) => html`
<tr>
${headerGroup.headers.map(
(header) => html`
<th
colspan=${header.colSpan}
style=${styleMap(this.headerStyle(header))}
>
${header.isPlaceholder
? null
: this.renderHeader(table, header)}
</th>
`,
)}
</tr>
`,
)}
</thead>
<tbody>
${table
.getTopRows()
.map((row) => this.renderPinnedRow(table, row))}
${table.getCenterRows().map(
(row) => html`
<tr>
${row
.getVisibleCells()
.map((cell) => this.renderCell(table, cell))}
</tr>
`,
)}
${table
.getBottomRows()
.map((row) => this.renderPinnedRow(table, row))}
</tbody>
</table>
</div>
<div class="spacer-sm"></div>
<div class="controls">
<button
class="demo-button demo-button-sm"
@click=${() => table.setPageIndex(0)}
?disabled=${!table.getCanPreviousPage()}
>
<<
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.previousPage()}
?disabled=${!table.getCanPreviousPage()}
>
<
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.nextPage()}
?disabled=${!table.getCanNextPage()}
>
>
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.setPageIndex(table.getPageCount() - 1)}
?disabled=${!table.getCanNextPage()}
>
>>
</button>
<span class="inline-controls">
<div>Page</div>
<strong>
${(table.state.pagination.pageIndex + 1).toLocaleString()} of
${table.getPageCount().toLocaleString()}
</strong>
</span>
<span class="inline-controls">
| Go to page:
<input
type="number"
min="1"
max=${table.getPageCount()}
.value=${String(table.state.pagination.pageIndex + 1)}
@input=${(event: InputEvent) => {
const page = (event.target as HTMLInputElement).value
? Number((event.target as HTMLInputElement).value) - 1
: 0
table.setPageIndex(page)
}}
class="page-size-input"
/>
</span>
<select
.value=${String(table.state.pagination.pageSize)}
@change=${(event: Event) =>
table.setPageSize(
Number((event.target as HTMLSelectElement).value),
)}
>
${[10, 20, 30, 50, 100].map(
(pageSize) =>
html`<option value=${pageSize}>Show ${pageSize}</option>`,
)}
</select>
</div>
<div class="spacer-sm"></div>
<div class="nowrap">
${table.getRowModel().rows.length.toLocaleString()} rows on this page
(${table.getFilteredRowModel().rows.length.toLocaleString()} filtered
of ${table.getCoreRowModel().rows.length.toLocaleString()} total)
</div>
<div class="spacer-md"></div>
<details>
<summary>Table state (live)</summary>
<pre class="state-dump">${JSON.stringify(table.state, null, 2)}</pre>
</details>
</div>
${this.styles()}
`
}
private renderHeader(
table: LitTable<typeof stockFeatures, Person>,
header: Header<typeof stockFeatures, Person, unknown>,
) {
const column = header.column
return html`
<div class="header-row">
<div style="flex: 1; min-width: 0">
<div class="header-controls">
${column.getCanPin()
? html`
<span class="pin-actions">
${column.getIsPinned() !== 'left'
? html`<button
class="pin-button"
@click=${() => column.pin('left')}
>
<
</button>`
: null}
${column.getIsPinned()
? html`<button
class="pin-button"
@click=${() => column.pin(false)}
>
x
</button>`
: null}
${column.getIsPinned() !== 'right'
? html`<button
class="pin-button"
@click=${() => column.pin('right')}
>
>
</button>`
: null}
</span>
`
: null}
${column.getCanGroup()
? html`<button
class="pin-button"
@click=${column.getToggleGroupingHandler()}
>
${column.getIsGrouped()
? `Stop (${column.getGroupedIndex()})`
: 'Group'}
</button>`
: null}
</div>
${column.id === 'select'
? html`<input
type="checkbox"
?checked=${table.getIsAllPageRowsSelected()}
.indeterminate=${table.getIsSomePageRowsSelected()}
@change=${table.getToggleAllPageRowsSelectedHandler()}
/>`
: column.getCanSort()
? html`<span
class="sortable-header"
@click=${column.getToggleSortingHandler()}
>
${FlexRender({ header })}
${column.getIsSorted() === 'asc'
? ' â–²'
: column.getIsSorted() === 'desc'
? ' â–¼'
: ''}
</span>`
: FlexRender({ header })}
${column.getCanFilter() ? this.renderFilter(column) : null}
</div>
</div>
${column.getCanResize()
? html`<div
@dblclick=${() => column.resetSize()}
@mousedown=${header.getResizeHandler()}
@touchstart=${header.getResizeHandler()}
class="resizer ${column.getIsResizing() ? 'isResizing' : ''}"
></div>`
: null}
`
}
private renderFilter(column: Column<typeof stockFeatures, Person>) {
const filterVariant = column.columnDef.meta?.filterVariant
const columnFilterValue = column.getFilterValue()
const minMaxValues =
filterVariant === 'range' ? column.getFacetedMinMaxValues() : undefined
if (filterVariant === 'range') {
return html`
<div class="filter-row">
<input
type="number"
class="filter-input"
min=${Number(minMaxValues?.[0] ?? '')}
max=${Number(minMaxValues?.[1] ?? '')}
.value=${String(
(columnFilterValue as [number, number] | undefined)?.[0] ?? '',
)}
placeholder=${`Min${minMaxValues?.[0] !== undefined ? ` (${minMaxValues[0]})` : ''}`}
@input=${(event: InputEvent) =>
this.debounceSet(`${column.id}-min`, () =>
column.setFilterValue((old: [number, number] | undefined) => [
(event.target as HTMLInputElement).value,
old?.[1],
]),
)}
/>
<input
type="number"
class="filter-input"
min=${Number(minMaxValues?.[0] ?? '')}
max=${Number(minMaxValues?.[1] ?? '')}
.value=${String(
(columnFilterValue as [number, number] | undefined)?.[1] ?? '',
)}
placeholder=${`Max${minMaxValues?.[1] !== undefined ? ` (${minMaxValues[1]})` : ''}`}
@input=${(event: InputEvent) =>
this.debounceSet(`${column.id}-max`, () =>
column.setFilterValue((old: [number, number] | undefined) => [
old?.[0],
(event.target as HTMLInputElement).value,
]),
)}
/>
</div>
`
}
const sortedUniqueValues = Array.from(
column.getFacetedUniqueValues().keys(),
)
.sort()
.slice(0, 5000)
if (filterVariant === 'select') {
return html`
<select
class="filter-select"
.value=${String(columnFilterValue ?? '')}
@change=${(event: Event) =>
column.setFilterValue((event.target as HTMLSelectElement).value)}
>
<option value="">All</option>
${sortedUniqueValues.map(
(value) =>
html`<option value=${String(value)}>${String(value)}</option>`,
)}
</select>
`
}
return html`
<datalist id=${column.id + 'list'}>
${sortedUniqueValues.map(
(value) => html`<option value=${String(value)}></option>`,
)}
</datalist>
<input
type="text"
class="filter-select"
list=${column.id + 'list'}
.value=${String(columnFilterValue ?? '')}
placeholder=${`Search (${column.getFacetedUniqueValues().size})`}
@input=${(event: InputEvent) =>
this.debounceSet(column.id, () =>
column.setFilterValue((event.target as HTMLInputElement).value),
)}
/>
`
}
private renderPinnedRow(
table: LitTable<typeof stockFeatures, Person>,
row: Row<typeof stockFeatures, Person>,
) {
return html`
<tr class="pinned-row" style=${styleMap(this.pinnedRowStyle(table, row))}>
${row.getVisibleCells().map((cell) => this.renderCell(table, cell))}
</tr>
`
}
private renderCell(
table: LitTable<typeof stockFeatures, Person>,
cell: Cell<typeof stockFeatures, Person, unknown>,
) {
return html`
<td
style=${styleMap(this.cellStyle(cell))}
class=${this.cellClass(table, cell) ?? ''}
>
${cell.column.id === 'select'
? html`<div class="column-toggle-row">
<input
type="checkbox"
?checked=${cell.row.getIsSelected()}
?disabled=${!cell.row.getCanSelect()}
.indeterminate=${cell.row.getIsSomeSelected()}
@change=${cell.row.getToggleSelectedHandler()}
/>
<button
class="pin-button"
@click=${() =>
cell.row.pin(
cell.row.getIsPinned() === 'top' ? false : 'top',
)}
>
${cell.row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'}
</button>
</div>`
: cell.column.id === 'firstName'
? html`<div style="padding-left: ${cell.row.depth * 1.5}rem">
${cell.row.getCanExpand()
? html`<button
@click=${cell.row.getToggleExpandedHandler()}
style="cursor: pointer; margin-right: 0.25rem"
>
${cell.row.getIsExpanded() ? 'v' : '>'}
</button>`
: html`<span style="margin-right: 0.25rem">-</span>`}
${FlexRender({ cell })}
</div>`
: cell.getIsGrouped()
? html`<button
@click=${cell.row.getToggleExpandedHandler()}
style="cursor: ${cell.row.getCanExpand()
? 'pointer'
: 'normal'}"
>
${cell.row.getIsExpanded() ? 'v' : '>'}
${FlexRender({ cell })}
(${cell.row.subRows.length.toLocaleString()})
</button>`
: FlexRender({ cell })}
</td>
`
}
private debounceSet(key: string, setValue: () => void) {
clearTimeout(this.debounceTimers.get(key))
this.debounceTimers.set(
key,
setTimeout(() => {
setValue()
this.debounceTimers.delete(key)
}, 300),
)
}
private shuffleColumns(table: LitTable<typeof stockFeatures, Person>) {
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
}
private getCommonPinningStyle(column: Column<typeof stockFeatures, Person>) {
const isPinned = column.getIsPinned()
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left')
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right')
return {
boxShadow: isLastLeftPinnedColumn
? '-4px 0 4px -4px gray inset'
: isFirstRightPinnedColumn
? '4px 0 4px -4px gray inset'
: undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
opacity: isPinned ? '0.97' : '1',
position: isPinned ? 'sticky' : 'relative',
zIndex: isPinned ? '1' : '0',
}
}
private headerStyle(header: Header<typeof stockFeatures, Person, unknown>) {
return {
...this.getCommonPinningStyle(header.column),
whiteSpace: 'nowrap',
width: `calc(var(--header-${header.id}-size) * 1px)`,
}
}
private cellStyle(cell: Cell<typeof stockFeatures, Person, unknown>) {
return {
...this.getCommonPinningStyle(cell.column),
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}
}
private pinnedRowStyle(
table: LitTable<typeof stockFeatures, Person>,
row: Row<typeof stockFeatures, Person>,
) {
const bottomRows = table.getBottomRows()
return {
position: 'sticky',
top:
row.getIsPinned() === 'top'
? `${row.getPinnedIndex() * 32 + 48}px`
: undefined,
bottom:
row.getIsPinned() === 'bottom'
? `${(bottomRows.length - 1 - row.getPinnedIndex()) * 32}px`
: undefined,
zIndex: '1',
}
}
private cellClass(
table: LitTable<typeof stockFeatures, Person>,
cell: Cell<typeof stockFeatures, Person, unknown>,
) {
const groupingActive = table.state.grouping.length > 0
const hasAggregation = !!cell.column.columnDef.aggregationFn
return !groupingActive
? undefined
: cell.getIsGrouped()
? 'cell-grouped'
: hasAggregation && cell.getIsAggregated()
? 'cell-aggregated'
: cell.getIsPlaceholder()
? 'cell-placeholder'
: undefined
}
private tableStyle(table: LitTable<typeof stockFeatures, Person>) {
const styles = [`width: ${table.getTotalSize()}px`]
for (const header of table.getFlatHeaders()) {
styles.push(`--header-${header.id}-size: ${header.getSize()}`)
styles.push(`--col-${header.column.id}-size: ${header.column.getSize()}`)
}
return styles.join('; ')
}
private styles() {
return html`<style>
* {
box-sizing: border-box;
font-family: sans-serif;
font-size: 14px;
}
.table-container {
overflow-x: auto;
width: 100%;
max-width: 1400px;
border: 1px solid lightgray;
}
table {
border-collapse: collapse;
border-spacing: 0;
table-layout: fixed;
}
th {
background-color: #f3f4f6;
border-bottom: 1px solid lightgray;
border-right: 1px solid lightgray;
font-weight: bold;
padding: 2px 4px;
position: relative;
text-align: left;
vertical-align: top;
}
td {
background-color: white;
border-bottom: 1px solid #eee;
padding: 2px 4px;
vertical-align: middle;
}
.resizer {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 5px;
background: rgba(0, 0, 0, 0.5);
cursor: col-resize;
user-select: none;
touch-action: none;
z-index: 2;
}
.resizer.isResizing {
background: blue;
}
.demo-root {
padding: 0.5rem;
}
.spacer-sm {
height: 0.5rem;
}
.spacer-md {
height: 1rem;
}
.controls,
.button-row,
.inline-controls,
.pin-actions,
.filter-row,
.header-row,
.header-controls,
.toolbar-row {
display: flex;
align-items: center;
}
.header-row,
.inline-controls,
.pin-actions,
.filter-row,
.header-controls,
.controls,
.toolbar-row {
gap: 0.5rem;
}
.toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid lightgray;
margin-bottom: 0.5rem;
}
.toolbar-row {
flex-wrap: wrap;
}
.column-toggle-panel {
display: inline-block;
border: 1px solid #000;
border-radius: 0.25rem;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.2);
padding: 0.25rem;
}
.column-toggle-panel-header {
border-bottom: 1px solid #000;
padding: 0 0.25rem;
margin-bottom: 0.25rem;
}
.column-toggle-row {
padding: 0 0.25rem;
}
.demo-button,
.pin-button,
.filter-input,
.filter-select,
.page-size-input,
.global-filter-input {
border: 1px solid currentColor;
border-radius: 0.25rem;
}
.demo-button {
padding: 0.5rem;
}
.demo-button-sm {
padding: 0.25rem;
}
.pin-button {
padding: 0 0.25rem;
cursor: pointer;
background: transparent;
font-size: 0.875rem;
}
.nowrap {
white-space: nowrap;
}
.page-size-input {
width: 4rem;
padding: 0.25rem;
}
.filter-input {
width: 6rem;
}
.filter-select {
width: 9rem;
}
.global-filter-input {
padding: 0.5rem;
width: 100%;
max-width: 24rem;
}
.sortable-header {
cursor: pointer;
user-select: none;
}
.cell-grouped {
background: #0aff0082;
}
.cell-aggregated {
background: #ffa50078;
}
.cell-placeholder {
background: #ff000042;
}
.state-dump {
max-height: 24rem;
overflow: auto;
}
</style>`
}
}
import { customElement, state } from 'lit/decorators.js'
import { LitElement, html } from 'lit'
import { repeat } from 'lit/directives/repeat.js'
import { styleMap } from 'lit/directives/style-map.js'
import { faker } from '@faker-js/faker'
import {
FlexRender,
TableController,
aggregationFns,
createColumnHelper,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFns,
sortFns,
stockFeatures,
} from '@tanstack/lit-table'
import { compareItems, rankItem } from '@tanstack/match-sorter-utils'
import { makeData } from './makeData'
import type { RankingInfo } from '@tanstack/match-sorter-utils'
import type { Person } from './makeData'
import type {
Cell,
CellData,
Column,
ColumnDef,
FilterFn,
Header,
LitTable,
Row,
RowData,
SortFn,
TableFeatures,
} from '@tanstack/lit-table'
declare module '@tanstack/lit-table' {
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue extends CellData = CellData,
> {
filterVariant?: 'text' | 'range' | 'select'
}
interface FilterFns {
fuzzy: FilterFn<typeof stockFeatures, Person>
}
interface FilterMeta {
itemRank?: RankingInfo
}
}
const fuzzyFilter: FilterFn<typeof stockFeatures, Person> = (
row,
columnId,
value,
addMeta,
) => {
const itemRank = rankItem(row.getValue(columnId), value)
addMeta?.({ itemRank })
return itemRank.passed
}
const fuzzySort: SortFn<typeof stockFeatures, Person> = (
rowA,
rowB,
columnId,
) => {
let dir = 0
if (rowA.columnFiltersMeta[columnId]) {
dir = compareItems(
rowA.columnFiltersMeta[columnId].itemRank!,
rowB.columnFiltersMeta[columnId].itemRank!,
)
}
return dir === 0 ? sortFns.alphanumeric(rowA, rowB, columnId) : dir
}
const sortStatusFn: SortFn<typeof stockFeatures, Person> = (rowA, rowB) => {
const statusOrder = ['single', 'complicated', 'relationship']
return (
statusOrder.indexOf(rowA.original.status) -
statusOrder.indexOf(rowB.original.status)
)
}
const columnHelper = createColumnHelper<typeof stockFeatures, Person>()
const columns: Array<ColumnDef<typeof stockFeatures, Person>> =
columnHelper.columns([
columnHelper.display({
id: 'select',
size: 80,
minSize: 80,
maxSize: 80,
enableSorting: false,
enableGrouping: false,
enableHiding: false,
enableResizing: false,
header: '',
cell: '',
}),
columnHelper.accessor('firstName', {
id: 'firstName',
size: 200,
header: 'First Name',
filterFn: 'fuzzy',
sortFn: fuzzySort,
meta: { filterVariant: 'text' },
getGroupingValue: (row) => `${row.firstName} ${row.lastName}`,
}),
columnHelper.accessor((row) => row.lastName, {
id: 'lastName',
size: 180,
header: 'Last Name',
meta: { filterVariant: 'text' },
}),
columnHelper.accessor('age', {
id: 'age',
size: 200,
header: 'Age',
meta: { filterVariant: 'range' },
aggregationFn: 'median',
aggregatedCell: ({ getValue }) =>
Math.round(getValue<number>() * 100) / 100,
}),
columnHelper.accessor('visits', {
id: 'visits',
size: 200,
header: 'Visits',
meta: { filterVariant: 'range' },
aggregationFn: 'sum',
aggregatedCell: ({ getValue }) => getValue<number>().toLocaleString(),
}),
columnHelper.accessor('status', {
id: 'status',
size: 200,
header: 'Status',
sortFn: sortStatusFn,
meta: { filterVariant: 'select' },
}),
columnHelper.accessor('progress', {
id: 'progress',
size: 200,
header: 'Profile Progress',
meta: { filterVariant: 'range' },
aggregationFn: 'mean',
cell: ({ getValue }) => `${Math.round(getValue<number>() * 100) / 100}%`,
aggregatedCell: ({ getValue }) =>
`${Math.round(getValue<number>() * 100) / 100}%`,
}),
])
@customElement('lit-table-example')
class LitTableExample extends LitElement {
@state()
private _data: Array<Person> = makeData(1_000)
private tableController = new TableController<typeof stockFeatures, Person>(
this,
)
private debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
protected render() {
const table = this.tableController.table(
{
features: stockFeatures,
rowModels: {
expandedRowModel: createExpandedRowModel(),
filteredRowModel: createFilteredRowModel({
...filterFns,
fuzzy: fuzzyFilter,
}),
facetedRowModel: createFacetedRowModel(),
facetedMinMaxValues: createFacetedMinMaxValues(),
facetedUniqueValues: createFacetedUniqueValues(),
groupedRowModel: createGroupedRowModel(aggregationFns),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(sortFns),
},
columns,
data: this._data,
getSubRows: (row) => row.subRows,
globalFilterFn: 'fuzzy',
columnResizeMode: 'onChange',
defaultColumn: { minSize: 200, maxSize: 800 },
initialState: {
columnOrder: columns.map((c) => c.id!),
columnPinning: { left: ['select'], right: [] },
pagination: { pageIndex: 0, pageSize: 20 },
},
keepPinnedRows: true,
debugTable: true,
},
(state) => state,
)
return html`
<div class="demo-root">
<h1>Kitchen Sink - All Features</h1>
<div class="toolbar">
<div class="toolbar-row">
<input
class="global-filter-input"
placeholder="Fuzzy search all columns..."
.value=${String(table.state.globalFilter ?? '')}
@input=${(event: InputEvent) =>
this.debounceSet('global', () =>
table.setGlobalFilter(
(event.target as HTMLInputElement).value,
),
)}
/>
</div>
<div class="toolbar-row">
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(1_000))}
>
Flat 1k
</button>
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(100, 5, 3))}
>
Nested 100x5x3
</button>
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(10_000))}
>
Stress 10k (flat)
</button>
<button
class="demo-button demo-button-sm"
@click=${() => (this._data = makeData(100_000))}
>
Stress 100k (flat)
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.reset()}
>
Reset Table
</button>
<button
class="demo-button demo-button-sm"
@click=${() => this.shuffleColumns(table)}
>
Shuffle Columns
</button>
<span class="nowrap">
${table.getSelectedRowModel().flatRows.length.toLocaleString()} of
${table.getCoreRowModel().flatRows.length.toLocaleString()}
selected
</span>
</div>
<details class="column-toggle-panel">
<summary class="column-toggle-panel-header">
Column visibility
</summary>
<div class="column-toggle-row">
<label>
<input
type="checkbox"
?checked=${table.getIsAllColumnsVisible()}
@change=${table.getToggleAllColumnsVisibilityHandler()}
/>
Toggle All
</label>
</div>
${table.getAllLeafColumns().map(
(column) => html`
<div class="column-toggle-row">
<label>
<input
type="checkbox"
?checked=${column.getIsVisible()}
?disabled=${!column.getCanHide()}
@change=${column.getToggleVisibilityHandler()}
/>
${column.id}
</label>
</div>
`,
)}
</details>
</div>
<div class="table-container">
<table style=${this.tableStyle(table)}>
<thead>
${repeat(
table.getHeaderGroups(),
(headerGroup) => headerGroup.id,
(headerGroup) => html`
<tr>
${headerGroup.headers.map(
(header) => html`
<th
colspan=${header.colSpan}
style=${styleMap(this.headerStyle(header))}
>
${header.isPlaceholder
? null
: this.renderHeader(table, header)}
</th>
`,
)}
</tr>
`,
)}
</thead>
<tbody>
${table
.getTopRows()
.map((row) => this.renderPinnedRow(table, row))}
${table.getCenterRows().map(
(row) => html`
<tr>
${row
.getVisibleCells()
.map((cell) => this.renderCell(table, cell))}
</tr>
`,
)}
${table
.getBottomRows()
.map((row) => this.renderPinnedRow(table, row))}
</tbody>
</table>
</div>
<div class="spacer-sm"></div>
<div class="controls">
<button
class="demo-button demo-button-sm"
@click=${() => table.setPageIndex(0)}
?disabled=${!table.getCanPreviousPage()}
>
<<
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.previousPage()}
?disabled=${!table.getCanPreviousPage()}
>
<
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.nextPage()}
?disabled=${!table.getCanNextPage()}
>
>
</button>
<button
class="demo-button demo-button-sm"
@click=${() => table.setPageIndex(table.getPageCount() - 1)}
?disabled=${!table.getCanNextPage()}
>
>>
</button>
<span class="inline-controls">
<div>Page</div>
<strong>
${(table.state.pagination.pageIndex + 1).toLocaleString()} of
${table.getPageCount().toLocaleString()}
</strong>
</span>
<span class="inline-controls">
| Go to page:
<input
type="number"
min="1"
max=${table.getPageCount()}
.value=${String(table.state.pagination.pageIndex + 1)}
@input=${(event: InputEvent) => {
const page = (event.target as HTMLInputElement).value
? Number((event.target as HTMLInputElement).value) - 1
: 0
table.setPageIndex(page)
}}
class="page-size-input"
/>
</span>
<select
.value=${String(table.state.pagination.pageSize)}
@change=${(event: Event) =>
table.setPageSize(
Number((event.target as HTMLSelectElement).value),
)}
>
${[10, 20, 30, 50, 100].map(
(pageSize) =>
html`<option value=${pageSize}>Show ${pageSize}</option>`,
)}
</select>
</div>
<div class="spacer-sm"></div>
<div class="nowrap">
${table.getRowModel().rows.length.toLocaleString()} rows on this page
(${table.getFilteredRowModel().rows.length.toLocaleString()} filtered
of ${table.getCoreRowModel().rows.length.toLocaleString()} total)
</div>
<div class="spacer-md"></div>
<details>
<summary>Table state (live)</summary>
<pre class="state-dump">${JSON.stringify(table.state, null, 2)}</pre>
</details>
</div>
${this.styles()}
`
}
private renderHeader(
table: LitTable<typeof stockFeatures, Person>,
header: Header<typeof stockFeatures, Person, unknown>,
) {
const column = header.column
return html`
<div class="header-row">
<div style="flex: 1; min-width: 0">
<div class="header-controls">
${column.getCanPin()
? html`
<span class="pin-actions">
${column.getIsPinned() !== 'left'
? html`<button
class="pin-button"
@click=${() => column.pin('left')}
>
<
</button>`
: null}
${column.getIsPinned()
? html`<button
class="pin-button"
@click=${() => column.pin(false)}
>
x
</button>`
: null}
${column.getIsPinned() !== 'right'
? html`<button
class="pin-button"
@click=${() => column.pin('right')}
>
>
</button>`
: null}
</span>
`
: null}
${column.getCanGroup()
? html`<button
class="pin-button"
@click=${column.getToggleGroupingHandler()}
>
${column.getIsGrouped()
? `Stop (${column.getGroupedIndex()})`
: 'Group'}
</button>`
: null}
</div>
${column.id === 'select'
? html`<input
type="checkbox"
?checked=${table.getIsAllPageRowsSelected()}
.indeterminate=${table.getIsSomePageRowsSelected()}
@change=${table.getToggleAllPageRowsSelectedHandler()}
/>`
: column.getCanSort()
? html`<span
class="sortable-header"
@click=${column.getToggleSortingHandler()}
>
${FlexRender({ header })}
${column.getIsSorted() === 'asc'
? ' â–²'
: column.getIsSorted() === 'desc'
? ' â–¼'
: ''}
</span>`
: FlexRender({ header })}
${column.getCanFilter() ? this.renderFilter(column) : null}
</div>
</div>
${column.getCanResize()
? html`<div
@dblclick=${() => column.resetSize()}
@mousedown=${header.getResizeHandler()}
@touchstart=${header.getResizeHandler()}
class="resizer ${column.getIsResizing() ? 'isResizing' : ''}"
></div>`
: null}
`
}
private renderFilter(column: Column<typeof stockFeatures, Person>) {
const filterVariant = column.columnDef.meta?.filterVariant
const columnFilterValue = column.getFilterValue()
const minMaxValues =
filterVariant === 'range' ? column.getFacetedMinMaxValues() : undefined
if (filterVariant === 'range') {
return html`
<div class="filter-row">
<input
type="number"
class="filter-input"
min=${Number(minMaxValues?.[0] ?? '')}
max=${Number(minMaxValues?.[1] ?? '')}
.value=${String(
(columnFilterValue as [number, number] | undefined)?.[0] ?? '',
)}
placeholder=${`Min${minMaxValues?.[0] !== undefined ? ` (${minMaxValues[0]})` : ''}`}
@input=${(event: InputEvent) =>
this.debounceSet(`${column.id}-min`, () =>
column.setFilterValue((old: [number, number] | undefined) => [
(event.target as HTMLInputElement).value,
old?.[1],
]),
)}
/>
<input
type="number"
class="filter-input"
min=${Number(minMaxValues?.[0] ?? '')}
max=${Number(minMaxValues?.[1] ?? '')}
.value=${String(
(columnFilterValue as [number, number] | undefined)?.[1] ?? '',
)}
placeholder=${`Max${minMaxValues?.[1] !== undefined ? ` (${minMaxValues[1]})` : ''}`}
@input=${(event: InputEvent) =>
this.debounceSet(`${column.id}-max`, () =>
column.setFilterValue((old: [number, number] | undefined) => [
old?.[0],
(event.target as HTMLInputElement).value,
]),
)}
/>
</div>
`
}
const sortedUniqueValues = Array.from(
column.getFacetedUniqueValues().keys(),
)
.sort()
.slice(0, 5000)
if (filterVariant === 'select') {
return html`
<select
class="filter-select"
.value=${String(columnFilterValue ?? '')}
@change=${(event: Event) =>
column.setFilterValue((event.target as HTMLSelectElement).value)}
>
<option value="">All</option>
${sortedUniqueValues.map(
(value) =>
html`<option value=${String(value)}>${String(value)}</option>`,
)}
</select>
`
}
return html`
<datalist id=${column.id + 'list'}>
${sortedUniqueValues.map(
(value) => html`<option value=${String(value)}></option>`,
)}
</datalist>
<input
type="text"
class="filter-select"
list=${column.id + 'list'}
.value=${String(columnFilterValue ?? '')}
placeholder=${`Search (${column.getFacetedUniqueValues().size})`}
@input=${(event: InputEvent) =>
this.debounceSet(column.id, () =>
column.setFilterValue((event.target as HTMLInputElement).value),
)}
/>
`
}
private renderPinnedRow(
table: LitTable<typeof stockFeatures, Person>,
row: Row<typeof stockFeatures, Person>,
) {
return html`
<tr class="pinned-row" style=${styleMap(this.pinnedRowStyle(table, row))}>
${row.getVisibleCells().map((cell) => this.renderCell(table, cell))}
</tr>
`
}
private renderCell(
table: LitTable<typeof stockFeatures, Person>,
cell: Cell<typeof stockFeatures, Person, unknown>,
) {
return html`
<td
style=${styleMap(this.cellStyle(cell))}
class=${this.cellClass(table, cell) ?? ''}
>
${cell.column.id === 'select'
? html`<div class="column-toggle-row">
<input
type="checkbox"
?checked=${cell.row.getIsSelected()}
?disabled=${!cell.row.getCanSelect()}
.indeterminate=${cell.row.getIsSomeSelected()}
@change=${cell.row.getToggleSelectedHandler()}
/>
<button
class="pin-button"
@click=${() =>
cell.row.pin(
cell.row.getIsPinned() === 'top' ? false : 'top',
)}
>
${cell.row.getIsPinned() === 'top' ? 'Pinned' : 'Pin'}
</button>
</div>`
: cell.column.id === 'firstName'
? html`<div style="padding-left: ${cell.row.depth * 1.5}rem">
${cell.row.getCanExpand()
? html`<button
@click=${cell.row.getToggleExpandedHandler()}
style="cursor: pointer; margin-right: 0.25rem"
>
${cell.row.getIsExpanded() ? 'v' : '>'}
</button>`
: html`<span style="margin-right: 0.25rem">-</span>`}
${FlexRender({ cell })}
</div>`
: cell.getIsGrouped()
? html`<button
@click=${cell.row.getToggleExpandedHandler()}
style="cursor: ${cell.row.getCanExpand()
? 'pointer'
: 'normal'}"
>
${cell.row.getIsExpanded() ? 'v' : '>'}
${FlexRender({ cell })}
(${cell.row.subRows.length.toLocaleString()})
</button>`
: FlexRender({ cell })}
</td>
`
}
private debounceSet(key: string, setValue: () => void) {
clearTimeout(this.debounceTimers.get(key))
this.debounceTimers.set(
key,
setTimeout(() => {
setValue()
this.debounceTimers.delete(key)
}, 300),
)
}
private shuffleColumns(table: LitTable<typeof stockFeatures, Person>) {
table.setColumnOrder(
faker.helpers.shuffle(table.getAllLeafColumns().map((d) => d.id)),
)
}
private getCommonPinningStyle(column: Column<typeof stockFeatures, Person>) {
const isPinned = column.getIsPinned()
const isLastLeftPinnedColumn =
isPinned === 'left' && column.getIsLastColumn('left')
const isFirstRightPinnedColumn =
isPinned === 'right' && column.getIsFirstColumn('right')
return {
boxShadow: isLastLeftPinnedColumn
? '-4px 0 4px -4px gray inset'
: isFirstRightPinnedColumn
? '4px 0 4px -4px gray inset'
: undefined,
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
opacity: isPinned ? '0.97' : '1',
position: isPinned ? 'sticky' : 'relative',
zIndex: isPinned ? '1' : '0',
}
}
private headerStyle(header: Header<typeof stockFeatures, Person, unknown>) {
return {
...this.getCommonPinningStyle(header.column),
whiteSpace: 'nowrap',
width: `calc(var(--header-${header.id}-size) * 1px)`,
}
}
private cellStyle(cell: Cell<typeof stockFeatures, Person, unknown>) {
return {
...this.getCommonPinningStyle(cell.column),
width: `calc(var(--col-${cell.column.id}-size) * 1px)`,
}
}
private pinnedRowStyle(
table: LitTable<typeof stockFeatures, Person>,
row: Row<typeof stockFeatures, Person>,
) {
const bottomRows = table.getBottomRows()
return {
position: 'sticky',
top:
row.getIsPinned() === 'top'
? `${row.getPinnedIndex() * 32 + 48}px`
: undefined,
bottom:
row.getIsPinned() === 'bottom'
? `${(bottomRows.length - 1 - row.getPinnedIndex()) * 32}px`
: undefined,
zIndex: '1',
}
}
private cellClass(
table: LitTable<typeof stockFeatures, Person>,
cell: Cell<typeof stockFeatures, Person, unknown>,
) {
const groupingActive = table.state.grouping.length > 0
const hasAggregation = !!cell.column.columnDef.aggregationFn
return !groupingActive
? undefined
: cell.getIsGrouped()
? 'cell-grouped'
: hasAggregation && cell.getIsAggregated()
? 'cell-aggregated'
: cell.getIsPlaceholder()
? 'cell-placeholder'
: undefined
}
private tableStyle(table: LitTable<typeof stockFeatures, Person>) {
const styles = [`width: ${table.getTotalSize()}px`]
for (const header of table.getFlatHeaders()) {
styles.push(`--header-${header.id}-size: ${header.getSize()}`)
styles.push(`--col-${header.column.id}-size: ${header.column.getSize()}`)
}
return styles.join('; ')
}
private styles() {
return html`<style>
* {
box-sizing: border-box;
font-family: sans-serif;
font-size: 14px;
}
.table-container {
overflow-x: auto;
width: 100%;
max-width: 1400px;
border: 1px solid lightgray;
}
table {
border-collapse: collapse;
border-spacing: 0;
table-layout: fixed;
}
th {
background-color: #f3f4f6;
border-bottom: 1px solid lightgray;
border-right: 1px solid lightgray;
font-weight: bold;
padding: 2px 4px;
position: relative;
text-align: left;
vertical-align: top;
}
td {
background-color: white;
border-bottom: 1px solid #eee;
padding: 2px 4px;
vertical-align: middle;
}
.resizer {
position: absolute;
top: 0;
right: 0;
height: 100%;
width: 5px;
background: rgba(0, 0, 0, 0.5);
cursor: col-resize;
user-select: none;
touch-action: none;
z-index: 2;
}
.resizer.isResizing {
background: blue;
}
.demo-root {
padding: 0.5rem;
}
.spacer-sm {
height: 0.5rem;
}
.spacer-md {
height: 1rem;
}
.controls,
.button-row,
.inline-controls,
.pin-actions,
.filter-row,
.header-row,
.header-controls,
.toolbar-row {
display: flex;
align-items: center;
}
.header-row,
.inline-controls,
.pin-actions,
.filter-row,
.header-controls,
.controls,
.toolbar-row {
gap: 0.5rem;
}
.toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid lightgray;
margin-bottom: 0.5rem;
}
.toolbar-row {
flex-wrap: wrap;
}
.column-toggle-panel {
display: inline-block;
border: 1px solid #000;
border-radius: 0.25rem;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.2);
padding: 0.25rem;
}
.column-toggle-panel-header {
border-bottom: 1px solid #000;
padding: 0 0.25rem;
margin-bottom: 0.25rem;
}
.column-toggle-row {
padding: 0 0.25rem;
}
.demo-button,
.pin-button,
.filter-input,
.filter-select,
.page-size-input,
.global-filter-input {
border: 1px solid currentColor;
border-radius: 0.25rem;
}
.demo-button {
padding: 0.5rem;
}
.demo-button-sm {
padding: 0.25rem;
}
.pin-button {
padding: 0 0.25rem;
cursor: pointer;
background: transparent;
font-size: 0.875rem;
}
.nowrap {
white-space: nowrap;
}
.page-size-input {
width: 4rem;
padding: 0.25rem;
}
.filter-input {
width: 6rem;
}
.filter-select {
width: 9rem;
}
.global-filter-input {
padding: 0.5rem;
width: 100%;
max-width: 24rem;
}
.sortable-header {
cursor: pointer;
user-select: none;
}
.cell-grouped {
background: #0aff0082;
}
.cell-aggregated {
background: #ffa50078;
}
.cell-placeholder {
background: #ff000042;
}
.state-dump {
max-height: 24rem;
overflow: auto;
}
</style>`
}
}