Elements

DataTable

DataTable shows rows and columns as a spreadsheet does and scrolls both ways over any number of either. Only the cells in view are made, so a table of a million rows and ten thousand columns scrolls as a small one does. Rows, columns or cells select, with the pointer and the keyboard; columns have fixed widths, resize with a drag of their edge, move with a drag of their title, stay at the left while the others scroll, and sort. Titles can sit under group labels, and a cell or a title can be drawn by the app.

el-data-table.tsx
el-data-table.tsxLive
import { mount, signal, Text, View } from "nsh";
import { DataTable, type DataTableColumn, type DataTableSelection, type DataTableSort } from "nsh/base";
import { Log, Segmented, Split, ui, wide } from "./palette";

const NAMES = ["Ada", "Alan", "Grace", "Linus", "Margaret", "Dennis", "Barbara", "Ken"];
const CITIES = ["Oslo", "Lima", "Kyoto", "Cairo", "Perth", "Quito", "Turin", "Hanoi"];

type Person = { id: number; name: string; city: string; score: number };

const people: Person[] = Array.from({ length: 2000 }, (_, i) => ({
  id: i + 1,
  name: `${NAMES[i % NAMES.length]} ${i + 1}`,
  city: CITIES[(i * 7) % CITIES.length],
  score: (i * 37) % 1000,
}));

const columns: DataTableColumn[] = [
  { key: "id", title: "ID", width: 70, align: "end", fixed: true, sortable: true },
  { key: "name", title: "Name", width: 150, sortable: true },
  { key: "city", title: "City", width: 120, sortable: true },
  { key: "score", title: "Score", width: 90, align: "end", sortable: true },
];

// Sorting is the app's: the table reports the next sort (`onSortChange`),
// the app orders its rows and sets `sort`.
function App() {
  const mode = signal<"row" | "cell">("row");
  const sort = signal<DataTableSort | undefined>(undefined);
  const rows = signal<Person[]>(people);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 4));

  const sortBy = (next: DataTableSort | undefined) => {
    sort.set(next);
    if (!next) return rows.set(people);
    const key = next.key as keyof Person;
    const sign = next.direction === "descending" ? -1 : 1;
    rows.set([...people].sort((a, b) => (a[key] < b[key] ? -sign : a[key] > b[key] ? sign : 0)));
  };

  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>data table</Text>
      <Text style={{ color: ui().muted }}>Press a title to sort, drag its edge to resize or the title to move it</Text>

      <Split
        grow
        width={680}
        inspector={
          <>
            <Segmented label="selection" options={["row", "cell"] as const} value={mode()} onChange={(v) => mode.set(v)} />
            <Log title="events" lines={events()} minHeight={96} />
          </>
        }
      >
        <View style={{ height: wide() ? 360 : 320, ...ui().card, borderRadius: 14, overflow: "hidden" }}>
          <DataTable
            columns={columns}
            data={rows()}
            rowHeader
            stripe
            cellSelectable={mode() === "cell"}
            sort={sort()}
            onSortChange={(next) => {
              sortBy(next ?? undefined);
              log(`onSortChange: ${next ? `${next.key} ${next.direction}` : "none"}`);
            }}
            onSelectionChange={(s: DataTableSelection | null) => log(`onSelectionChange: ${s ? JSON.stringify(s) : "none"}`)}
            onColumnResize={(key, width) => log(`onColumnResize: ${key} ${Math.round(width)}`)}
            style={{ flex: 1 }}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);

DataTable owns behavior and comes with a look of its own built from the theme’s tokens: titles on muted, a border line between rows, a hover tint and a selection tint on primary. Each part takes the app’s styles over these defaults (classes and styles). Rust makes one with nsh_base::data_table(), TSX with <DataTable> from nsh/base.

use nsh_base::{DataTableColumn, data_table};
use nsh_core::ui::prelude::*;

fn column(key: &str, title: &str) -> DataTableColumn {
    let mut column = DataTableColumn::default();
    column.key = key.into();
    column.title = Some(title.into());
    column
}

