Skip to content

components data table

GitHub Actions edited this page Sep 18, 2026 · 2 revisions

components/data-table

Import: import { RowActions, DataTable } from "@tundralibs/ui/data-table"

Rendered in every variant on the catalogue (deno task build:demos, then demo/index.html). Guides: Getting started · rAPId integration · Theming.

Functions

RowActions

RowActions(props: RowActionsProps): Html

Row actions as an in-row strip instead of a floating menu: the kebab opens a row-height ink strip of labelled buttons anchored at the row's end (it covers the row's values while open, nothing moves, nothing floats), with a close button; Escape and an outside click close it and focus returns to the kebab. The same idiom as the bulk bar — one row gets a row strip, many rows get the header strip. Pass it as DataTable.rowActions.

DataTable

DataTable<T>(props: DataTableProps): Html

Types

DataTableColumn

Prop Type Required Description
key string yes
label string yes
sortable boolean
numeric boolean Right-aligns and applies tabular figures.
mono boolean Monospace + smaller: use for ids, shas, codes.
pinned boolean Sticky first column. Only set this on one column.
render `(row: T) => Html string`

DataTableProps

Prop Type Required Description
id string yes Required: history push refuses a region with no id.
title string
columns DataTableColumn[] yes
rows T[] yes
rowKey (row: T) => string yes
selectable boolean
selected string[]
selectName string Form field name the row checkboxes submit under (default "selected").
sort `{ key: string; dir: "asc" "desc" }`
buildSortHref `(key: string, dir: "asc" "desc") => string`
bulkActions Html Bulk actions, shown only when selected is non-empty. With bulkAction set, make each one a submit button that names the operation — Button({ type: "submit", attrs: { name: "op", value: "archive" } }) — and the server receives op plus one selectName entry per checked row.
bulkAction string URL the selection posts to. When set, the bulk bar and the rows sit in a <form method="post"> carrying data-action (a rAPId swap that replaces this table, outer into #<id>; a plain navigation without the runtime — answer with a redirect, PRG). The toolbar and footer stay outside the form, so a search box or a filter never submits it. Without it the bulk bar is a view concern only and its buttons need their own wiring.
toolbar Html
footer Html
rowActions (row: T) => Html
maxHeight `"sm" "md" "lg"`
empty Html Shown instead of the body when rows is empty — an Empty, or anything.
emptyMessage string Plain-text alternative to empty. @default "Nothing to show."
attrs Attrs

RowAction

Prop Type Required Description
label string yes
href string A link (GET). Without it the item is a <button> — give it attrs (a form's formaction, data-action, …).
danger boolean The destructive one — rendered last, after a separator, in the danger colour.
attrs Attrs

RowActionsProps

Prop Type Required Description
id string yes Base id; the strip is <id>-strip. Derive it from the row key (§4).
label string yes Accessible name of the group, e.g. "Actions for INV-2048".
items RowAction[] yes

Usage

Each example as the rAPId call and the HTML it renders — the markup a plain page writes by hand. Icons are inline SVG in the real output; they are shortened to <svg …>…</svg> here.

Invoices: sortable, selectable, with bulk and row actions

Sort links swap the table in place and push history; bulk buttons post op plus one selected per checked row through the table's own form; the kebab opens a row action strip.

DataTable<Invoice>({
  id: "invoices",
  title: "Invoices",
  selectable: true,
  sort: { key: "id", dir: "desc" },
  buildSortHref: (key, dir) => `/invoices?sort=${key}&dir=${dir}`,
  bulkAction: "/invoices/bulk",
  columns: [
    { key: "id", label: "Invoice", pinned: true, mono: true, sortable: true },
    { key: "client", label: "Client", sortable: true },
    { key: "total", label: "Total", numeric: true },
  ],
  rows: invoices,
  rowKey: (r) => r.id,
  bulkActions: html`${
    Button({ label: "Archive", size: "sm", type: "submit", attrs: { name: "op", value: "archive" } })
  }${Button({ label: "Clear", size: "sm", attrs: { "data-bulk-clear": "" } })}`,
  rowActions: (r) =>
    RowActions({
      id: `inv-${r.id}`,
      label: `Actions for ${r.id}`,
      items: [{ label: "View", href: `/invoices/${r.id}` }, {
        label: "Archive",
        danger: true,
        attrs: {
          "data-action": `/invoices/${r.id}/archive`,
          "data-method": "post",
          "data-target": "#invoices",
          "data-swap": "outer",
        },
      }],
    }),
  footer: Pagination({ page: 1, totalPages: 4, buildHref: (p) => `/invoices?page=${p}`, target: "#invoices" }),
})
<div class="data-table" id="invoices">
  <div class="data-table__toolbar">
    <span class="data-table__title">Invoices</span>
  </div>
  <form class="data-table__form" method="post" action="/invoices/bulk" data-action="/invoices/bulk" data-target="#invoices" data-swap="outer">
    <div class="data-table__scroll">
      <div class="data-table__bulk" hidden data-bulk-bar>
        <span class="data-table__bulk-count" data-bulk-count>0 rows selected</span>
        <span class="data-table__bulk-sep"></span>
        <button type="submit" class="btn btn--sm" name="op" value="archive">Archive</button>
        <button type="button" class="btn btn--sm" data-bulk-clear="">Clear</button>
      </div>
      <table class="data-table__table">
        <thead>
          <tr>
            <th class="data-table__select">
              <input type="checkbox" aria-label="Select all rows" data-select-all>
            </th>
            <th class="data-table__cell--pinned" aria-sort="descending">
              <a class="data-table__sort" href="/invoices?sort=id&amp;dir=asc" data-action="/invoices?sort=id&amp;dir=asc" data-target="#invoices" data-swap="outer" data-push>
                INVOICE
                <svg width="11" height="11" width="2" ></svg>
              </a>
            </th>
            <th class="">
              <a class="data-table__sort" href="/invoices?sort=client&amp;dir=asc" data-action="/invoices?sort=client&amp;dir=asc" data-target="#invoices" data-swap="outer" data-push>CLIENT</a>
            </th>
            <th class="data-table__num">TOTAL</th>
            <th class="data-table__actions">
              <span class="sr-only">Actions</span>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr class="data-table__row" data-row-key="INV-2048">
            <td class="data-table__select">
              <input type="checkbox" name="selected" aria-label="Select row INV-2048" data-select-row value="INV-2048">
            </td>
            <td class="data-table__cell--pinned data-table__mono">INV-2048</td>
            <td class="">Northwind Traders</td>
            <td class="data-table__num">$12,400.00</td>
            <td class="data-table__actions">
              <button type="button" class="btn btn--ghost btn--sm btn--icon" data-row-actions="#inv-INV-2048-strip" aria-expanded="false" aria-controls="inv-INV-2048-strip">
                <svg width="16" height="16" width="2" ></svg>
                <span class="sr-only">Actions for INV-2048</span>
              </button>
              <div class="data-table__row-actions" id="inv-INV-2048-strip" role="group" aria-label="Actions for INV-2048" hidden>
                <a class="btn btn--sm" href="/invoices/INV-2048">View</a>
                <span class="data-table__row-actions-sep"></span>
                <button type="button" class="btn btn--sm btn--danger" data-action="/invoices/INV-2048/archive" data-method="post" data-target="#invoices" data-swap="outer">Archive</button>
                <span class="data-table__row-actions-sep"></span>
                <button type="button" class="btn btn--ghost btn--sm btn--icon" data-row-actions-close aria-label="Close actions">
                  <svg width="14" height="14" width="2" ></svg>
                </button>
              </div>
            </td>
          </tr>
          <tr class="data-table__row" data-row-key="INV-2047">
            <td class="data-table__select">
              <input type="checkbox" name="selected" aria-label="Select row INV-2047" data-select-row value="INV-2047">
            </td>
            <td class="data-table__cell--pinned data-table__mono">INV-2047</td>
            <td class="">Contoso Ltd</td>
            <td class="data-table__num">$3,120.00</td>
            <td class="data-table__actions">
              <button type="button" class="btn btn--ghost btn--sm btn--icon" data-row-actions="#inv-INV-2047-strip" aria-expanded="false" aria-controls="inv-INV-2047-strip">
                <svg width="16" height="16" width="2" ></svg>
                <span class="sr-only">Actions for INV-2047</span>
              </button>
              <div class="data-table__row-actions" id="inv-INV-2047-strip" role="group" aria-label="Actions for INV-2047" hidden>
                <a class="btn btn--sm" href="/invoices/INV-2047">View</a>
                <span class="data-table__row-actions-sep"></span>
                <button type="button" class="btn btn--sm btn--danger" data-action="/invoices/INV-2047/archive" data-method="post" data-target="#invoices" data-swap="outer">Archive</button>
                <span class="data-table__row-actions-sep"></span>
                <button type="button" class="btn btn--ghost btn--sm btn--icon" data-row-actions-close aria-label="Close actions">
                  <svg width="14" height="14" width="2" ></svg>
                </button>
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  </form>
  <div class="data-table__footer">
    <nav aria-label="Pagination">
      <ul class="pagination">
        <li>
          <span class="pagination__link pagination__link--disabled" aria-disabled="true" aria-label="Previous page">&#8249;</span>
        </li>
        <li>
          <a class="pagination__link" href="/invoices?page=1" aria-current="page" aria-label="Page 1" data-action="/invoices?page=1" data-target="#invoices" data-swap="outer" data-push="">1</a>
        </li>
        <li>
          <a class="pagination__link" href="/invoices?page=2" aria-label="Page 2" data-action="/invoices?page=2" data-target="#invoices" data-swap="outer" data-push="">2</a>
        </li>
        <li>
          <span class="pagination__ellipsis" aria-hidden="true">&hellip;</span>
        </li>
        <li>
          <a class="pagination__link" href="/invoices?page=4" aria-label="Page 4" data-action="/invoices?page=4" data-target="#invoices" data-swap="outer" data-push="">4</a>
        </li>
        <li>
          <a class="pagination__link" href="/invoices?page=2" aria-label="Next page" data-action="/invoices?page=2" data-target="#invoices" data-swap="outer" data-push="">&#8250;</a>
        </li>
      </ul>
    </nav>
  </div>
</div>

Nothing to show

DataTable<Invoice>({
  id: "invoices",
  columns: [{ key: "id", label: "Invoice" }, { key: "client", label: "Client" }],
  rows: [],
  rowKey: (r) => r.id,
  empty: Empty({ variant: "inline", title: "No invoices yet", icon: "invoice" }),
})
<div class="data-table" id="invoices">
  <div class="data-table__scroll">
    <table class="data-table__table">
      <thead>
        <tr>
          <th class="">INVOICE</th>
          <th class="">CLIENT</th>
        </tr>
      </thead>
      <tbody></tbody>
    </table>
    <div class="data-table__empty">
      <div class="empty empty--inline">
        <span class="empty__icon">
          <svg width="16" height="16" width="2" ></svg>
        </span>
        <span class="empty__body">
          <span class="empty__title">No invoices yet</span>
        </span>
      </div>
    </div>
  </div>
</div>

CSS hooks

Classes defined by components/data-table/data-table.css — structural, token-driven; override from an unlayered stylesheet (see Theming):

.btn, .btn--danger, .btn--icon, .data-table, .data-table__actions, .data-table__bulk, .data-table__bulk-count, .data-table__bulk-sep, .data-table__cell--pinned, .data-table__empty, .data-table__footer, .data-table__form, .data-table__mono, .data-table__num, .data-table__row--selected, .data-table__row-actions, .data-table__row-actions-sep, .data-table__scroll, .data-table__scroll--lg, .data-table__scroll--md, .data-table__scroll--sm, .data-table__select, .data-table__sort, .data-table__table, .data-table__title, .data-table__toolbar, .empty, .table

Behaviour

components/data-table/data-table.js ships in ui.js (delegated on document, re-initialised after a rAPId swap).

Attributes it reads or writes: data-bulk-bar, data-bulk-clear, data-bulk-count, data-row-actions, data-row-actions-close, data-select-all, data-select-row, data-table.

Events: rapid:swapped.

Clone this wiki locally