Zoeken...  ⌘K GitHub

Table UI Elements

Responsive datatable. Klikbare sort per kolom, striped/bordered/minimal varianten, sticky header.

/table
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