fn table() -> impl IntoElement {
    data_table()
        .columns(vec![column("name", "Name"), column("city", "City")])
        .flex_1()
}

The table fills the box the app gives it (flex-1, a height) and scrolls inside: vertically through the rows and sideways through the columns, with scroll bars (scrollbar={false} hides them, bounces={false} stops the bounce at the ends where a platform has one).

#Rows and columns

data is the rows: objects whose fields the columns’ keys name, or arrays the columns index by position. Numbers, booleans and strings show as text. A column is a DataTableColumn:

FieldDescription
keyWho it is, unique among the columns: the field of the rows its cells show, and the name that events and the ref use.
titleThe text of its title. Default its key.
widthIts width, in points. Default 120.
minWidth, maxWidthThe narrowest and widest a drag or a fit makes it. Default 20 and 1200.
fitPaddingWhat the app’s drawing of a cell (renderCell) adds to its text’s width (an icon, a badge), counted when a fit sizes the column.
align"start" (the default), "center" or "end", for numbers. Cells and title alike.
sortableIts title has a sort button that asks for a sort (see Sorting).
fixedIt stays at the left while the other columns scroll under it. Fixed columns lead the others, in their order.
resizable, movableWhether a drag of its edge resizes it and a drag of its title moves it. Default the table’s resizableColumns and movableColumns.
selectableWhether it takes part in selection. A column of buttons or checkboxes sets false. Default true.
cellClassClasses of the column’s cells, after the cell part’s: paddings or alignment of one column.
headerClassClasses of the column’s title, after the headerCell part’s.
positiveClassClasses of the column’s cells that hold a rise: a number above zero, or a text starting with +.
negativeClassClasses of the column’s cells that hold a fall: a number below zero, or a text starting with -.

rowHeight is the height of a body row (default 32) and headerHeight the height of a title row (default the row height). rowHeader adds a column of row numbers at the left, which selects rows. stripe gives the odd rows the rowOdd part.

#Group labels

groups puts labels above the columns, the lowest level first. Each level is a list of { label, span } whose spans add up to the number of columns, or of the level below.

<DataTable
  columns={columns}
  data={rows}
  groups={[
    [{ label: "Person", span: 2 }, { label: "Result", span: 1 }],
  ]}
  class="flex-1"
/>

#Any number of rows

The table asks for rows by position, and the app serves them. rowCount is the total number of rows, data the rows the app holds, and dataStart the position of the first of them. Rows outside data are empty until the app supplies them. onVisibleRowsChange reports the rows in view, { start, end }, at every scroll step, and is the place to fetch the page around them.

const ROWS = 1_000_000;
const page = signal({ start: 0, rows: load(0) });

<DataTable
  columns={columns}
  data={page().rows}
  dataStart={page().start}
  rowCount={ROWS}
  onVisibleRowsChange={({ start, end }) => {
    // Keep the rows in view inside the page held.
    const held = page();
    if (start < held.start || end > held.start + held.rows.length) {
      const first = Math.max(0, start - 200);
      page.set({ start: first, rows: load(first) });
    }
  }}
  class="flex-1"
/>

For a list that grows at its end, set hasMore: onLoadMore fires once for each number of rows when the rows in view come within loadMoreThreshold (default 20) of the last. While loading is true it waits, and the loadingView slot shows over the rows. The empty slot shows when there are no rows.

Past a few million points of content the scroll bars span a scaled range, so a million rows still scroll to every row.

#Selection

Three switches decide what a press selects, as in gpui-component:

PropA press selectsselection
rowSelectable (default true)A row: any of its cells, or its row header{ row }
columnSelectable (default true)A column: its title{ column }
cellSelectable (default false)A cell; the row header (shown by default with it) selects rows{ row, column }

