table
A virtual data-table, configurable via a columns array (which will automatically be generated if not provided),
that displays gigantic tables with fixed headers (and live column-resizing) using a minimum of resources and cpu.
import { tosiTable } from 'tosijs-ui'
import { input } from 'tosijs'.elements
const emojiRequest = await fetch('https://raw.githubusercontent.com/tonioloewald/emoji-metadata/master/emoji-metadata.json')
const emojiData = await emojiRequest.json()
const columns = [
{
name: "emoji",
prop: "chars",
align: "center",
width: 80,
sort: false,
visible: true
},
{
prop: "name",
width: 300,
// custom cell using bindings to make the field editable
dataCell() {
return input({
class: 'td',
bindValue: '^.name',
title: 'name',
onMouseup: (event) => { event.stopPropagation() },
onTouchend: (event) => { event.stopPropagation() },
})
},
},
{
prop: "category",
sort: "ascending",
width: 150
},
{
prop: "subcategory",
width: 150
},
]
const table = tosiTable({
multiple: true,
array: emojiData,
localized: true,
columns,
rowHeight: 40,
})
table.addEventListener('mouseover', (e) => {
for (const el of table.querySelectorAll('.row-hover')) {
el.classList.remove('row-hover')
}
const item = table.getItem(e.target)
if (!item) return
table.getCells(item)?.forEach(c => c.classList.add('row-hover'))
})
preview.append(table)
.preview input.td {
margin: 0;
border-radius: 0;
box-shadow: none !important;
}
.preview input.td:focus {
background: #fff4;
}
.preview tosi-table {
height: 100%;
}
.preview .row-hover {
background: #08835810;
}
const table = await waitFor('tosi-table')
await new Promise(resolve => {
const check = () => {
if (table.visibleRows.length > 0) return resolve()
setTimeout(check, 100)
}
check()
})
test('table renders with data', () => {
expect(table.multiple).toBe(true)
expect(table.visibleRows.length).toBeGreaterThan(0)
expect(table.array.length).toBeGreaterThan(0)
})
test('row selection: data model + aria-selected on row (incl. custom dataCell)', async () => {
// Wait for listBinding to stamp DOM cells for the visible window
const items = table.visibleRows
await new Promise(resolve => {
const check = () => {
if (table.getCells(items[0]) && table.getCells(items[1])) return resolve()
setTimeout(check, 100)
}
check()
})
table.deSelect()
table.selectRow(items[0])
table.selectRow(items[1])
// Data model reflects selection immediately
expect(items[0][table.selectedKey]).toBe(true)
expect(items[1][table.selectedKey]).toBe(true)
expect(table.selectedRows.length).toBe(2)
// DOM: aria-selected lives on the row element. CSS targets
// .tr[aria-selected] .td to highlight cells. The attribute is set via
// toggleAttribute, so its value is "" (presence-only) — match accordingly.
const cells0 = table.getCells(items[0])
const cells1 = table.getCells(items[1])
expect(cells0.length).toBe(table.visibleColumns.length)
expect(cells1.length).toBe(table.visibleColumns.length)
const row0 = cells0[0].closest('.tr')
const row1 = cells1[0].closest('.tr')
expect(row0.hasAttribute('aria-selected')).toBe(true)
expect(row1.hasAttribute('aria-selected')).toBe(true)
// The `name` column (index 1) uses a dataCell input — confirm the custom
// element is the actual cell living inside the same selected row.
expect(cells0[1].tagName).toBe('INPUT')
expect(cells0[1].closest('.tr')).toBe(row0)
// Deselect and verify both data model and DOM clear
table.deSelect()
expect(table.selectedRows.length).toBe(0)
expect(items[0][table.selectedKey]).not.toBe(true)
expect(items[1][table.selectedKey]).not.toBe(true)
expect(row0.hasAttribute('aria-selected')).toBe(false)
expect(row1.hasAttribute('aria-selected')).toBe(false)
})
test('getCells and getItem', async () => {
// Wait for list binding to stamp DOM elements
const items = table.visibleRows
let cells
await new Promise(resolve => {
const check = () => {
cells = table.getCells(items[0])
if (cells) return resolve()
setTimeout(check, 100)
}
check()
})
expect(cells.length).toBe(table.visibleColumns.length)
// getItem round-trips back to the same item
const item = table.getItem(cells[0])
expect(item).toBe(items[0])
// getCells from a cell element
const cellsFromCell = table.getCells(cells[1])
expect(cellsFromCell).toBe(cells)
})
In the preceding example, the
namecolumn is editable (and bound, try editing something and scrolling it out of view and back) andmultipleselect is enabled. In the console, you can try$('tosi-table').visibleRowsand $('tosi-table').selectedRows`.
You can set the <tosi-table>'s array, columns, and filter properties directly, or set its value to:
{
array: any[],
columns: ColumnOptions[] | null,
filter?: ArrayFilter
}
ColumnOptions
You can configure the table's columns by providing it an array of ColumnOptions:
export interface ColumnOptions {
name?: string
prop: string
width: number
visible?: boolean
align?: string
pinned?: 'left' | 'right'
sort?: false | 'ascending' | 'descending'
headerCell?: (options: ColumnOptions) => HTMLElement
dataCell?: (options: ColumnOptions) => HTMLElement
}
Pinned Columns and Rows
Set pinned: 'left' or pinned: 'right' on individual columns to pin
them during horizontal scroll. Pinned columns are sorted to the edges
automatically. You can also pin/unpin columns via the header menu, or by
dragging a column into/out of a pinned zone.
Set pinnedTop and pinnedBottom to pin the first/last N data rows
(pinned top rows appear below the header row).
All pinning uses CSS position: sticky for frame-perfect rendering with
no jitter.
import { elements } from 'tosijs'
import { tosiTable, icons } from 'tosijs-ui'
const { button, span } = elements
const count = 100
const cols = ['Q1', 'Q2', 'Q3', 'Q4']
const numKeys = []
const rows = Array.from({ length: count }, (_, i) => {
const row = { id: i + 1, name: 'Item ' + (i + 1) }
for (const year of [2024, 2025, 2026]) {
for (const q of cols) {
const key = q + ' ' + year
row[key] = Math.round((Math.random() * 200 - 100) * 100) / 100
if (i === 0) numKeys.push(key)
}
}
return row
})
// totals row
const totals = { id: '', name: 'Total' }
for (const key of numKeys) {
totals[key] = Math.round(rows.reduce((sum, r) => sum + r[key], 0) * 100) / 100
}
rows.push(totals)
// custom cell that colors negative numbers red
function numCell(options) {
return span({
class: 'td num-cell',
bindText: '^.' + options.prop,
bind: {
value: '^.' + options.prop,
binding: {
toDOM(el, val) {
el.style.color = val < 0 ? '#c00' : ''
}
}
}
})
}
const dataColumns = numKeys.map(key => ({
prop: key, width: 100, align: 'right', dataCell: numCell,
}))
const table = tosiTable({
array: rows,
rowHeight: 32,
pinnedBottom: 1,
rowRendered(item, cells) {
const total = numKeys.reduce((sum, key) => sum + (item[key] || 0), 0)
const rowClass = total < 0 ? 'row-negative' : 'row-positive'
for (const c of cells) {
if (c.classList.contains('num-cell')) {
c.classList.add(rowClass)
}
}
},
columns: [
{ prop: 'id', name: '#', width: 50, align: 'right', pinned: 'left' },
{ prop: 'name', width: 120, pinned: 'left' },
...dataColumns,
{
prop: '_actions',
name: '',
width: 48,
sort: false,
pinned: 'right',
dataCell() {
return button(
{
class: 'td actions-btn',
onClick(e) { e.stopPropagation() },
onMouseup(e) { e.stopPropagation() },
},
icons.moreVertical(),
)
},
},
],
})
preview.append(table)
.preview tosi-table {
height: 100%;
}
.preview .actions-btn {
border: none;
padding: 0;
cursor: pointer;
display: block;
text-align: center;
width: 100%;
}
.preview tosi-table .pinned-bottom {
background: #eee;
font-weight: bold;
}
.preview .row-pinned .td {
background: #eee;
}
.preview .num-cell {
font-variant-numeric: tabular-nums;
}
const tables = document.querySelectorAll('tosi-table')
const table = tables[tables.length - 1]
// Wait until the pinned row has been stamped AND its bindings have settled
// (numeric cells show their text, the actions button is in place).
await new Promise(resolve => {
const check = () => {
const row = table.querySelector('.tbody-pinned-bottom .tr')
if (
row &&
row.querySelector('button') &&
Array.from(row.children).some(c => c.classList.contains('num-cell') && c.textContent.trim().length > 0)
) return resolve()
setTimeout(check, 100)
}
check()
})
test('pinned row goes through the same listBinding as virtual rows', () => {
const totals = table.array[table.array.length - 1]
const pinnedRow = table.querySelector('.tbody-pinned-bottom .tr')
const pinnedCells = Array.from(pinnedRow.children)
// Sanity: same number of cells as visible columns
expect(pinnedCells.length).toBe(table.visibleColumns.length)
// dataCell honoured: numeric columns kept their `num-cell` class, and the
// _actions column rendered its <button>
const numCells = pinnedCells.filter(c => c.classList.contains('num-cell'))
expect(numCells.length).toBeGreaterThan(0)
expect(pinnedCells.some(c => c.tagName === 'BUTTON')).toBe(true)
// numCell uses bindText: '^.<prop>' — confirm path-bindings resolved
// (this requires the cell to live inside a list-bound row).
const renderedTexts = numCells.map(c => c.textContent?.trim() ?? '')
expect(renderedTexts.every(t => t.length > 0)).toBe(true)
expect(renderedTexts.some(t => /^-?\d/.test(t))).toBe(true)
// rowRendered fired: numeric cells of this row carry `row-negative` or
// `row-positive` based on the totals row's sign. Either way the loop did
// *something* — so the test verifies the work happened regardless of the
// randomized data.
const total = Object.keys(totals)
.filter(k => typeof totals[k] === 'number')
.reduce((s, k) => s + totals[k], 0)
const expected = total < 0 ? 'row-negative' : 'row-positive'
expect(numCells.every(c => c.classList.contains(expected))).toBe(true)
// getCells / getItem round-trip works for pinned items
const cellsForTotals = table.getCells(totals)
expect(cellsForTotals?.length).toBe(table.visibleColumns.length)
expect(table.getItem(cellsForTotals[0])).toBe(totals)
// Selection on a pinned row sets aria-selected on the row element
table.deSelect()
table.selectRow(totals)
expect(pinnedRow.hasAttribute('aria-selected')).toBe(true)
table.deSelect()
expect(pinnedRow.hasAttribute('aria-selected')).toBe(false)
})
Selection
<tosi-table> supports select and multiple boolean properties allowing rows to be selectable. Selected rows will
be given the [aria-selected] attribute, so style them as you wish.
multiple select supports shift-clicking and command/meta-clicking.
<tosi-table> provides an selectionChanged(visibleSelectedRows: any[]): void callback property allowing you to respond to changes
in the selection, and also selectedRows and visibleSelectedRows properties.
The following methods are also provided:
<tosi-table>.selectRow(row: any, select = true)(de)selects specified row<tosi-table>.selectRows(rows?: any[], select = true)(de)selects specified rows<tosi-table>.deSelect(rows?: any[])deselects all or specified rows.
These are rather fine-grained but they're used internally by the selection code so they may as well be documented.
Row Access
Because the table uses a flat CSS grid (no .tr row elements), two methods
provide O(1) access between items and their cells:
<tosi-table>.getCells(itemOrCell)— returns theHTMLElement[]of cells for a given data item or any cell in the row, orundefinedif the row isn't currently rendered (virtual scroll)<tosi-table>.getItem(cell)— returns the data item bound to a cell element
These are useful for row-level hover effects, styling, and event handling:
table.addEventListener('mouseover', (e) => {
for (const el of table.querySelectorAll('.row-hover')) {
el.classList.remove('row-hover')
}
const item = table.getItem(e.target)
if (!item) return
table.getCells(item)?.forEach(c => c.classList.add('row-hover'))
})
rowRendered callback
For virtual tables, cells are created and destroyed as you scroll. The
rowRendered callback fires whenever a row's cells are rendered, letting
you apply styling that survives virtualisation:
table.rowRendered = (item, cells) => {
if (item.overdue) {
cells.forEach(c => c.classList.add('overdue'))
}
}
Sorting
By default, the user can sort the table by any column which doesn't have a sort === false.
You can set the initial sort by setting the sort value of a specific column to ascending
or descending.
You can override this by setting the table's sort function (it's an Array.sort() callback)
to whatever you like, and you can replace the headerCell or set the sort of each column
to false if you have some specific sorting in mind.
You can disable sorting controls by adding the nosort attribute to the <tosi-table>.
Hiding (and Showing) Columns
By default, the user can show / hide columns by clicking via the column header menu.
You can remove this option by adding the nohide attribute to the <tosi-table>
Reordering Columns
By default, the user can reorder columns by dragging them around. You can disable this
by adding the noreorder attribute to the <tosi-table>.
Row Height
If you set the <tosi-table>'s rowHeight to 0 it will render all its elements (i.e. not be virtual). This is
useful for smaller tables, or tables with variable row-heights.
Styling
The component uses a flat CSS grid layout where every cell (header, data, pinned)
is a direct child of the grid container. This means standard CSS works for styling,
and position: sticky handles all pinning.
Breaking change in v1.5.0: The table no longer uses .thead, .tbody, or .tr
wrapper elements. All cells are direct children of a single .grid container.
Update any custom CSS targeting those classes:
.thead→.th(header cells).tbody→ the.gridcontainer itself.tr→ no equivalent; cells are flat grid children[part="pinnedTopRows"]→.pinned-top[part="pinnedBottomRows"]→.pinned-bottom.td-pinned,.th-pinned→.col-pinned.pin-left,.pin-right→ no longer needed (CSSstickyhandles positioning)
Localization
<tosi-table> supports the localized attribute which simply causes its default headerCell
to render a <tosi-localized> element instead of a span for its caption, and localize its
popup menu.
You'll need to make sure your localized strings include:
- Sort
- Show
- Hide
- Column
- Ascending
- Descending
- Pin
- Unpin
- Left
- Right
As well as any column names you want localized.