Dense data table: wraps a native <table> (light DOM, so cells can hold any markup and Angular bindings). Sortable column: <th sortable>; sorted="asc|desc" is the current direction (the table sets aria-sort), data-key names the column (else its index). <td data-value="-71"> sorts by that value instead of the cell text. Cells: class="mono" (IP, MAC, IDs), class="num" (right-aligned figures). Rows: class="clickable", aria-selected="true", class="dim" (reset/inactive). Empty state: <td colspan="…" class="empty">.
Data mode: with columns (and rows) the table builds its own <table>; don't combine it with own markup. Assign new arrays to update it. Rows are kept per key (see rowKey): new rows move and update the existing <tr>, so focus, text selection and open details stay; only cells whose class or content changed are touched.
Pagination: page-size adds a kern-pagination below the rows. internal pages the rows itself (back to page 1 after sorting); external shows the rows it gets and the app loads a page on kernPageChange (bubbles from the embedded pagination) and sets total. Labels: set them on table.querySelector('kern-pagination').
Selection: selectable adds a first column with a kern-checkbox per row and one in the header for the visible rows of the current page. Rows are identified by data-key on the <tr> (data mode: rowKey, default row.id or the object identity). selected holds the keys and survives sorting, paging and new rows; selected rows get aria-selected="true". For data loaded from a server use rowKey or id: without both, new objects get new keys.
Detail rows: a <tr kern-details> right after its row is hidden until the row is expanded; it spans all columns, moves with its row when sorting and is no row for paging or sorting. A <button kern-expand> in the row toggles it (the table sets aria-expanded and aria-controls). Data mode: detail adds a chevron column and the detail rows. expanded holds the keys of the open rows.
Column state (data mode): columnState shows, hides, orders and pins columns by key (e.g. from kern-table-columns or a saved view); the rows, their keys, selection and details stay. Pinned columns stay at the start or end while the table scrolls sideways; with columns pinned to the start, the selection and toggle columns stay too. Own markup: no column state, render the columns you want yourself.
Examples
Device table
kern-table wraps a native <table> in the light DOM, so cells can hold any markup. Use class="mono" for IPs, MACs and IDs, class="num" for right-aligned figures, and class="clickable", aria-selected="true" or class="dim" on rows.
Mark a header with sortable and the table sorts its rows itself: numbers numerically, text in the page language, empty cells (and –) last. sorted="asc|desc" sets the initial order; the table keeps it and aria-sort up to date. Where a cell shows a component instead of its value, put the value in data-value. compact makes rows 30px instead of 36px.
With controlled="external" the table does not touch the rows. A click on a sortable header (or sortBy()) only emits kernSort with { key, column, direction }; your code loads or orders the data and sets sorted on the header. Use this when the server sorts, or when a framework renders the rows (Angular @for). data-key names a column, otherwise key is its index.
When a filter matches nothing, render a single cell spanning all columns with class="empty".
Name
IP
Status
No devices match “garage”.
<kern-table>
<table>
<thead>
<tr><th>Name</th><th>IP</th><th>Status</th></tr>
</thead>
<tbody>
<tr><td colspan="3" class="empty">No devices match “garage”.</td></tr>
</tbody>
</table>
</kern-table>
Built from data
Set columns and rows and the table builds its own <table>; don't combine this with own markup. Each column reads row[key]; cell returns a Node (for components like kern-signal) or text, sortValue what to sort by. getRowClass, getCellClass and getHeaderCellClass add classes; kernRowClick reports clicks on a row (not on buttons or fields in it). Assign new arrays to update the table. Sorting, controlled and sortBy() work as with markup.
loading dims the content, shows a bar under the header and sets aria-busy. In data mode without rows it shows loading-rows skeleton rows instead; label-empty is the text for empty data. loading works with markup too.
page-size adds a kern-pagination below the rows; page-sizes offers other sizes. The table pages its rows itself, with data or with own markup, and goes back to page 1 after sorting. kernPageChange bubbles from the embedded pagination; set its labels on table.querySelector('kern-pagination').
With controlled="external" the table shows the rows it gets and only reports kernPageChange and kernSort. Your code loads the page and sets rows, total and page (usually back to 1 after sorting). Here a timeout stands in for the server.
selectable adds a first column with a checkbox per row. The header checkbox selects or clears the visible rows of the current page and shows a mixed state when only some of them are selected. Rows are identified by data-key on the <tr>; selected holds the keys and survives sorting, paging and new rows, and selected rows get aria-selected="true". kernSelectionChange reports the new keys (cancel it to keep selected as it is). A click on a checkbox is no row click. label-select-all and label-select-row name the checkboxes for screen readers.
In data mode rowKey returns a row's key (default: row.id, else a key per row object). For data loaded from a server set rowKey or give rows an id: without both, the key belongs to the object, so a selection follows it through sorting and filtering, but reloaded objects are not selected. Set selected to select rows from the app, e.g. for a bulk action like “only devices with changes”; the table keeps keys of rows that are filtered out.
A <tr kern-details> right after a row is its detail row: hidden until the row is expanded, spanning all columns (the selection column, too). It moves with its row when sorting, doesn't count as a row for paging and hides with its row. A <button kern-expand> in the row toggles it; the table sets aria-expanded and aria-controls. expanded holds the keys of the open rows (data-key), kernRowToggle reports { key, open } (cancel it to keep expanded), and toggleRow(key, open?) opens or closes a row from the app.
In data mode detail returns the content of a row's detail row (a Node or text) and adds a first column with a chevron toggle. It is called when a row is opened for the first time. label-expand-row names the toggle for screen readers. expanded uses the same keys as selected: rowKey or row.id (use one of them for server data), else a key per row object.
New rows don't rebuild the table: rows are kept per key (rowKey, else row.id) and moved into the new order, and only cells whose text or class changed are touched. A focused button in a cell, selected text, open detail rows and their scroll position survive updates, even several per second. Rows that are no longer shown (removed, filtered, on another page) are dropped with their nodes.
cell is called on every update. Its third argument is the Node the cell shows now: change it and return it to keep it, or return a new Node to replace it. Text is only written when it changed. detail is called once, when a row is opened; update its content yourself. Here the progress bars and buttons are created once per row. Sort by progress and focus a button: the rows move every second, the button keeps the focus and the open log its scroll position.
<kern-table compact></kern-table>
const el = (tag, props) => Object.assign(document.createElement(tag), props);
const table = root.querySelector('kern-table');
let jobs = [
{ id: 'k', name: 'Kitchen light', progress: 12 },
{ id: 'b', name: 'Bathroom fan', progress: 48 },
{ id: 's', name: 'Shutter south', progress: 73 },
];
table.columns = [
{ key: 'name', label: 'Device', sortable: true },
{
key: 'progress', label: 'Progress', sortable: true, width: '40%',
// Created once per row, then only its value changes.
cell: (job, _i, bar = el('kern-progress', { label: job.name })) => Object.assign(bar, { value: job.progress, valueText: `${job.progress} %` }),
},
{
key: 'action', label: 'Action',
// The same button on every update: it keeps the focus while its row moves.
cell: (job, _i, button = el('kern-button', { size: 'sm', textContent: 'Restart' })) => button,
},
];
table.detail = (job) => el('kern-code', { heading: 'Log', maxHeight: '120px', code: Array.from({ length: 20 }, (_, i) => `${job.name}: step ${i + 1}`).join('\n') });
table.rows = jobs;
const timer = setInterval(() => {
if (!table.isConnected) return clearInterval(timer);
jobs = jobs.map((job) => ({ ...job, progress: job.progress >= 100 ? 0 : Math.min(100, job.progress + Math.ceil(Math.random() * 6)) }));
table.rows = jobs;
}, 1000);
Column state
In data mode columnState shows, hides, orders and pins columns by their key: [{ key, hidden?, pinned? }], the array order is the column order. It is plain data, so an app can store it (see kern-table-columns and the table views pattern). Columns the state doesn't name keep their place in columns, unknown keys are ignored, so a state stored before a column was added or removed still works. Without a state the table uses columns[].hidden and columns[].pinned.
pinned: 'start' | 'end' keeps a column in view while the table scrolls sideways. With columns pinned to the start, the selection and detail toggle columns stay in view before them.
minWidth keeps a column from shrinking; the table scrolls sideways instead. hideable: false keeps a column always visible, min-visible-columns keeps at least that many.
Rows, their keys, selection, open details, sorting (also by a hidden column) and the page stay when the state changes; cells move within their rows.
Own markup has no column state: render the columns you want yourself (e.g. with @if in Angular). Keys of header cells with colspan/rowspan would not name one column, so the table doesn't hide or move them.
Data mode: which columns show, in which order, and which are pinned, by key; plain data to store. Columns it doesn't name keep their place in columns, unknown keys are ignored. Default: columns[].hidden and .pinned.
columns
KernTableColumn<any>[] | undefined
Data mode: column definitions. The table builds its own <table> from them and rows.
compactreflectsdefault false
boolean
30px rows instead of 36px.
controlledreflectsdefault 'internal'
"external" | "internal"
internal: the table sorts its rows itself. external: it only emits kernSort; the app orders the rows and sets sorted on the header (in data mode: new rows and columns[].sorted).
Data mode: content of a row's detail row; adds a first column with a toggle. Called when the row is first opened; the content stays while the row is shown (new rows don't rebuild it), so update it yourself for live data.
expandedmutabledefault []
string[]
Keys of the expanded rows (see selected and rowKey for keys).
Data mode: key of a row for selected, expanded and keeping its <tr> through updates. Default: row.id, else a key per row object (kept through sorting, filtering and reordering, but new for reloaded objects; use rowKey or id for server data). Rows that are no objects use their index in rows.
rows
any[] | undefined
Data mode: one object per row; each column reads row[column.key].
selectablereflectsdefault false
boolean
Adds a first column with a checkbox per row and a header checkbox for the visible rows of the current page.
selectedmutabledefault []
string[]
Keys of the selected rows (data-key, in data mode rowKey). Keys of rows not shown (filtered, other page) stay. Data mode without rowKey and id: the key belongs to the row object, so reloaded objects are not selected.
total
number | undefined
controlled="external": number of all rows (the table only gets the current page).
Events
kernRowClick
CustomEvent<{ row: any; index: number; }>
Data mode: a row was clicked (not on a button, link or field in it). index is the position in rows.
kernRowToggle
CustomEvent<{ key: string; open: boolean; }>
A row's details were opened or closed (button or toggleRow()). Cancelable: preventDefault() keeps expanded as it is.
kernSelectionChange
CustomEvent<{ selected: string[]; }>
The selection was changed with a checkbox. Cancelable: preventDefault() keeps selected as it is.