Skip to content

Data table

Sort, filter and page are signals. Any change calls @get('/api/products'), which sends the signals in the query string, and the server answers with the rendered table plus the page count and total. Because the table is morphed by id, the search box keeps focus while you type. data-query-string mirrors the state into the URL, and data-init refetches when the page loads with a non-default state.

Live
updating…
Anviltools70
Augermeasuring938
Bellowsmaterials4413
Bracetools461
Chiselmeasuring8126
Clampmaterials8314
Dividermeasuring12027
Doweltools11839
Sorted by · state lives in the URL
---
import { products } from '../../data/products';
import ProductTable from './table/ProductTable.astro';
const rows = [...products].sort((a, b) => a.name.localeCompare(b.name)).slice(0, 8);
---
<div class="ex table-app"
data-signals__ifmissing="{ q: '', sort: 'name', dir: 'asc', page: 1, pages: 4, total: 30 }"
data-query-string:q="''" data-query-string:sort="'name'" data-query-string:dir="'asc'" data-query-string:page="1"
data-init="($q || $sort !== 'name' || $dir !== 'asc' || $page !== 1) && @get('/api/products')"
data-indicator:loading>
<header class="bar">
<label class="search"><span aria-hidden="true">⌕</span><input class="field" type="search" placeholder="Filter by name or category" data-bind:q data-on:input__debounce.250ms="$page = 1; @get('/api/products')"></label>
<span class="chip" data-text="$total + ' products'"></span>
<span class="muted small" data-show="$loading">updating…</span>
</header>
<div class="scroll"><ProductTable rows={rows} sort="name" dir="asc" /></div>
<footer class="bar">
<span class="muted small">Sorted by <b data-text="$sort"></b> <span data-text="$dir === 'asc' ? '↑' : '↓'"></span> · state lives in the URL</span>
<nav class="pager">
<button class="btn ghost" type="button" data-attr:disabled="$page <= 1" data-on:click="$page--; @get('/api/products')">‹</button>
<span class="num">page <b data-text="$page"></b> / <b data-text="$pages"></b></span>
<button class="btn ghost" type="button" data-attr:disabled="$page >= $pages" data-on:click="$page++; @get('/api/products')">›</button>
</nav>
</footer>
</div>
<style is:global>
.table-app { background: var(--ex-surface); }
.table-app .bar { display: flex; align-items: center; gap: .8rem; padding: .7rem 1rem; }
.table-app header.bar { border-bottom: 1px solid var(--ex-line); }
.table-app footer.bar { border-top: 1px solid var(--ex-line); justify-content: space-between; }
.table-app .search { position: relative; flex: 1; max-width: 22rem; }
.table-app .search span { position: absolute; left: .7rem; top: 50%; transform: translateY(-50%); color: var(--ex-muted); }
.table-app .search .field { padding-left: 2rem; }
.table-app .small { font-size: .8rem; }
.table-app .scroll { overflow: auto; }
#products { width: 100%; table-layout: fixed; border-collapse: collapse; margin: 0; font-size: .9rem; }
#products td, #products th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#products .stock { justify-content: end; }
#products th { position: sticky; top: 0; background: var(--ex-surface-2); text-align: left; font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--ex-muted); padding: .5rem 1rem; }
#products th.num, #products td.num { text-align: right; }
#products th button { font: inherit; background: none; border: 0; padding: 0; cursor: pointer; color: inherit; display: inline-flex; gap: .3rem; align-items: center; }
#products th button i::after { content: '↕'; opacity: .35; }
#products th.sorted button { color: var(--ex-accent); }
#products th.sorted.asc button i::after { content: '↑'; opacity: 1; } #products th.sorted.desc button i::after { content: '↓'; opacity: 1; }
#products td { padding: .55rem 1rem; border-top: 1px solid var(--ex-line); }
#products tbody tr:hover td { background: var(--ex-accent-low); }
#products td.empty { text-align: center; color: var(--ex-muted); padding: 1.5rem; }
#products .tag { font-size: .72rem; padding: .15rem .5rem; border-radius: 999px; background: var(--ex-surface-2); }
#products .tag.tools { color: #7c5cff; } #products .tag.materials { color: var(--ex-warm); } #products .tag.measuring { color: var(--ex-accent); }
#products .stock { display: inline-grid; grid-template-columns: 3rem auto; gap: .4rem; align-items: center; }
#products .stock i { display: block; height: .4rem; border-radius: 999px; background: var(--ex-good); }
#products .stock.mid i { background: var(--ex-warm); } #products .stock.low i { background: var(--ex-bad); }
#products .stock.low span { color: var(--ex-bad); font-weight: 700; }
.table-app .pager { display: flex; align-items: center; gap: .5rem; font-size: .85rem; }
@media (max-width: 40rem) { #products .stock { grid-template-columns: auto; } #products .stock i { display: none; } #products th, #products td { padding-left: .6rem; padding-right: .6rem; } }
.table-app .pager .btn { padding: .3rem .7rem; }
</style>
  • data-on:input__debounce.250ms for search, data-on:click on header buttons that flip sort and dir
  • data-query-string for shareable URLs and data-init to honour them on load
  • readSignals on the server to drive the query; sse(patchElements(table), patchSignals({ page, pages, total }))
  • data-class:low and @intl inside server-rendered rows: attributes in patched HTML mount like any other
import { patchElements, patchSignals, readSignals, sse } from '@sigmx/astro/server';
import type { APIRoute } from 'astro';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import ProductTable from '../../components/examples/table/ProductTable.astro';
import { products } from '../../data/products';
export const prerender = false;
export const PAGE = 8;
export const GET: APIRoute = async ({ request }) => {
const { q = '', sort = 'name', dir = 'asc', page = 1 } = await readSignals(request);
const term = String(q).toLowerCase();
const rows = products
.filter((p) => !term || p.name.toLowerCase().includes(term) || p.category.includes(term))
.sort((a: any, b: any) => (a[sort] > b[sort] ? 1 : a[sort] < b[sort] ? -1 : 0) * (dir === 'desc' ? -1 : 1));
const pages = Math.max(1, Math.ceil(rows.length / PAGE));
const current = Math.min(Math.max(1, Number(page)), pages);
const container = await AstroContainer.create();
const table = await container.renderToString(ProductTable, {
props: { rows: rows.slice((current - 1) * PAGE, current * PAGE), sort, dir },
});
return sse(patchElements(table), patchSignals({ page: current, pages, total: rows.length }));
};

src/components/examples/table/ProductTable.astro

Section titled “src/components/examples/table/ProductTable.astro”
---
import type { Product } from '../../../data/products';
interface Props {
rows: Product[];
sort: string;
dir: string;
}
const { rows, sort, dir } = Astro.props;
const cols: [keyof Product, string, string][] = [
['name', 'Product', ''],
['category', 'Category', ''],
['price', 'Price', 'num'],
['stock', 'Stock', 'num'],
];
---
<table id="products">
<colgroup><col style="width:34%"><col style="width:24%"><col style="width:20%"><col style="width:22%"></colgroup>
<thead>
<tr>
{cols.map(([key, label, cls]) => (
<th class={`${cls} ${sort === key ? `sorted ${dir}` : ''}`}>
<button type="button" data-on:click={`$sort === '${key}' ? ($dir = $dir === 'asc' ? 'desc' : 'asc') : ($sort = '${key}', $dir = 'asc'); $page = 1; @get('/api/products')`}>{label}<i aria-hidden="true"></i></button>
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td colspan="4" class="empty">No products match.</td></tr>}
{rows.map((p) => (
<tr id={`p-${p.id}`}>
<td><b>{p.name}</b></td>
<td><span class={`tag ${p.category}`}>{p.category}</span></td>
<td class="num" data-text={`@intl('number', ${p.price}, { style: 'currency', currency: 'EUR' })`}>{p.price}</td>
<td class="num"><span class={`stock ${p.stock < 5 ? 'low' : p.stock < 15 ? 'mid' : ''}`}><i style={`width:${Math.min(100, p.stock * 2)}%`}></i><span>{p.stock}</span></span></td>
</tr>
))}
</tbody>
</table>