Search pages, headings and code across the docs, examples and tutorials.

to navigateto open
Dashboard

Tables

Typed rows with sorting, paging, selection, and row actions.

table shows a list of rows as a table. You hand it a getter for the rows and describe the columns; every feature, from sorting to row menus, turns on by the option you pass.

dashboard/pages/raiders.ts
import { defineDashboardPage, page, table } from 'ohnejs/dashboard';

interface Raider {
  name: string;
  role: string;
  level: number;
}

const raiders: Raider[] = [
  { name: 'Thrall', role: 'Shaman', level: 80 },
  { name: 'Jaina Proudmoore', role: 'Mage', level: 80 },
  { name: 'Anduin Wrynn', role: 'Priest', level: 79 },
];

export default defineDashboardPage(() =>
  page(
    { title: 'Raiders' },
    table(() => raiders, {
      key: (raider) => raider.name,
      columns: {
        name: { label: 'Name', value: (raider) => raider.name, sortable: 'text' },
        role: { label: 'Role', value: (raider) => raider.role },
        level: { label: 'Level', value: (raider) => raider.level, align: 'end' },
      },
    }),
  ),
);
A raiders table sorted by level, with a role badge column and a help icon in a header

The row type comes from the getter, so every raider in the columns is a Raider and a typo fails the typecheck. key names each row, the way each does.

Values and render

A column shows its value, or what render builds:

ts
columns: {
  gold: { label: 'Gold', value: (raider) => raider.gold, align: 'end' },
  role: { label: 'Role', render: (raider) => badge(raider.role, { variant: 'soft' }) },
}
  • A number value prints in the dashboard language with even-width digits; null prints nothing.
  • value is also the sort key, so a rendered column can keep one to stay sortable.
  • help adds a tooltip to the header.
  • Columns show in the order you write them. Number-like names such as '2024' jump to the front, as JavaScript orders such keys.

Sorting

sortable adds a sort button to the header:

ts
const sort = ref<TableSort<'name' | 'level'> | null>({ column: 'level', direction: 'desc' });

table(() => raiders.value, { key, columns, sort });
  • The table sorts by the column's value: numbers as numbers, text in natural order, empty values last.
  • A click sorts ascending, a second click descending.
  • Omitted, sort starts unsorted and the table keeps it.
  • With serverSort: true a click only writes sort and the rows show in the order they come. Use it when your API sorts, and send sort.value with the request.

Pagination

pageSize splits the rows into pages, with page buttons under the table:

ts
table(() => members.value, { key, columns, pageSize: 20 })
  • The buttons hide when everything fits on one page.
  • A refresh keeps the page you are on; a new sort goes back to page one.
  • For pages your API serves, render pagination yourself and hand the table one page of rows.

Loading and empty

The rows getter tells the table what to show:

ts
const raids = useResource<Raid[]>('GET /raids');

table(raids, { key: (raid) => raid.UUID, columns, empty: 'No raids planned' })
  • undefined shows skeleton rows at the final row height.
  • [] shows the empty state, reading empty or a default text.
  • A useResource result adds its failures: an error with a retry, or a locked state for a 403.

Opening rows

href makes each row lead somewhere:

ts
table(() => raiders.value, { key, columns, href: (raider) => `/raiders/${raider.name}` })
  • A click opens the row, and Enter opens a focused one.
  • Cmd or Ctrl and a middle click open it in a new tab.
  • onOpen runs your own code instead, or alongside.
  • On a table with selection, a double-click opens, so a single click stays free for checkboxes.

Selection

A table with a checkbox per row, one row selected and the guild master's box disabled

selection adds a checkbox per row, bound to a set of keys:

ts
const picked = ref<ReadonlySet<string>>(new Set());

table(() => raiders.value, {
  key: (raider) => raider.name,
  columns,
  selection: { selected: picked, selectable: (raider) => raider.rank !== 'Guild Master' },
})
  • Shift-click selects the range from the last row you picked.
  • The header box selects every selectable row on the page, or clears them.
  • Keys on other pages stay selected, so a selection can span pages.
  • checkboxes decides when the column shows, and all replaces the header box's state and toggle.

Row actions

A table with one row's actions menu open: two actions, a divider, then Remove from guild

actions adds a menu button at the end of each row:

ts
actions: (raider) => [
  [{ label: 'Promote', icon: 'arrow-up', onSelect: () => promote(raider) }],
  [{ label: 'Remove', icon: 'trash-x', destructive: true, onSelect: () => remove(raider) }],
]

The function runs each time a menu opens, so it can read live state. The groups are the ones menu takes. A row whose groups are all empty shows no menu button.

A right-click on the row opens the same menu as a context menu.

Acting on the selection

Opening a row's menu never changes the selection. To act on every selected row, read the selection in actions: when the row is part of it, return the actions for all of them.

ts
const picked = ref<ReadonlySet<string>>(new Set());

table(() => raiders.value, {
  key: (raider) => raider.name,
  columns,
  selection: { selected: picked },
  actions: (raider) => {
    const names = picked.value;
    if (!names.has(raider.name) || names.size < 2) {
      return [[{ label: 'Promote', icon: 'arrow-up', onSelect: () => promote(raider) }]];
    }
    return [
      [{ label: 'Clear selection', icon: 'square-off', onSelect: () => (picked.value = new Set()) }],
      [
        {
          label: `Remove ${names.size}`,
          icon: 'trash-x',
          destructive: true,
          onSelect: () => remove(names),
        },
      ],
    ];
  },
})

Layout and scrolling

The table fills its container's width and scrolls sideways when the columns need more:

ts
card(table(() => raiders.value, { key, columns, maxHeight: '24rem' }), { flush: true })
  • The header sticks while the table has a bounded height: maxHeight, or a parent with a height.
  • The checkbox and first columns stick to the left, the actions to the right.
  • Cells stay on one line; wrap: true lets a column wrap, at least minWidth or 16rem wide.
  • fixed: true keeps the set width of each column and truncates longer text.
  • Inside a container narrower than 30rem, the first column narrows and wraps.