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
| Anvil | tools | 7 | 0 |
| Auger | measuring | 9 | 38 |
| Bellows | materials | 44 | 13 |
| Brace | tools | 46 | 1 |
| Chisel | measuring | 81 | 26 |
| Clamp | materials | 83 | 14 |
| Divider | measuring | 120 | 27 |
| Dowel | tools | 118 | 39 |
---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>What it uses
Section titled “What it uses”data-on:input__debounce.250msfor search,data-on:clickon header buttons that flipsortanddirdata-query-stringfor shareable URLs anddata-initto honour them on loadreadSignalson the server to drive the query;sse(patchElements(table), patchSignals({ page, pages, total }))data-class:lowand@intlinside server-rendered rows: attributes in patched HTML mount like any other
Server side
Section titled “Server side”src/pages/api/products.ts
Section titled “src/pages/api/products.ts”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>