With cellSelectable and the row header hidden (rowHeader={false}), pressing the selected cell again selects its row. A press with Shift or the platform’s command key held selects a row only while selectOnModifierPress (default true), so an app that keeps its own multi-row selection handles those presses itself: onRowPress reports every press of the body with its row, the column under the pointer, the clicks in a row and the modifier keys held. A column whose selectable is false selects neither itself nor its cells.

Rows count from 0 in the data, and a column is its index among columns, whatever order the user moved them to. onSelectionChange reports what the user selected (null for nothing). The selection prop sets the selection from the app; leave it unset and the table keeps it. onDoublePress reports a row, a cell or a title pressed twice, and onContextMenu a secondary or long press with the point to open the app’s Menu at. Both name the cell under the pointer (its row and column), a row’s number (row only) or a title (column only). Until the next press, the cell a menu was asked for has the cellActive part, or its row the rowActive part where cells do not select.

Screen readers get a grid (named by accessibilityLabel) of column headers and cells, the selected ones said to be selected.

#Keyboard

Pressing the table, or reaching it with Tab (macOS, the web), gives it the keyboard until another press, the focus moving elsewhere or blur(); focus() gives it from the app. The table is one stop of Tab: inside it Tab moves the selection, and Escape gives the keyboard back.

KeysWhat they do
Arrow keysMove the selection by a row, a column or a cell, scrolling it into view.
Tab, Shift+TabMove to the next or the previous column.
Page Up, Page DownMove by a page of rows.
Home, EndMove to the first or the last.
EscapeClears the selection, then gives the keyboard back.
Copy shortcutPuts the selection’s text on the clipboard (copy()).

loopSelection (default true) makes the arrow keys go around the ends.

#Sorting

A sortable column has a sort button at the end of its title, a small ghost button dimmed while the column is unsorted. Pressing it reports the next sort in the cycle of unsorted, ascending, descending and unsorted again, with onSortChange({ key, direction }) (null to clear). The table does not reorder the rows: the app sorts its data, or asks its server for rows in the new order, and sets sort so the title shows it. Because the app owns the order, sorting works the same over a million rows the app never holds.

<DataTable
  columns={columns}
  data={rows()}
  sort={sort()}
  onSortChange={(next) => {
    sort.set(next ?? undefined);
    rows.set(sorted(people, next));
  }}
  class="flex-1"
/>

#Resizing and moving columns

A drag of a column’s trailing edge resizes it within its minWidth and maxWidth, and onColumnResize(key, width) reports every step. Pressing the edge twice in a row fits the column to its widest text and title. A drag of a title moves the column, and a line shows where it will drop; onColumnMove reports { key, from, to, order }, order being the keys of all the columns in the order shown. Fixed columns stay among the fixed ones. resizableColumns and movableColumns turn both off for the table, and a column’s resizable and movable win over them.

#Live data

A quote table changes many times a second. The app formats the rows in view and a reach around them (it knows them from onVisibleRowsChange) and hands those over as data from dataStart, so an update costs what is on screen, whatever the number of rows. Rises and falls take their colors from the column’s positiveClass and negativeClass, applied natively to the cells whose value is above or below zero (or whose text starts with + or -).

const tone = { positiveClass: "text-green-600", negativeClass: "text-red-600" };
const columns: DataTableColumn[] = [
  { key: "symbol", title: "Symbol", fixed: true },
  { key: "price", title: "Price", align: "end", sortable: true },
  { key: "change", title: "Chg%", align: "end", ...tone },
];

Rows and columns are slots the table makes once and keeps: scrolling gives them other rows and columns to show, so a scroll, however long, changes texts and places only.

#Drawing cells and titles

renderCell draws the cells of the app’s own where a column is not just text (a badge, a button). It is called for the cells in view only, with a DataTableCell: row, column, key, text (what the table would show) and selected. The app looks up its own data by row. renderHeaderCell draws a title across its cell, told a DataTableHeaderCell: column, key, title, sort (the direction, or none) and selected.

