Table UI Elements
Responsive datatable. Klikbare sort per kolom, striped/bordered/minimal varianten, sticky header.
src/components/ui/Table.astro
---
/**
* Table
* Responsive data tabel. Striped, bordered of minimaal.
* Sorteer-indicatoren, sticky header, scroll wrapper.
*/
interface Column {
key: string;
label: string;
align?: 'left' | 'center' | 'right';
width?: string;
sortable?: boolean;
}
interface Props {
columns: Column[];
rows: Record<string, unknown>[];
variant?: 'default' | 'striped' | 'bordered' | 'minimal';
size?: 'sm' | 'md' | 'lg';
stickyHeader?: boolean;
caption?: string;
}
const {
columns = [],
rows = [],
variant = 'default',
size = 'md',
stickyHeader = false,
caption,
} = Astro.props;
---
<div class:list={['tbl-wrap', `tbl-wrap--${variant}`]} data-component="table">
<table class:list={['tbl', `tbl--${size}`, `tbl--${variant}`, { 'tbl--sticky': stickyHeader }]}>
{caption && <caption class="tbl__caption">{caption}</caption>}
<thead class="tbl__head">
<tr>
{columns.map(col => (
<th
class:list={['tbl__th', `tbl__th--${col.align ?? 'left'}`, { 'tbl__th--sortable': col.sortable }]}
style={col.width ? `width:${col.width}` : ''}
data-key={col.sortable ? col.key : undefined}
scope="col"
>
<span class="tbl__th-inner">
{col.label}
{col.sortable && (
<svg class="tbl__sort-icon" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 2v8M3 5l3-3 3 3M3 7l3 3 3-3" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
)}
</span>
</th>
))}
</tr>
</thead>
<tbody class="tbl__body">
{rows.map((row, ri) => (
<tr class:list={['tbl__row', { 'tbl__row--even': ri % 2 === 1 }]}>
{columns.map(col => (
<td class:list={['tbl__td', `tbl__td--${col.align ?? 'left'}`]}>
{String(row[col.key] ?? '—')}
</td>
))}
</tr>
))}
{rows.length === 0 && (
<tr>
<td class="tbl__empty" colspan={columns.length}>Geen gegevens beschikbaar</td>
</tr>
)}
</tbody>
</table>
</div>
<script>
document.querySelectorAll('[data-component="table"]').forEach(wrapper => {
const table = wrapper.querySelector<HTMLTableElement>('.tbl');
if (!table) return;
let sortKey = '';
let sortDir = 1;
wrapper.querySelectorAll<HTMLElement>('.tbl__th--sortable').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const key = th.dataset.key!;
if (sortKey === key) { sortDir *= -1; }
else { sortKey = key; sortDir = 1; }
// Update icons
wrapper.querySelectorAll('.tbl__th--sortable').forEach(t => {
t.setAttribute('aria-sort', t === th ? (sortDir === 1 ? 'ascending' : 'descending') : 'none');
});
// Sort rows
const tbody = table.querySelector<HTMLTableSectionElement>('tbody');
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll<HTMLTableRowElement>('tr:not(.tbl__empty-row)'));
const colIndex = Array.from(wrapper.querySelectorAll('.tbl__th--sortable')).indexOf(th);
const allThs = Array.from(table.querySelectorAll('th'));
const thIndex = allThs.indexOf(th);
rows.sort((a, b) => {
const av = a.cells[thIndex]?.textContent ?? '';
const bv = b.cells[thIndex]?.textContent ?? '';
const an = parseFloat(av);
const bn = parseFloat(bv);
if (!isNaN(an) && !isNaN(bn)) return (an - bn) * sortDir;
return av.localeCompare(bv, 'nl') * sortDir;
});
rows.forEach(r => tbody.appendChild(r));
});
});
});
</script>
<style>
.tbl-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: var(--radius, 0.5rem);
border: 1px solid rgba(0,0,0,0.08);
}
.tbl-wrap--minimal { border: none; }
.tbl {
width: 100%;
border-collapse: collapse;
font-size: 0.9375rem;
background: var(--color-bg, #fff);
}
.tbl--sm { font-size: 0.8125rem; }
.tbl--lg { font-size: 1rem; }
/* Caption */
.tbl__caption {
text-align: left;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-muted, #6b7280);
caption-side: top;
}
/* Head */
.tbl__head {
border-bottom: 2px solid rgba(0,0,0,0.08);
}
.tbl--sticky .tbl__head th {
position: sticky;
top: 0;
z-index: 1;
background: var(--color-bg, #fff);
}
.tbl__th {
padding: 0.75rem 1rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-muted, #6b7280);
white-space: nowrap;
background: rgba(0,0,0,0.02);
text-align: left;
}
.tbl--sm .tbl__th { padding: 0.5rem 0.75rem; }
.tbl--lg .tbl__th { padding: 1rem 1.25rem; }
.tbl__th--center { text-align: center; }
.tbl__th--right { text-align: right; }
.tbl__th-inner {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.tbl__sort-icon { opacity: 0.4; flex-shrink: 0; }
.tbl__th--sortable:hover .tbl__sort-icon { opacity: 1; }
[aria-sort="ascending"] .tbl__sort-icon,
[aria-sort="descending"] .tbl__sort-icon { opacity: 1; color: var(--color-accent, #6366f1); }
/* Body */
.tbl__row {
border-bottom: 1px solid rgba(0,0,0,0.06);
transition: background 0.1s;
}
.tbl__row:last-child { border-bottom: none; }
.tbl__row:hover { background: rgba(0,0,0,0.02); }
.tbl--striped .tbl__row--even { background: rgba(0,0,0,0.02); }
.tbl__td {
padding: 0.875rem 1rem;
color: var(--color-primary, #0a0a0a);
vertical-align: middle;
}
.tbl--sm .tbl__td { padding: 0.5rem 0.75rem; }
.tbl--lg .tbl__td { padding: 1.125rem 1.25rem; }
.tbl__td--center { text-align: center; }
.tbl__td--right { text-align: right; }
/* Bordered */
.tbl--bordered .tbl__th,
.tbl--bordered .tbl__td { border: 1px solid rgba(0,0,0,0.08); }
/* Minimal */
.tbl--minimal .tbl__th { background: none; border-bottom: 2px solid rgba(0,0,0,0.1); }
.tbl--minimal .tbl__row { border-bottom: 1px solid rgba(0,0,0,0.06); }
/* Empty */
.tbl__empty {
text-align: center;
padding: 3rem;
color: var(--color-muted, #6b7280);
font-size: 0.9375rem;
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
columns * | Column[] | — | Kolom definities: key, label, align, width, sortable |
rows * | Record<string, unknown>[] | — | Data rijen — key matcht column.key |
variant | 'default' | 'striped' | 'bordered' | 'minimal' | 'default' | Visuele stijl |
size | 'sm' | 'md' | 'lg' | 'md' | Cel padding grootte |
stickyHeader | boolean | false | Header blijft zichtbaar bij scrollen |
caption | string | — | Tabel onderschrift |
* = verplicht