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 name column is editable (and bound, try editing something and scrolling it out of view and back) and multiple select is enabled. In the console, you can try $('tosi-table').visibleRows and $('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:

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:

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:

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:

As well as any column names you want localized.