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
| Pattern | Use it for |
|---|---|
| Plain table | Key/value panels and small result sets. |
| Sortable | Anything the operator scans and reorders. Opt columns out with soma-table-unsortable. |
| Sticky header | Long result sets in a height-capped pane. The header stays readable while the rows scroll under it. Combines with sortable and zebra. |
| RESTful table | Small admin collections the operator edits in place: CRUD against a REST endpoint. |
| List group | Row lists that aren't columnar, such as settings, inbox previews, pick lists. |
Examples
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-sortat 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-unsortableopts 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 (inlinemax-heightor 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
| Class | Effect |
|---|---|
.soma-table | Base 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-interactive | Opt-in row hover tint. Only rows that actually do something should signal clickability. |
.soma-table-zebra | Dense read-only tables: alternating row stripes instead of row borders. |
.soma-table-sticky | On 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-sortable | Enables sorting (auto-init). Sortable headers get a pointer cursor, a reserved indicator slot, and a chevron that previews on hover. |
.soma-table-unsortable | Per-header opt-out. |
th[aria-sort] | Set by the component, styled by the CSS: primary-tinted header, chevron up (ascending) or down (descending). |
.soma-restfultable | Added by the inline-edit CRUD component alongside .soma-table. See the RESTful table page. |
JavaScript
Constructor
| Member | Description |
|---|---|
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. |
| Throws | When the selector matches nothing (no element matched) or the element is not a <table>. |
| Auto-init | Every table.soma-table-sortable is bound once at DOMContentLoaded; calling Soma.sortableTable on it later returns that same instance. |
Instance methods
| Member | Description |
|---|---|
.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 field | Value |
|---|---|
th | The sorted header cell. |
columnIndex | Zero-based index of the sorted column (th.cellIndex). |
direction | 'ascending' | 'descending'. |
ascending | Boolean mirror of direction, kept for pre-drift listeners. |
column | Legacy 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}`;
});