<DataTable
  columns={columns}
  data={rows()}
  renderCell={(cell) =>
    cell.key === "status" ? (
      <View class={cell.text === "open" ? "rounded bg-green-500/20 px-2" : "rounded bg-muted px-2"}>
        <Text class="text-xs">{cell.text}</Text>
      </View>
    ) : (
      <Text class="px-2 text-sm">{cell.text}</Text>
    )
  }
  class="flex-1"
/>

fontFamily and fontSize (default 14) set the font of the cells, which the table also measures texts in to fit columns.

#The ref

ref gives a DataTableRef:

MethodDescription
scrollToRow(row, animated?)Scrolls a row into view.
scrollToColumn(key, animated?)Scrolls a column into view.
selection()What is selected, null for nothing.
select(selection)Selects (null: nothing) and brings it into view, without onSelectionChange.
visibleRows(), visibleColumns()The rows and the scrolling columns in view, as { start, end } (end excluded).
columnWidths()The widths now, in the order of columns.
setColumnWidth(key, width)Sets a column’s width, within its limits.
fitColumn(key)Sets a column as wide as its widest text and its title need, among the rows in view and up to a thousand more around them. It returns the width, null for no such column.
dump(start?, end?)The texts of the rows given (default all those held) and of the titles, as shown: { headers, rows }, for export.
copy()Puts the selection’s text on the clipboard: a row’s cells with tabs between, a column’s with line breaks, a cell’s text.
focus(), blur()Gives the keyboard to the table or takes it.
menuTarget(), clearMenuTarget()What the last context menu was asked for at ({ row?, column? }, marked by cellActive / rowActive), and dropping that mark when the app’s menu closes.

#Styling the parts

class and style style the table’s box. classes takes Tailwind classes by part and styles inline styles by part; a part’s classes come after its own default styles, so they win, and its styles win over its classes.

<DataTable
  columns={columns}
  data={rows()}
  stripe
  class="flex-1 rounded-lg border border-border"
  classes={{
    headerCell: "bg-secondary",
    rowSelected: "bg-primary/10",
    rowHover: "bg-muted/50",
    fixedEdge: "bg-border",
  }}
/>
PartWhat it is
rootThe table’s box: its border, radius and background
headerThe header across the table, its rows of group labels and titles
groupCellA group label above the columns it spans
headerCellEvery column’s title cell
headerCellSortedThe title of the column the table is sorted by, after headerCell
headerCellSelectedThe title of the selected column, after headerCell
sortIndicatorThe sort button of a sortable column: up and down chevrons, the sorted direction’s in the text color
resizeHandleThe line at a column’s trailing edge that a drag resizes
moveIndicatorThe line where a dragged column will drop
rowEvery body row
rowOddThe odd rows, after row (stripe)
rowHoverThe row the pointer is over, after row
rowSelectedThe selected row, after row
rowActiveThe row a context menu was asked for where cells do not select, until the next press, after row
cellEvery body cell
cellSelectedThe selected cell, after cell
cellActiveThe cell a context menu was asked for, until the next press, after cell
cellColumnSelectedThe cells of the selected column, after cell
rowHeaderThe row header cells, the row numbers
rowHeaderCornerThe corner above the row headers
fixedEdgeThe line at the fixed columns’ trailing edge, over the columns that scroll under them
emptyThe box of the empty slot

#Props

