CwDataTable

Async data loading, search, sort, pagination, virtual scrolling, zebra rows, custom cell rendering (e.g. CwDuration), page-size picker, toolbar actions, and per-row action buttons.

DevEUIActions

No data available

Columns Settings

Choose which columns are visible for this grid.

Built-in menu refresh callback count: 0. Column visibility is persisted per grid ID.

CwDataTable example Copy code
<CwDataTable
	columns={columns}
	loadData={loadData}
	rowKey="id"
	gridId="devices_grid"
	rowTextSizeKey="textSize"
	searchable
	pageSize={10}
	onRefresh={refreshDevices}
	rowActionsHeader="Actions"
>
	{#snippet actionsHeader()}
		<CwButton size="sm" variant="primary">+ Add Device</CwButton>
		<CwButton size="sm" variant="secondary">Export</CwButton>
	{/snippet}
	{#snippet rowActions(row)}
		<div class="row-actions">
			<CwButton size="sm" variant="ghost" onclick={() => handleEdit(row)}>Edit</CwButton>
			<CwButton size="sm" variant="danger" onclick={() => handleDelete(row)}>Delete</CwButton>
		</div>
	{/snippet}
	{#snippet cell(row, col, defaultValue)}
		{#if col.key === 'lastSeen'}
			<CwDuration from={row.lastSeen} />
		{:else}
			{defaultValue}
		{/if}
	{/snippet}
</CwDataTable>

Grouped Rows

Pass groupBy with a row key or callback to insert category headers in the standard paginated table. Grouping is intentionally ignored when virtualScroll is enabled.

DevEUI

No data available

Columns Settings

Choose which columns are visible for this grid.

Grouped datatable Copy code
<CwDataTable
	columns={columns}
	loadData={loadData}
	rowKey="id"
	gridId="devices_grouped_grid"
	rowTextSizeKey="textSize"
	groupBy="group"
	searchable
	pageSize={12}
>
	{#snippet cell(row, col, defaultValue)}
		{#if col.key === 'lastSeen'}
			<CwDuration from={row.lastSeen} />
		{:else}
			{defaultValue}
		{/if}
	{/snippet}
</CwDataTable>

Phone View

The table now responds to its own width, so this phone shell shows the rotated mobile fields, compact toolbar, and a single-button actions column.

DevEUIAction

No data available

Columns Settings

Choose which columns are visible for this grid.

Phone-width datatable Copy code
<div class="datatable-phone-preview">
	<CwDataTable
		columns={columns}
		loadData={loadData}
		rowKey="id"
		gridId="devices_phone_grid"
		rowTextSizeKey="textSize"
		searchable
		pageSize={4}
		rowActionsHeader="Action"
	>
		{#snippet rowActions(row)}
			<CwButton size="sm" variant="secondary" onclick={() => handleEdit(row)}>
				View
			</CwButton>
		{/snippet}
		{#snippet cell(row, col, defaultValue)}
			{#if col.key === 'lastSeen'}
				<CwDuration from={row.lastSeen} />
			{:else}
				{defaultValue}
			{/if}
		{/snippet}
	</CwDataTable>
</div>

<style>
	.datatable-phone-preview {
		width: min(100%, 24rem);
		margin-inline: auto;
	}
</style>

Fill Parent Height + Internal Scroll

Use fillParent when the surrounding shell already owns height. In flex layouts, make the table a direct flex child and keep shrinking wrappers at min-height: 0 so the internal scroll viewport can resolve correctly.

DevEUI

No data available

Columns Settings

Choose which columns are visible for this grid.

Fill-parent datatable Copy code
<div class="datatable-shell">
	<div class="datatable-shell__body">
		<CwDataTable
			columns={columns}
			loadData={loadData}
			rowKey="id"
			gridId="devices_fill_parent_grid"
			rowTextSizeKey="textSize"
			fillParent
			searchable={false}
			pageSize={25}
		/>
	</div>
</div>

<style>
	.datatable-shell {
		display: flex;
		flex-direction: column;
		height: clamp(18rem, 50vh, 26rem);
		min-height: 0;
	}

	.datatable-shell__body {
		display: flex;
		flex: 1 1 auto;
		min-height: 0;
	}
</style>

Virtual Scroll + Preserved Query State

Virtual mode keeps native scrolling for touch devices, incrementally fetches matching pages, and preserves search, sort, and external filters in every request. Use virtualScrollHeight when the table owns its own viewport, or combine virtualScroll with fillParent inside a bounded flex shell.

DevEUI

No data available

Columns Settings

Choose which columns are visible for this grid.

Virtual-scroll datatable Copy code
<script lang="ts">
	let statusFilter = $state<'all' | 'online' | 'offline' | 'warning'>('all');
	const filters = $derived(statusFilter === 'all' ? {} : { status: [statusFilter] });

	async function loadData(query: CwTableQuery): Promise<CwTableResult<Device>> {
		const rows = applyServerQuery(query);
		return {
			rows: rows.slice((query.page - 1) * query.pageSize, query.page * query.pageSize),
			total: rows.length
		};
	}
</script>

<CwDataTable
	columns={columns}
	loadData={loadData}
	rowKey="id"
	gridId="devices_virtual_grid"
	filters={filters}
	virtualScroll
	virtualScrollHeight="24rem"
	virtualRowHeight={52}
	virtualOverscan={14}
	pageSize={75}
/>

Virtual Scrolling Documentation

Use this checklist before enabling virtualScroll in production. The feature is intentionally query-driven so the same loadData contract works for both pagination and continuous scrolling.

What virtual mode changes

  • Pagination stays the default. Nothing changes until you pass virtualScroll.
  • The table still calls loadData(query) with page, pageSize, search, sort, and filters.
  • In virtual mode, pageSize becomes the fetch batch size rather than a single visible page.
  • The DOM only renders the visible row window plus overscan, so large datasets stay responsive.

What stays preserved

  • Search remains built in and still debounces before refetching.
  • Sorting still resets to the top of the result set and refetches with the new order.
  • External filters continue to flow through query.filters on every request, even while the table is fetching additional pages during scroll.
  • Custom cells, row actions, row click handlers, and row-level loading styles still work the same way.

Layout and iPad guidance

  • Give the table a bounded viewport with fillParent or virtualScrollHeight. Virtual mode needs an actual scroll container.
  • If you use fillParent in a flex dashboard, keep each shrinking ancestor at min-height: 0 so the table can resolve a non-zero viewport height.
  • The scroll container uses native overflow scrolling with momentum, which is what you want on iPad instead of a custom gesture layer.
  • Keep virtualRowHeight close to the real rendered row height. If the estimate is too small or too large, the scroll window will feel less accurate.
  • Increase virtualOverscan when touch users scroll quickly and you want more rows pre-rendered ahead of the viewport.

Recommended implementation checklist

  • Return a stable total from loadData whenever possible so the footer and load-more logic stay accurate.
  • Apply filtering and sorting on the same query path the table already uses for pagination. Do not create a second code path just for virtual mode.
  • If the table renders but never scrolls or keeps loading, inspect the layout chain first. Virtual prefetch only starts once the viewport has a real height.
  • Use a batch size that matches the density of the screen. Dense admin tables can often use 75 or 100; taller mobile cards may prefer smaller batches.
  • Test on a real touch device before shipping. Fast momentum scrolling is where overscan and row-height estimates matter most.

Row-Level Update Indicator Demo

Rows where cwloading === true are highlighted and blurred, while the rest of the table stays visible.

DevEUIUpdate

No data available

Columns Settings

Choose which columns are visible for this grid.

Row-level loading convention Copy code
interface Device {
	id: string;
	name: string;
	cwloading?: boolean;
}

async function updateRow(row: Device) {
	row.cwloading = true;
	await api.updateDevice(row.id);
	row.cwloading = undefined;
}

<CwDataTable columns={columns} loadData={loadData} rowKey="id" />

Row-Level Loading Documentation

Use a row convention to trigger update state styling. The datatable checks each row with a strict boolean comparison.

  • Set cwloading: true on a row object to show the yellow background + blur indicator.
  • Set cwloading to false, null, undefined, or remove it to restore normal row styles.
  • For async row updates, set cwloading before the request and clear it after the request resolves.
  • Only the affected rows change state; the table dataset remains visible during the async update.
Documentation Upgrade

Start here

CwDataTable is query-driven. The table owns search, sort, pagination, refresh, column visibility, optional grouped rows, and virtual windowing, while your `loadData(query)` function stays authoritative for rows, totals, and filters. Built-in column settings persist visible columns per grid ID in local storage.

How to think about it

  1. Start from the query contract Every interaction funnels back through `loadData(query)`. Search text, sort state, `page`, `pageSize`, and `filters` are all part of the same request shape.
  2. Use pagination by default Keep the default mode when the dataset is moderate or when you want explicit page navigation. Turn on `virtualScroll` when the main goal is continuous browsing through large result sets.
  3. Group rows only in paginated mode Use `groupBy` when you want visual category headers inside the default paginated table. Group headers are intentionally skipped in `virtualScroll` mode so row windowing stays predictable.
  4. Use fillParent inside a bounded flex shell If a dashboard card or split pane already owns height, make the table a direct flex child with `fillParent` and keep shrinking ancestors at `min-height: 0` so the internal viewport can resolve.
  5. Use virtualScrollHeight when the table owns height When you do not control the parent layout, set `virtualScrollHeight` and let the table manage its own viewport. This is the simplest standalone pattern.
  6. Treat filters as external state Search and sort UI are built in. Filters are intentionally passed in as `Record<string, string[]>` so your page owns filter controls while the table still preserves them in every request.
  7. Fix layout before debugging data loading Virtual mode only prefetches once the viewport has a real height. If the table renders but does not continue loading on scroll, inspect the layout chain first.
  8. Tune for touch, not just desktop `virtualRowHeight` and `virtualOverscan` matter most on iPad and other touch devices. Keep row height estimates honest and overscan high enough to avoid blank gaps during momentum scrolling.

Core API for pagination, grouping, and virtual mode

Pagination is the default behavior. Use `groupBy` there when you want section headers between rows. In virtual mode, `pageSize` becomes the number of rows fetched per request rather than the number of rows shown on one discrete page, and grouped headers are disabled to keep the virtual window stable. Every table also includes a built-in overflow menu for refresh and persisted column settings. Pair `virtualScroll` with either `virtualScrollHeight` or `fillParent` so the table has a real scroll viewport.

APITypeDetails
columns Required
CwColumnDef<T>[]Column definitions for headers, alignment, sortability, and optional string cell formatters.
loadData Required
(query: CwTableQuery) => Promise<CwTableResult<T>>Async row loader. Receives `page`, `pageSize`, `search`, `sort`, `filters`, and `signal`, and should return the matching `rows` plus `total` when known.
rowKey Required
keyof T & stringStable unique key used for keyed row rendering and row actions.
searchable

Default: true

booleanShows the built-in search input in the toolbar.
filters

Default: {}

Record<string, string[]>Externally-controlled filters preserved in every request. Use this when your page owns dropdowns, chips, tabs, or other filter controls.
pageSize

Default: 20

numberRows per page in pagination mode. In virtual mode, this becomes the batch size requested each time the table fetches more rows.
pageSizeOptions

Default: [10, 20, 50, 100]

number[]Toolbar choices shown in the rows-per-page or rows-per-batch dropdown.
groupBy
keyof T & string | ((row: T) => string | number | boolean | null | undefined)Optional grouping key or callback for the default paginated renderer. The table inserts group headers for each resolved label and ignores this prop in `virtualScroll` mode.
ungroupedLabel

Default: 'Ungrouped'

stringFallback group header label used when `groupBy` resolves to `null`, `undefined`, or an empty string.
gridId

Default: derived from route

stringStorage key for persisted column visibility. Defaults to the current page path plus `_grid`; pass an explicit value when multiple tables share one route.
virtualScroll

Default: false

booleanEnables continuous scrolling. The table incrementally loads pages, renders only the visible row window plus overscan, and waits for a real viewport before prefetching more rows.
virtualScrollHeight

Default: '28rem'

stringViewport height used when `virtualScroll` is enabled without `fillParent`. Choose this when the table should manage its own standalone scroll area. Accepts any CSS size such as `24rem`, `50vh`, or `480px`.
virtualRowHeight

Default: 52

numberEstimated row height in pixels for virtual windowing. Keep this close to the real rendered row height for the smoothest touch scrolling.
virtualOverscan

Default: 12

numberExtra rows rendered above and below the viewport. Higher values reduce visible pop-in during fast scrolls and iPad momentum scrolling.
fillParent

Default: false

booleanMakes the table flex to fill a bounded parent and turns the body region into the scroll container. Best for dashboard cards, split panes, and other flex layouts that already define height. Any shrinking ancestor should also allow it with `min-height: 0`.
actionsHeader
SnippetSnippet rendered in the toolbar before the built-in overflow menu. Use this for add, export, filter, or other table-level actions.
toolbarActions
SnippetDeprecated alias for `actionsHeader`, kept so existing toolbar content keeps rendering during migration.
cell
Snippet<[T, CwColumnDef<T>, string]>Optional custom cell renderer. Useful for badges, durations, links, and richer markup while still falling back to the table’s default string value.
rowActions
Snippet<[T]>Optional per-row actions column rendered on the far right.
rowActionsHeader
stringOptional text label for the row actions column header. If omitted, the header stays blank.
rowTextSizeKey
stringOptional row property name containing a CSS font-size value when a dataset needs row-level text scaling.
onSearch / onSort / onPageSizeChanged / onRefresh
callbacksOptional observers for analytics, syncing external controls, or reacting to table query changes outside the table itself. `onRefresh` fires when the built-in Refresh menu action is selected.

Copy-paste examples

These snippets intentionally show the full public API surface the live demo relies on.

Basic paginated table

Use this first. Search and sort are built in, while your loader stays responsible for filtering, slicing, and total counts.

Basic paginated table Copy code
<CwDataTable
	columns={columns}
	loadData={loadData}
	rowKey="id"
	gridId="devices_grid"
	searchable
	pageSize={20}
/>
Grouped paginated table

Use `groupBy` when operators need category headers like greenhouse, zone, or device family while keeping normal pagination.

Grouped paginated table Copy code
<CwDataTable
	columns={columns}
	loadData={loadData}
	rowKey="id"
	gridId="devices_grouped_grid"
	groupBy="group"
	pageSize={12}
	searchable
/>
Virtual scrolling with external filters

Use this when the table owns the viewport height with `virtualScrollHeight`, while the page still owns filter state across search, sort, and scrolling.

Virtual scrolling with external filters Copy code
<script lang="ts">
	let status = $state<'all' | 'online' | 'offline' | 'warning'>('all');
	const filters = $derived(status === 'all' ? {} : { status: [status] });
</script>

<CwDataTable
	columns={columns}
	loadData={loadData}
	rowKey="id"
	gridId="devices_virtual_grid"
	filters={filters}
	virtualScroll
	virtualScrollHeight="24rem"
	virtualRowHeight={52}
	virtualOverscan={14}
	pageSize={75}
/>
Fill-parent virtual table inside a flex dashboard shell

Prefer this layout when the surrounding shell already defines height. The key is making the table a flex child and keeping each shrinking wrapper at `min-height: 0`.

Fill-parent virtual table inside a flex dashboard shell Copy code
<div class="table-shell">
	<div class="table-shell__body">
		<CwDataTable
			columns={columns}
			loadData={loadData}
			rowKey="id"
			gridId="devices_fill_parent_grid"
			fillParent
			virtualScroll
			pageSize={100}
		/>
	</div>
</div>

<style>
	.table-shell {
		display: flex;
		flex-direction: column;
		height: clamp(22rem, 58vh, 32rem);
		min-height: 0;
	}

	.table-shell__body {
		display: flex;
		flex: 1 1 auto;
		min-height: 0;
	}
</style>