Skip to content

DataTable

DataTable is rows of data in columns that line up, for a set big enough that scrolling it matters: a hundred rows or a hundred thousand. It sorts from its header, keeps one chosen row, and mounts only the rows in view. Reach for it when the data is a record with fields; when the rows are one thing each and the columns are not the point, LazyList is the smaller control, and when the data nests, Tree is the shape that flattens.

The table is one grid. The header and every row are subgrids of it, so a column is one track and nothing has to agree with anything about where it starts.

tsx
/**
 * The table, with its row type named once.
 *
 * `DataTable` is the library's one generic component, and a JSX tag
 * cannot pass a type argument through to the props it checks. An
 * instantiation expression names the type once here, and the tag below
 * is an ordinary component whose `columns`, `rows` and `onSelect` are
 * all typed as `Run`.
 */
const RunTable = DataTable<Run>;

/**
 * A table of twelve rows, sorted and chosen from, with the application
 * owning both.
 *
 * **The sort is controlled.** Pressing a header asks for the next sort
 * in the cycle (ascending, then descending, then none) and the
 * application writes it back, which is why the two buttons below can
 * set and clear the same value with no header press behind them.
 *
 * **The chosen row is controlled too, and it is an index into `rows`
 * rather than a position on screen.** Choose a row, sort the table, and
 * the same person is still chosen: the row moved and the selection did
 * not.
 *
 * The header is sticky, so it stays while the rows scroll under it, and
 * every row shares its column tracks: the table is one grid, and a row
 * is a subgrid of it.
 *
 * Click the table and use the arrows, Page Up and Page Down, Home and
 * End to move the choice; Enter or Space opens it.
 */
export function Runs(_inputs: Inputs<{}>, _ctx: ComponentContext) {
  const sort = internalState<DataTableSort | null>(null);
  const chosen = internalState(-1);
  const opened = internalState('nothing yet');

  const caption = combineLatest([sort, chosen]).pipe(
    map(([order, row]) => `${describeSort(order)}. Chosen: ${row < 0 ? 'nobody' : RUNS[row].name}`)
  );

  return (
    <column gap={12} padding={16} width={percent(100)} height={percent(100)}>
      <RunTable
        label="Scores"
        columns={COLUMNS}
        rows={RUNS}
        rowHeight={26}
        columnGap={8}
        height={168}
        sort={sort}
        onSortChange={next => (sort.value = next)}
        selectedRow={chosen}
        onSelect={index => (chosen.value = index)}
        onActivate={index => (opened.value = RUNS[index].name)}
      />
      <row gap={12} y="center">
        <button
          label="Sort by score"
          onClick={() => (sort.value = { column: 'score', direction: 'descending' })}
          padding={8}
          borderRadius={6}
          borderWidth={1}
          borderColor="border"
          backgroundColor="background"
          cursor="pointer"
          modifiers={[HOVER_CONTROL]}>
          <text text="Sort by score" fontSize={12} color="text" />
        </button>
        <button
          label="Clear sort"
          onClick={() => (sort.value = null)}
          padding={8}
          borderRadius={6}
          borderWidth={1}
          borderColor="border"
          backgroundColor="background"
          cursor="pointer"
          modifiers={[HOVER_CONTROL]}>
          <text text="Clear sort" fontSize={12} color="text" />
        </button>
      </row>
      <text text={caption} fontSize={12} color="textMuted" />
      <text text={opened.pipe(map(name => `Enter opened: ${name}`))} fontSize={12} color="textMuted" />
    </column>
  );
}

Press a header to sort by it: ascending, then descending, then back to the order the data came in. Choose a row and sort again, and the same person is still chosen, because the choice is an index into rows rather than a position on screen. The two buttons write the same sort the header writes, which is what a controlled sort is for.

Props

DataTable is the library's one generic component. A JSX tag cannot carry a type argument through to the props it checks, so name the row type once with an instantiation expression, as the example does:

tsx
const RunTable = DataTable<Run>;
PropTypeDefaultWhat it does
columnsreadonly DataColumn<T>[]requiredThe columns, and the table's track list. Read once, when the table is built.
rowsreadonly T[]requiredThe data, in its natural order. Sorting orders a view of it and leaves the array alone.
sortDataTableSort | nullnoneThe sort, when the application owns it. Supplying it makes the sort controlled.
defaultSortDataTableSort | nullnoneThe sort to start with, for a table that owns its own. Supplying both throws.
onSortChange(sort: DataTableSort | null) => voidnoneCalled with the sort a header press asks for, including null for the third press.
selectedRownumbernoneThe chosen row, as its index in rows. Supplying it makes the selection controlled.
defaultSelectedRownumbernoneThe row to start on. -1, meaning nothing chosen, when neither prop is given.
onSelect(index: number) => voidnoneCalled with the index in rows of the row a click or a key chose.
onActivate(index: number) => voidnoneCalled on Enter or Space, with the index of the chosen row. Nothing happens when nothing is chosen.
rowHeightnumber28The height the window expects of a row it has not measured yet.
columnGapnumber0Space between the columns, in the header and in every row.
labelstring'Table'The table's accessible name.
refUiNodeRefnoneReceives the node that is the table, for focusing it or reading where it is scrolled to.

