Tables

Summary

Quiet rows with muted small-caps headers on the widget surface. Add soma-table-sortable and every header sorts on click or Enter/Space — numeric-aware via Intl.Collator ("9" before "10"), one aria-sort column at a time. soma-table-sticky pins the header row inside a height-capped scroll pane. Inline-edit CRUD against a REST endpoint is the RESTful table; stacked non-tabular rows are the List group.

When to use

PatternUse it for
Plain tableKey/value panels and small result sets.
SortableAnything the operator scans and reorders. Opt columns out with soma-table-unsortable.
Sticky headerLong result sets in a height-capped pane. The header stays readable while the rows scroll under it. Combines with sortable and zebra.
RESTful tableSmall admin collections the operator edits in place: CRUD against a REST endpoint.
List groupRow lists that aren't columnar, such as settings, inbox previews, pick lists.

Examples

Sortable

Click a header (or Enter/Space) — the Actions column opts out:

Service Version Instances Uptime % Status Actions
nware-portal 2.4.1 12 99.98 healthy
nware-core 2.4.10 4 99.90 healthy
cortex-gateway 0.9.2 2 97.10 degraded
axon-runner 1.12.0 32 99.99 healthy
soma-cdn 1.2.3 8 91.40 failing

Sorts by zero-based column index: .sort(3, 'descending'). Last soma-table-sort: (none)

Plain table

Key/value panels and small result sets:

KeyValue
Regionus-east
Tierproduction
Ownerplatform-team

Caption and totals footer

A caption renders as a header strip; tfoot rows get a strong top border:

July deployments
ServiceCount
nware-portal14
cortex-gateway9
soma-cdn21
Total44

Interactive rows

.soma-table-interactive, an opt-in hover tint for clickable rows:

DeploymentWhen
nware-portal #4822 min ago
cortex-gateway #1131 h ago
axon-runner #9073 h ago

Hover a row. Rows here would open the deployment detail.

Static rows

The default — no hover signal on data that does nothing:

DeploymentWhen
nware-portal #4822 min ago
cortex-gateway #1131 h ago
axon-runner #9073 h ago

Same data without the class — hover stays quiet, as it should for read-only rows.

Zebra

Alternating stripes instead of row borders, for dense read-only data:

ServiceRegionInstancesOwner
nware-portalus-east12platform-team
nware-coreus-east4platform-team
cortex-gatewayus-west2cortex-team
axon-runnerus-west32cortex-team
soma-cdnglobal8platform-team

Sticky header

.soma-table-sticky on a height-capped scroll wrapper. Scroll the pane (it is keyboard-focusable) and the header stays pinned; sorting still works:

InstanceServiceRegionUptime %
portal-01nware-portalus-east99.98
portal-02nware-portalus-east99.95
portal-03nware-portalus-west99.91
core-01nware-coreus-east99.90
core-02nware-coreus-east99.88
gateway-01cortex-gatewayus-west97.10
gateway-02cortex-gatewayus-west96.84
runner-01axon-runnerus-west99.99
runner-02axon-runnerus-west99.97
runner-03axon-runnerus-east99.96
runner-04axon-runnerus-east99.93
cdn-01soma-cdnglobal91.40
cdn-02soma-cdnglobal92.65
cdn-03soma-cdnglobal93.02

Sorting behaviour

Sorting reorders the rows of the table's first <tbody> by the visible cell text (textContent, trimmed; rows missing the cell sort as empty). Comparison runs through a numeric-aware, case-insensitive Intl.Collator, so "9" sorts before "10" and "4,332" before "12,001". Version strings and counts order naturally without data attributes.

  • The first activation of a header sorts ascending; activating it again flips the direction.
  • Only one column carries aria-sort at a time; the CSS tints that header and shows the chevron indicator from it, so state and styling share one source of truth.
  • Sortable headers get tabindex="0"; Enter or Space on a focused header sorts, same as a click.
  • th.soma-table-unsortable opts a column out: no binding, no pointer cursor, no indicator (use it for actions columns).
  • Rows are moved, not rebuilt — event listeners bound to cells (like the Logs buttons above) survive sorting.

