-
Notifications
You must be signed in to change notification settings - Fork 41
example data table
<os-table> is the data-grid primitive: assign a columns descriptor and a data array and you get a styled table with optional per-column filters, click-to-sort, multi-row selection, sticky columns, sticky header, custom cell renderers, a loading skeleton, and a slottable empty state.
Status: Stable. The named events / filter kinds may still grow.
<os-table id="users"></os-table>const table = document.getElementById( 'users' );
table.columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' },
];
table.data = [
{ name: 'Alice', email: 'alice@a.com', role: 'admin' },
{ name: 'Bob', email: 'bob@b.com', role: 'editor' },
];That's the entire happy path. Everything below is opt-in.
The OsTable class is generic over the row type, but it is not yet on the Stable export list of the openstation package (see Importing the classes) — there is no class or type import for it. Until it joins that list, declare the slice of the element API you use as a local structural type; the property contract below is the documented surface:
interface User extends Record< string, unknown > {
name: string;
email: string;
role: 'admin' | 'editor';
logins: number;
}
interface UserColumn {
key: string;
label?: string;
filter?: boolean | 'text' | 'select';
sortable?: boolean;
align?: 'start' | 'center' | 'end';
sortValue?: ( row: User, value: unknown ) => unknown;
render?: ( value: unknown, row: User, index: number ) => string | Node;
}
type UserTable = HTMLElement & {
columns: UserColumn[];
data: User[];
getRowId: ( row: User, index: number ) => string | number;
};
const columns: UserColumn[] = [
{ key: 'name', label: 'Name', filter: 'text', sortable: true },
{ key: 'email', label: 'Email', filter: 'text' },
{ key: 'role', label: 'Role', filter: 'select' },
{ key: 'logins', label: 'Logins', align: 'end', sortable: true },
];
const table = document.querySelector< UserTable >( '#users' )!;
table.columns = columns;
table.data = users;
table.getRowId = ( row ) => row.email; // stable id for selectionSet column.filter to put a filter input under the header. Two kinds:
-
'text'(ortrue) — substring, case-insensitive. -
'select'— dropdown built from the unique column values.
table.columns = [
{ key: 'name', label: 'Name', filter: 'text' },
{ key: 'email', label: 'Email', filter: 'text' },
{ key: 'role', label: 'Role', filter: 'select' },
];Filter inputs are persistent across re-paints, so typing never loses focus or caret position. Read or pre-seed the filter map via table.filters, listen for changes, and clear them with the built-in method:
table.filters = { role: 'admin' }; // pre-seed
table.addEventListener( 'os-table-filter-change', ( e ) => {
console.log( e.detail.filters );
} );
table.clearFilters(); // drop everything (emits filter-change)Set column.sortable = true and the header cycles asc → desc → unsorted on click. Numbers compare numerically; everything else falls back to a locale-aware string compare. Provide column.sortValue for shaped sorts:
table.columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'created', label: 'Created', sortable: true,
sortValue: ( row ) => Date.parse( row.created ) },
{ key: 'priority', label: 'Priority', sortable: true,
sortValue: ( row ) => ({ low: 0, med: 1, high: 2 }[ row.priority ]) },
];Read or set the active sort programmatically:
table.sort = { key: 'name', direction: 'asc' };
table.addEventListener( 'os-table-sort-change', ( e ) => {
persist( e.detail.sort ); // null when cleared
} );
table.clearSort();If you don't want the built-in sort but want to react to header clicks (e.g. server-side sort), declare sortable: true and listen for os-table-sort-change — your handler can re-fetch and reassign table.data while letting the indicator UI handle itself.
Set selectable="single" or selectable="multi" and a checkbox column is auto-prepended. Multi mode adds a select-all checkbox in the header; single mode enforces at-most-one selected.
<os-table id="users" selectable="multi"></os-table>table.getRowId = ( row ) => row.email; // stable id (default: row index)
table.addEventListener( 'os-table-selection-change', ( e ) => {
console.log( e.detail.selection ); // ids[]
console.log( e.detail.rows ); // resolved row objects
} );
// Programmatic API
table.select( 'alice@a.com' );
table.deselect( 'alice@a.com' );
table.selectAll(); // multi only — selects the VISIBLE rows
table.clearSelection();
table.selection = new Set( savedIds ); // bulk replaceselectAll() (and the header select-all checkbox) selects the rows passing the active client-side filters — never rows a filter is currently hiding. The header checkbox tri-state follows the same rule: "checked" means every visible row is selected. Tables without client-side filters are unaffected.
Why getRowId matters: when the user reloads data from the server, selections survive the refresh because they're keyed by stable id, not array index. Fall back to the default (index) only when rows have no natural identifier.
Ids must be unique across the whole data set. If the table mixes entity kinds whose id sequences are independent (e.g. posts and comments carry numeric ids from different tables), qualify the id with the kind — getRowId = ( row ) => `${ row.type }:${ row.id }` — or two different rows will share one selection key and select (and act) together.
Destructive consumers: clear the selection when the data set changes. Selection deliberately survives data reassignment, so ids from a previous page / search / filter linger invisibly. If your bulk actions consume table.selection (trash, delete, role changes…), call table.clearSelection() whenever the query changes — otherwise a forgotten off-page selection rides silently into the next action. See src/comments-window/index.ts and src/posts-window/index.ts for the pattern.
<os-table sticky-columns="2" sticky-header striped hover></os-table>-
sticky-columns="N"pins the firstNcolumns. Widths are measured after layout and re-measured automatically — every paint runs three measurement passes (synchronous, microtask, animation frame) and aResizeObserverwatches the inner scroll element + the host so window resizes, hidden→visible transitions, sibling reflow, font loads, and scrollbar appearance all trigger a recompute. You don't have to call anything; offsets stay correct. Variable-width columns work, including RTL viainset-inline-start.If a column-1+ sticky cell ever ends up at
inset-inline-start: 0pxwhile the host is visible, the component logs a one-timeconsole.warnwith the measured widths and a pointer torecomputeLayout(). That should never fire in practice — it's an "if you see this, you've found the bug" tripwire. -
sticky-headerkeeps the header (plus the filter row, if any) pinned. -
Per-column override:
column.sticky = trueopts in even outside the band,column.sticky = falseopts out within it.
For sticky to engage the table needs a scrolling container. Set --os-ui-table-max-height (or wrap in any scrolling parent):
os-table { --os-ui-table-max-height: 400px; }If you set sticky-header on a table with no scroll container, the component logs a one-time console.warn after enough data has loaded to need scrolling — saves the "why isn't it sticking?" debug session.
The auto-injected expander (subTable) and select (selectable) columns count as leading sticky columns. Plan sticky-columns accordingly:
| Setup |
sticky-columns="N" keeps pinned |
|---|---|
| 4 data columns, no sub-table, no selection |
2 → cols 0, 1 |
4 data columns + subTable (expander prepended) |
2 → expander, col 0 |
4 data columns + selectable="multi"
|
2 → checkbox, col 0 |
4 data columns + selectable="multi" + subTable
|
3 → checkbox, expander, col 0 |
Rule of thumb: count from the visible left edge after all auto-prepended columns. If you want the checkbox + expander + name + email pinned in the busy case, that's sticky-columns="4".
Set subTable( row, index ) and an expander column is auto-prepended. Return any of:
-
null/undefined— no children for this row (no caret). -
{ columns, data, subTable? }— a nested<os-table>is rendered. Sub-tables can themselves declare asubTablefor unlimited nesting. - A
Node— fully custom expanded content (build it withdocument.createElementor by cloning a<template>).
table.subTable = ( order ) => order.items?.length
? {
columns: [
{ key: 'sku', label: 'SKU' },
{ key: 'qty', label: 'Qty', align: 'end' },
{ key: 'name', label: 'Item' },
],
data: order.items,
}
: null;Programmatic control of expansion:
table.expand( 3 );
table.collapse( 3 );
table.expandAll(); // every row that has children
table.collapseAll();
table.isExpanded( 3 ); // boolean
// Read or replace the full open set — useful for restoring state.
const open = Array.from( table.expanded );
localStorage.setItem( 'orders.open', JSON.stringify( open ) );
table.expanded = JSON.parse( localStorage.getItem( 'orders.open' ) || '[]' );<os-table loading loading-rows="5"></os-table>While loading is set, the body paints shimmering skeleton rows. Headers, filters, and sort indicators remain live. Toggle the attribute when the fetch resolves:
table.toggleAttribute( 'loading', true );
const data = await wp.os.fetch( '/api/users', undefined, { source: 'my-plugin/users-table' } ).then( ( r ) => r.json() );
table.data = data;
table.toggleAttribute( 'loading', false );prefers-reduced-motion: reduce disables the shimmer animation automatically.
The empty attribute is the text fallback. For richer empty states (button, illustration, multi-line copy) project light-DOM into the empty slot:
<os-table id="orders">
<div slot="empty">
<p>No orders yet.</p>
<os-button id="orders-cta">Create your first order</os-button>
</div>
</os-table>document.getElementById( 'orders-cta' ).addEventListener( 'click', openWizard );The slotted content shows whenever data.length === 0 OR every row got filtered out. If you want different empty states for "no data" vs "no matches," check table.filters from your handler and swap the slotted children accordingly.
column.render( value, row, index ) returns a string (rendered as text via textContent, so it's XSS-safe) or a Node. The html\`tagged-template helper the component sources use internally is not part of the package's public exports, so plugin code builds nodes withdocument.createElement`:
table.columns = [
{ key: 'avatar', label: '', width: '32px',
render: ( v ) => {
const img = document.createElement( 'img' );
img.src = String( v );
img.width = 24;
img.height = 24;
return img;
} },
{ key: 'name', label: 'Name' },
{ key: 'status', label: 'Status',
render: ( v ) => {
const badge = document.createElement( 'os-badge' );
badge.setAttribute( 'tone', v === 'active' ? 'success' : 'warning' );
badge.textContent = String( v );
return badge;
} },
];Best practice — pick one return shape per column. Mixing string and Node across columns is legal but harder to read at a glance. For plain text, return a string; for anything with markup, build the node imperatively with document.createElement. If you find yourself returning both from the same render based on a runtime check, that's usually a smell that the column wants splitting.
table.addEventListener( 'os-table-row-click', ( e ) => {
const { row, index, originalEvent } = e.detail;
openOrder( row.id );
} );Clicks on filter inputs, the expander button, and selection checkboxes do not fire os-table-row-click — they're marked data-noclick. Mark any of your own interactive cell content the same way to opt out:
render: ( v, row ) => {
const btn = document.createElement( 'button' );
btn.dataset.noclick = '';
btn.textContent = '×';
btn.addEventListener( 'click', () => del( row.id ) );
return btn;
}Same pattern as custom renderers — return an input. Two real gotchas:
-
Mark the control
data-noclickso clicks on it don't fireos-table-row-click. -
Avoid full-table repaints on every keystroke — they tear down and rebuild the input, losing focus/caret. Either commit on blur/Enter (mutate
row.fieldin place; reassigntable.dataonly on save), or keep edits in a side buffer (Map<rowId, edits>) that the renderer reads from.
{ key: 'name', label: 'Name',
render: ( v, row ) => {
const i = document.createElement( 'input' );
i.value = String( v );
i.dataset.noclick = '';
i.addEventListener( 'change', () => { row.name = i.value; } );
return i;
} }If editing is the primary use case, request a first-class column.editor API — the persistent-input plumbing is already in place for filters and would generalize naturally.
| Method | What it does |
|---|---|
expand(i) / collapse(i)
|
Open/close one row. |
expandAll() / collapseAll()
|
Open every row that has children / close everything. |
isExpanded(i) |
Boolean. |
expanded |
Get/set the full open-set (for state persistence). |
clearFilters() |
Drop every filter; emits filter-change. |
clearSort() |
Drop the active sort; emits sort-change. |
select(id) / deselect(id)
|
Mutate the selection by id. |
selectAll() / clearSelection()
|
Bulk operations (multi-mode). selectAll() selects only the rows passing the active client-side filters. |
selection |
Get/set the selection set. |
selectedRows |
Resolved row objects matching selection (resolves against the full data buffer). |
visibleRows |
Rows passing the active client-side filters — the set selectAll() operates on. Destructive bulk consumers should resolve selection against this, not data. |
getRowId |
Stable-id extractor (default: index). |
sort |
Get/set the active { key, direction } (or null). |
filters |
Get/set the filter map. |
scrollToRow(i) |
Bring the (filtered) row at index i into view. |
recomputeLayout() |
Force a sticky-offset / header-height recompute. Public escape hatch — usually not needed. |
| Attribute | Type | What it does |
|---|---|---|
sticky-columns |
integer | Pin the first N columns. Auto-injected expander/select columns count toward N. |
sticky-header |
boolean | Pin the header (and filter row). Needs a scrolling container. |
striped |
boolean | Zebra rows. |
hover |
boolean | Row hover highlight. |
compact |
boolean | Tighter padding + smaller font. |
bordered |
boolean | Vertical cell borders. |
selectable |
"single" | "multi" |
Prepends a checkbox column. |
loading |
boolean | Shimmering skeleton rows in place of body. |
loading-rows |
integer | Skeleton-row count when loading. Default 5. |
empty |
string | Text fallback when there are no rows. Slot empty for rich content. |
| Property | Default |
|---|---|
--os-ui-table-bg |
var( --os-ui-surface, #fff ) |
--os-ui-table-border |
var( --os-ui-border, rgba(0,0,0,0.08) ) |
--os-ui-table-header-bg |
var( --os-ui-surface-elevated, #f6f7f7 ) |
--os-ui-table-row-hover |
rgba(0,0,0,0.04) |
--os-ui-table-stripe |
var( --os-ui-surface-subtle, rgba( 0, 0, 0, 0.03 ) ) |
--os-ui-table-cell-padding |
8px 12px |
--os-ui-table-font-size |
13px |
--os-ui-table-max-height |
none |
--os-ui-table-skeleton-color |
rgba(0,0,0,0.06) |
--os-ui-table-skeleton-highlight |
rgba(0,0,0,0.14) |
| Name | event.detail |
Fires when |
|---|---|---|
os-table-filter-change |
{ filters } |
A filter input changed (or clearFilters() ran). |
os-table-sort-change |
{ sort } (or { sort: null }) |
A sortable header was clicked or sort was set. |
os-table-selection-change |
{ selection: id[], rows: T[] } |
Selection mutated. |
os-table-row-click |
{ row, index, originalEvent } |
A body row was clicked (excluding data-noclick). |
os-table-expand-change |
{ row, index, expanded } |
A row's sub-table was toggled. |
| Name | Default content | When it shows |
|---|---|---|
empty |
The empty attribute text |
data is empty OR all rows got filtered out. |
-
sticky-headerwith no scroll container. If--os-ui-table-max-heightis unset and no ancestor scrolls, sticky positioning is inert. The component warns once viaconsole.warnafter the data has filled the viewport. -
sticky-columnscounting auto columns. The expander and select columns are prepended;sticky-columns="2"pins them, not your first two data columns. Pad accordingly (see "Worked example" above). -
Mixing
column.align: 'end'with customrender. Alignment applies to the cell, but if your renderer returns adisplay: block-ish element it may not pick up text-align. Either settext-align: endon the rendered element or wrap in a<span>. -
Editable cells losing focus. Reassigning
table.dataon every keystroke triggers a full body repaint — the new<input>is a different element, so focus is lost. Commit on blur/Enter, or hold edits in a side buffer until save. -
Selection going stale on data refresh. Without
getRowId, ids are array indices — selections drift if rows reorder. SetgetRowId = ( row ) => row.idwhenever rows have a natural identifier. -
Colliding ids across mixed row kinds. Two rows whose
getRowIdreturns the same value are one row as far as selection is concerned — ticking one ticks both. Qualify the id (( row ) => `${ row.type }:${ row.id }`) when a list mixes entities from independent id sequences. -
Stale selection feeding destructive bulk actions. Selection survives
datareassignment by design. If your bulk actions readtable.selection(trash, delete…), callclearSelection()whenever the query (page, search, filter) changes, and resolve the selection againsttable.visibleRows(notdata) before acting — a data-driven change can hide a selected row without any filter event firing, and rows the user can't see must never be swept into a destructive action.
-
Layout primitives — wrap the table in
<os-body>/<os-panel>for the standard window shape. - JavaScript reference — every os-* component.
This wiki is generated from the docs/ directory — edits made here are overwritten by the next sync.
To change a page, open a pull request against docs/.
Guides
- Development guide
- Releasing openstation
- Agents security model
- API Index
- Architecture
- Bridge protocol — wiring overview
- <os-*> component reference
- Native Desktop Host — Experimental
- Desktop themes
- Dock customization — two registries, one mental model
- The event-driven framework
- Files on the Desktop
- Folder sharing
- Getting Started
- Hooks Reference
- Icons
- JavaScript Reference
- The Living Tree — algorithm definition
- Mio
- Native Windows & Framework Interop
- Plugin compatibility layer
- Progressive Web App (PWA)
- Station Home
- Using openstation from your own plugin
Migration notes
- Migration: built-in activity channels move to the os/ namespace
- Migration: window, wallpaper and widget bundles load on demand
- Migration — the navigation model
- Migration: a native window's tabs move to the window chrome
All examples
- AI Agents — extend and invoke from a plugin
- wp.os.ai.ask() — programmatic AI Copilot
- Tune the AI model config
- Custom arrange-menu action
- Open a child window its owner can't cover
- Style a specific admin page inside the iframe
- Code Blue — register your plugin's log file
- Open a file in the Code editor (deep-link from any window)
- Connect to a window — title-bar button + iframe pub/sub
- Content changes — live-refresh every window listing your type
- Custom window chrome (Experimental)
- Register a custom unfocused-window effect
- Example: render a data table
- Real file storage — react to uploads, gate policy, share from PHP
- React to a window being set free onto the real desktop
- Cross-window devtools — instrumentation primitives
- Add a dock item with a badge
- Decorate the dock without forking the renderer
- Replace the dock rail entirely
- Retune the Drafts widget's AI writing assistant
- Gate OpenStation by role
- Iframe-initiated window opens
- Build a feed reader without the bookkeeping
- Inject data into openStationConfig
- Render a list without losing clicks — renderKeyedList()
- Example: layout primitives (body → panel → row → col)
- Use <os-*> components from a plugin that ships as a zip
- Restyle and drive Mio
- Add an action that works on a whole selection
- WP Explorer — custom post types and their folder
- Add an action button to a WP Explorer preview pane
- Example: native Posts window
- Example: native window with tabs
- Native windows
- Customize note → post conversion
- Send a notification
- OAuth relay — connect to an external service
- OS-file drop
- <os-flyout> — window-scoped sliding card
- Plugins window — extras
- Track who's around — wp.os.presence
- Example: progress bar
- PWA install — surface your own button
- React to window events
- Example: extend the Trash
- Register a slash-command
- Register a desktop theme from a plugin
- Register a game
- Example: register a desktop icon (Jorvy)
- Register a wallpaper
- Register a widget
- Related entities — extend the title bar's "Related" menu
- The native-window render ctx
- Programmatic folder sharing
- Share state across multi-bundle plugins — wp.os.createSharedStore()
- Example: loading spinner
- Add an opt-in card to Station Home
- Accept drops on your desktop icon
- Give a tile two icons, one per state
- Add a row to a window's ⋯ menu
- Example: window activity & the status ring
- Window controls
- Subscribe to window lifecycle events
- Window links — relate windows and restyle the ties (Experimental)
- Window loading state — spinner overlay & ready signal
- Show a banner at the top of a window
- Pulse a window's icon — Window.requestAttention()
- Register a custom window reveal
- Window slots
- Window themes
- Native window with bundle-bound config