PropRustTypeDefaultDescription
columns.columns(..)DataTableColumn[][]The columns, left to right. Fixed ones lead.
data.data(..)Row[][]The rows: objects by column key, or arrays by position.
rowCount.row_count(..)numberthe rows heldHow many rows there are in all.
dataStart.data_start(..)number0The row of the first row of data.
rowClasses.row_classes(..)string[][]Classes of the rows given, by their place in data: a row’s own look, with no call into JS per row.
rowHeight.row_height(..)number32The height of a body row, in points.
headerHeight.header_height(..)numberthe row heightThe height of a title row.
groups.groups(..)DataTableColumnGroup[][][]Labels above the columns, lowest level first.
sort.sort(..)DataTableSortWhat the data is sorted by, shown on the title.
rowSelectable.row_selectable(..)booleantruePresses and keys select rows.
columnSelectable.column_selectable(..)booleantrueA title’s press selects its column, and the horizontal keys move between columns.
cellSelectable.cell_selectable(..)booleanfalsePresses and keys select cells.
selectOnModifierPress.select_on_modifier_press(..)booleantrueA press with Shift or the command key held selects a row.
selection.selection(..)DataTableSelectionWhat is selected, when the app sets it.
loopSelection.loop_selection(..)booleantrueThe arrow keys go around the ends.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers call the table.
rowHeader.row_header(..)booleanshown with cellSelectableA column of row numbers at the left.
stripe.stripe(..)booleanfalseOdd rows get the rowOdd part.
resizableColumns.resizable_columns(..)booleantrueA drag of a column’s edge resizes it.
movableColumns.movable_columns(..)booleantrueA drag of a title moves its column.
loading.loading(..)booleanfalseThe table is loading: onLoadMore waits and loadingView shows.
hasMore.has_more(..)booleanfalseThere are more rows to load.
loadMoreThreshold.load_more_threshold(..)number20How many rows from the last make onLoadMore fire.
fontFamily.font_family(..)stringthe theme’s font-sansThe font of the cells.
fontSize.font_size(..)number14The font size of the cells, in points.
scrollbar.scrollbar(..)booleantrueThe scroll bars.
verticalScrollbar, horizontalScrollbar.vertical_scrollbar(..), .horizontal_scrollbar(..)booleanscrollbarOne axis’s scroll bar (the horizontal one is the desktops’ bar under the table).
bounces.bounces(..)booleantrueWhether scrolling bounces at the ends, where the platform does so.
renderCell.render_cell(..)(cell: DataTableCell) => JSX.ElementDraws the cells in view.
renderHeaderCell.render_header_cell(..)(cell: DataTableHeaderCell) => JSX.ElementDraws the titles.
empty.empty(..)JSX.ElementWhat shows when there are no rows.
loadingView.loading_view(..)JSX.ElementWhat shows over the rows while loading.
classes.classes(..)DataTableClasses{}Tailwind classes by part.
styles.styles(..)DataTableStyles{}Inline styles by part, over the classes.
class, stylestyle methodsstring, StyleProp<ViewStyle>The table’s box.

#Events

EventRustDescription
onSelectionChange.on_selection_change(|selection: &Option<DataTableSelection>| ..)The user changed the selection: { row?, column? }, or null.
onRowPress.on_row_press(|event: &DataTablePress| ..)A row or cell was pressed: { row, column?, clicks, shift, control, alt, platform }.
onDoublePress.on_double_press(|target: &DataTableTarget| ..)A row, cell or title was pressed twice: { row?, column? }.
onContextMenu.on_context_menu(|event: &DataTableContextMenu| ..)A context menu was asked for: { row?, column?, x, y }, the point in the root view.
onSortChange.on_sort_change(|sort: &Option<DataTableSort>| ..)A sort button was pressed: the next sort { key, direction }, or null.
onColumnResize.on_column_resize(|key: &SharedString, width: &Points| ..)A column was resized, at every step of a drag and once more when a fit sets it.
onColumnMove.on_column_move(|event: &DataTableColumnMove| ..)A column was moved: { key, from, to, order }.
onVisibleRowsChange.on_visible_rows_change(|range: &DataTableRange| ..)The rows in view changed: { start, end }. Fetch rows from here.
onVisibleColumnsChange.on_visible_columns_change(|range: &DataTableRange| ..)The scrolling columns in view changed.
onLoadMore.on_load_more(|| ..)The rows in view came within loadMoreThreshold of the last while hasMore and not loading.
  • Editor and DiffView: the other data-heavy base components.
  • Menu: the menu to open from onContextMenu.
  • nsh/base: the other base components.