Sticky header

.soma-table-sticky pins the thead row to the top of the nearest scroll pane while the rows pass under it. Two placements:

  • On a scroll wrapper (the batteries-included form): the wrapper becomes the scroll pane (overflow-y: auto); cap its height yourself (inline max-height or your own class).
  • On the table itself: bring your own scrolling ancestor (a table box can't scroll reliably on its own); the header sticks to whatever pane scrolls.

The pinned header paints on --soma-surface-solid, the opaque tier, so rows never ghost through it on the translucent widget surface (deliberately not -overlay: the header is table chrome, not a floating menu at the top of the elevation ladder). Its bottom border is retained while pinned: the component switches the table to border-collapse: separate, because collapsed borders belong to the table's border grid and scroll away with the rows — visually identical, since .soma-table only draws horizontal borders.

A scrollable pane must be keyboard-reachable — give the wrapper tabindex="0" plus role="region" and an aria-label naming it (the same contract as the log viewer); it gets the focus ring. Combines freely with -sortable (the pinned headers still sort; the chevron indicator keeps working), -zebra and -interactive.

HTML

The base table puts headers in thead and data in tbody; no wrapper element needed:

<table class="soma-table">
  <thead><tr><th>Key</th><th>Value</th></tr></thead>
  <tbody>
    <tr><td>Region</td><td>us-east</td></tr>
  </tbody>
</table>

Sortable — one class on the table (auto-init binds it at DOMContentLoaded), per-column opt-out:

<table class="soma-table soma-table-sortable">
  <thead><tr>
    <th>Name</th>
    <th class="soma-table-unsortable">Actions</th>
  </tr></thead>
  <tbody>…</tbody>
</table>

A <caption> gives the table an accessible title and renders as a start-aligned header strip; a <tfoot> totals row gets a strong top border and semibold text:

<table class="soma-table">
  <caption>July deployments</caption>
  <thead><tr><th>Service</th><th>Count</th></tr></thead>
  <tbody>…</tbody>
  <tfoot><tr><td>Total</td><td>44</td></tr></tfoot>
</table>

Presentation variants combine freely with sortable. Hover tint for rows that act, stripes for dense read-only data:

<table class="soma-table soma-table-interactive">…</table>
<table class="soma-table soma-table-zebra">…</table>

Sticky header — the class on a height-capped scroll wrapper (focusable and named, since it scrolls); sortable combines:

<div class="soma-table-sticky" style="max-height: 240px"
     role="region" aria-label="Service instances" tabindex="0">
  <table class="soma-table soma-table-sortable">
    <thead><tr><th>Instance</th><th>Region</th></tr></thead>
    <tbody>…</tbody>
  </table>
</div>

CSS classes

ClassEffect
.soma-tableBase table; sorted column highlighted via th[aria-sort]. A <caption> renders as a start-aligned header strip; tfoot totals rows get a strong top border.
.soma-table-interactiveOpt-in row hover tint. Only rows that actually do something should signal clickability.
.soma-table-zebraDense read-only tables: alternating row stripes instead of row borders.
.soma-table-stickyOn the scroll wrapper (which becomes the scroll pane; cap its height, add tabindex="0" + role="region" + aria-label) or on the table (bring your own scroll pane): the header row pins to the pane top on the opaque --soma-surface-solid, bottom border retained. Combines with -sortable/-zebra/-interactive.
.soma-table-sortableEnables sorting (auto-init). Sortable headers get a pointer cursor, a reserved indicator slot, and a chevron that previews on hover.
.soma-table-unsortablePer-header opt-out.
th[aria-sort]Set by the component, styled by the CSS: primary-tinted header, chevron up (ascending) or down (descending).
.soma-restfultableAdded by the inline-edit CRUD component alongside .soma-table. See the RESTful table page.

JavaScript

Constructor

MemberDescription
Soma.sortableTable(elOrSelector)Get or create the singleton instance for a <table>. Works on ANY table: the .soma-table-sortable class is only the auto-init hook (and the styling); imperative binding needs no marker class.
ThrowsWhen the selector matches nothing (no element matched) or the element is not a <table>.
Auto-initEvery table.soma-table-sortable is bound once at DOMContentLoaded; calling Soma.sortableTable on it later returns that same instance.

Instance methods

MemberDescription
.sort(column)Sort by a column and toggle: ascending unless the column is already ascending. column is a <th> element or a zero-based column index.
.sort(column, 'ascending' | 'descending')Explicit direction as a string.
.sort(column, boolean)Boolean form: true = ascending, false = descending.
Unknown column.sort() throws (Soma.sortableTable: unknown column) for an out-of-range index or a <th> that isn't in this table's header row.
.refresh()Bind any header not yet bound, for columns added or re-rendered after init. Already-bound headers and -unsortable ones are skipped; safe to call repeatedly.
.destroy()Unbind all headers (listeners and tabindex removed) and drop the singleton. Row order and any aria-sort left on a header remain as they are.
.on('sort', fn) / .off('sort', fn)Subscribe/unsubscribe to the sort event (see below).

Methods return the instance, so calls chain (st.refresh().sort(0)) — except .destroy(), which returns nothing.

Events

Detail fieldValue
thThe sorted header cell.
columnIndexZero-based index of the sorted column (th.cellIndex).
direction'ascending' | 'descending'.
ascendingBoolean mirror of direction, kept for pre-drift listeners.
columnLegacy alias for th (the header cell under its old name).

The underlying DOM event is a bubbling soma-table-sort CustomEvent dispatched on the table for header clicks, keyboard activation and programmatic .sort() calls alike, usable directly with addEventListener for delegated listening.

Programmatic sorting — every direction form:

const st = Soma.sortableTable('#services');

st.sort(0);                   // toggle: ascending first, flips on repeat
st.sort(0, 'descending');     // direction string
st.sort(0, true);             // boolean: true = ascending
st.sort(th, false);           // a <th> element + descending

// st.sort(99);               // → Error: Soma.sortableTable: unknown column

Delegated listening. The event bubbles, so one listener can watch every sortable table on the page:

document.addEventListener('soma-table-sort', (e) => {
  console.log(e.target.id, '→ column', e.detail.columnIndex, e.detail.direction);
});

Headers rendered after init are inert until .refresh() binds them:

const table = document.querySelector('#services');
table.querySelector('thead tr').insertAdjacentHTML('beforeend', '<th>Cost</th>');
table.querySelectorAll('tbody tr').forEach((tr) => { /* …append the cells… */ });

Soma.sortableTable(table).refresh();   // the new header now sorts too

Integration — persist the operator's sort and restore it on the next visit:

const st = Soma.sortableTable('#services');

st.on('sort', (e) => {
  localStorage.setItem('services-sort', JSON.stringify({
    column: e.detail.columnIndex,
    direction: e.detail.direction,
  }));
});

const saved = JSON.parse(localStorage.getItem('services-sort') || 'null');
if (saved) st.sort(saved.column, saved.direction);

Tear down before replacing the table wholesale:

Soma.sortableTable('#services').destroy();  // unbinds headers, drops the singleton
// …swap the table's DOM, then re-init:
Soma.sortableTable('#services');

The live examples above, exactly as this page wires them:

// Auto-init already bound the .soma-table-sortable table; calling
// Soma.sortableTable again returns the same instance.
const st = Soma.sortableTable('#tbl-sortable');

// Sort by zero-based column index — here: Uptime %, descending.
document.querySelector('#tbl-sort-uptime').addEventListener('click', () => {
  st.sort(3, 'descending');
});

// The sort event fires for header clicks and programmatic sorts alike.
st.on('sort', (e) => {
  const { th, columnIndex, direction } = e.detail;
  document.querySelector('#tbl-sort-out').textContent =
    `${th.textContent.trim()} — column ${columnIndex}, ${direction}`;
});