DataColumn<T>

One column, and everything the table knows about it.

FieldTypeDefaultWhat it does
keystringrequiredThe column's identity, and what a DataTableSort names.
headerstringrequiredThe header's text, and the column's accessible name.
cell(row: T, index: number) => UiChildrequiredThe content of one cell. index is the row's index in rows, not its position.
widthUiTrackSizefr(1)The track this column takes: pixels, percent, fr, auto or minmax.
compare(a: T, b: T) => numbernoneOrders two rows by this column. A column without it cannot be sorted.
align'start' | 'center' | 'end''start'Where the cell's content sits in its track, in the header and in every row.

DataTableSort

{ column: string; direction: 'ascending' | 'descending' }, where column is a DataColumn's key. No sort at all is null rather than an absent field, which is why onSortChange can report it.

Every prop takes a plain value or an Observable of one, and the layout props on the library page apply here too.

Two things are read once rather than bound. columns is the table's track list, and tracks are not something the rows can be asked to re-agree on between frames, so a later array is not picked up: build a new table when the columns change. rowHeight and columnGap are read the same way. rows is bound, so new data does arrive, and so does a different number of rows.

Controlled and uncontrolled

There are two values here and each is owned separately:

tsx
// The application owns the sort and the chosen row.
<RunTable columns={COLUMNS} rows={RUNS} sort={sort} onSortChange={next => (sort.value = next)} />

// The table owns both, and reports them if asked.
<RunTable columns={COLUMNS} rows={RUNS} defaultSort={{ column: 'score', direction: 'descending' }} />

Which of the two applies is decided once, when the table is built, from whether the value prop was supplied. Passing sort and defaultSort together throws an error naming the component, and so does selectedRow with defaultSelectedRow.

A controlled table draws what it is handed. A header press is a request: onSortChange fires, and if nothing writes the value back then the arrow does not move and the rows do not reorder. That is also what lets something other than a header set the sort, which is what the buttons in the example do.

The chosen row is an index into rows, deliberately, rather than a position in the sorted view. A selection that meant "the fourth row on screen" would name a different record after every sort.

Keyboard

The table is one tab stop, and every sortable header is another. Focus lands on the table itself, the focus ring is drawn on it, and the keys below move the choice inside it. A chosen row that is scrolled out of view is scrolled back in, clear of the sticky header.

KeyWhat it does
DownThe next row
UpThe previous row
Page DownTen rows down
Page UpTen rows up
HomeThe first row
EndThe last row
Enter, SpaceCalls onActivate with the chosen row

A page is ten rows rather than a viewport's worth, because rows are of unknown height until they are mounted and a viewport's worth of them is not a number the table can know for the part of the data it has never seen. Every key clamps: a step past the end is the end, never the start.

On a sortable header, Enter and Space cycle that column's sort, which is the same cycle a press gives. A key that is bound is consumed; a key that is not is left for whatever is listening above, which is what keeps Tab, and an application's own shortcuts, working while the table has focus.

Semantics

WhatValue
Rolegrid, on the node that takes focus and scrolls
Namelabel
Header rowrole="row", named Column headers
Header cellrole="columnheader", named by the column's header
Sorted bya description of sorted ascending or sorted descending, on that header alone
Rowrole="row", with posInSet its place in the sorted order and setSize the number of rows
Chosen rowthe selected state, on that row
Cellrole="cell"

posInSet and setSize are the real ones. A mounted row is row 4,213 of 100,000 and says so, rather than reporting its place among the dozen rows that happen to exist, which is the one thing virtualization must not be allowed to say.

Which way a column is sorted is a description rather than a state. ARIA says it with aria-sort and UiSemantics has no state for it, so this is the honest place for it.

What this page has checked

The behaviour above is asserted by the spec beside the example, which drives the real runtime with a fake canvas: the sort cycle, the sort written from a button, the unsortable column that takes no press and no focus, the chosen row surviving a sort, every key in the table above, and the scroll that reveals the last row.

The example above was also driven by hand in Chrome on Linux: a press on the Score header sorted the rows and drew the arrow, a press on a row chose it, and End scrolled the table to its last row with the header holding its place. That was Canvas2D, which is what a reader sees here unless they asked for the other renderer, and Chrome is the extent of what any of it has been opened in.

The table sorts one column at a time and chooses one row at a time. Sorting is compare over an array in memory, so a data set too large to hold is a query rather than a rows prop.

Next

Tree is the same windowing over data that nests, and LazyList is the plain list underneath both of them. Overflow and scrolling is what the table scrolls with.